diff --git a/bindings/AgentState.ts b/bindings/AgentState.ts index f704eb35..4806033f 100644 --- a/bindings/AgentState.ts +++ b/bindings/AgentState.ts @@ -81,4 +81,8 @@ dev_server_pid: number | null, /** * Path to the git worktree for this ticket (per-ticket isolation) */ -worktree_path: string | null, }; +worktree_path: string | null, +/** + * Name of the `RemoteHost` this agent's CLI runs on over SSH (None = local) + */ +remote_host: string | null, }; diff --git a/bindings/Config.ts b/bindings/Config.ts index eed10427..079b4682 100644 --- a/bindings/Config.ts +++ b/bindings/Config.ts @@ -14,6 +14,7 @@ import type { NotificationsConfig } from "./NotificationsConfig"; import type { PathsConfig } from "./PathsConfig"; import type { QueueConfig } from "./QueueConfig"; import type { RelayConfig } from "./RelayConfig"; +import type { RemoteHost } from "./RemoteHost"; import type { RestApiConfig } from "./RestApiConfig"; import type { SessionsConfig } from "./SessionsConfig"; import type { TemplatesConfig } from "./TemplatesConfig"; @@ -47,6 +48,11 @@ delegators: Array, * Implicit builtin servers exist for each `llm_tool`'s vendor API and do not need declaration. */ model_servers: Array, +/** + * Remote machines agents can be launched on over SSH, referenced by name + * from `DelegatorLaunchConfig.host`. + */ +hosts: Array, /** * Relay MCP injection configuration */ diff --git a/bindings/DelegatorLaunchConfig.ts b/bindings/DelegatorLaunchConfig.ts index 329b15c1..d640e63a 100644 --- a/bindings/DelegatorLaunchConfig.ts +++ b/bindings/DelegatorLaunchConfig.ts @@ -42,4 +42,9 @@ prompt_suffix: string | null, /** * Override global relay auto-inject MCP setting per-delegator (None = use global setting) */ -operator_relay: boolean | null, }; +operator_relay: boolean | null, +/** + * Name of a declared `RemoteHost` (from `Config.hosts`) to launch the agent + * CLI on over SSH. `None` = launch locally. + */ +host?: string | null, }; diff --git a/bindings/DelegatorLaunchConfigDto.ts b/bindings/DelegatorLaunchConfigDto.ts index d43e8aef..586c0ea5 100644 --- a/bindings/DelegatorLaunchConfigDto.ts +++ b/bindings/DelegatorLaunchConfigDto.ts @@ -42,4 +42,8 @@ prompt_suffix?: string | null, /** * Override global relay auto-inject MCP setting per-delegator (None = use global setting) */ -operator_relay?: boolean | null, }; +operator_relay?: boolean | null, +/** + * Name of a declared `RemoteHost` to launch the agent CLI on over SSH (None = local) + */ +host?: string | null, }; diff --git a/bindings/KanbanConfig.ts b/bindings/KanbanConfig.ts index 14c40172..dffef89a 100644 --- a/bindings/KanbanConfig.ts +++ b/bindings/KanbanConfig.ts @@ -2,6 +2,7 @@ import type { GithubProjectsConfig } from "./GithubProjectsConfig"; import type { JiraConfig } from "./JiraConfig"; import type { LinearConfig } from "./LinearConfig"; +import type { OpenspecConfig } from "./OpenspecConfig"; /** * Kanban provider configuration for syncing issues from external systems @@ -28,4 +29,10 @@ linear: { [key in string]: LinearConfig }, * branches. The two use different env vars and different scopes — see * `docs/getting-started/kanban/github.md` for the full disambiguation. */ -github: { [key in string]: GithubProjectsConfig }, }; +github: { [key in string]: GithubProjectsConfig }, +/** + * `OpenSpec` roots keyed by a free-form instance name (e.g., a repo alias). + * Experimental, pull-only: each active change under `/changes/` + * acts as a kanban "project" whose issues are the tasks.md task groups. + */ +openspec: { [key in string]: OpenspecConfig }, }; diff --git a/bindings/KanbanProviderKind.ts b/bindings/KanbanProviderKind.ts index ee006878..fe6d16f9 100644 --- a/bindings/KanbanProviderKind.ts +++ b/bindings/KanbanProviderKind.ts @@ -3,4 +3,4 @@ /** * Which kanban provider an onboarding request targets. */ -export type KanbanProviderKind = "jira" | "linear" | "github"; +export type KanbanProviderKind = "jira" | "linear" | "github" | "openspec"; diff --git a/bindings/ListKanbanProjectsRequest.ts b/bindings/ListKanbanProjectsRequest.ts index c2a7868b..3c9852ea 100644 --- a/bindings/ListKanbanProjectsRequest.ts +++ b/bindings/ListKanbanProjectsRequest.ts @@ -3,8 +3,9 @@ import type { GithubCredentials } from "./GithubCredentials"; import type { JiraCredentials } from "./JiraCredentials"; import type { KanbanProviderKind } from "./KanbanProviderKind"; import type { LinearCredentials } from "./LinearCredentials"; +import type { OpenspecSourceDto } from "./OpenspecSourceDto"; /** * Request to list projects/teams from a provider using ephemeral creds. */ -export type ListKanbanProjectsRequest = { provider: KanbanProviderKind, jira?: JiraCredentials | null, linear?: LinearCredentials | null, github?: GithubCredentials | null, }; +export type ListKanbanProjectsRequest = { provider: KanbanProviderKind, jira?: JiraCredentials | null, linear?: LinearCredentials | null, github?: GithubCredentials | null, openspec?: OpenspecSourceDto | null, }; diff --git a/bindings/ListKanbanStatusesRequest.ts b/bindings/ListKanbanStatusesRequest.ts index 682eb237..fdbb2b50 100644 --- a/bindings/ListKanbanStatusesRequest.ts +++ b/bindings/ListKanbanStatusesRequest.ts @@ -3,6 +3,7 @@ import type { GithubCredentials } from "./GithubCredentials"; import type { JiraCredentials } from "./JiraCredentials"; import type { KanbanProviderKind } from "./KanbanProviderKind"; import type { LinearCredentials } from "./LinearCredentials"; +import type { OpenspecSourceDto } from "./OpenspecSourceDto"; /** * Request to list workflow statuses/columns for a specific project using @@ -12,4 +13,4 @@ export type ListKanbanStatusesRequest = { provider: KanbanProviderKind, /** * Project/team key to list statuses for */ -project_key: string, jira?: JiraCredentials | null, linear?: LinearCredentials | null, github?: GithubCredentials | null, }; +project_key: string, jira?: JiraCredentials | null, linear?: LinearCredentials | null, github?: GithubCredentials | null, openspec?: OpenspecSourceDto | null, }; diff --git a/bindings/OpenspecConfig.ts b/bindings/OpenspecConfig.ts new file mode 100644 index 00000000..ca8978b2 --- /dev/null +++ b/bindings/OpenspecConfig.ts @@ -0,0 +1,21 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * `OpenSpec` provider configuration (experimental, pull-only) + * + * The instance name is the `HashMap` key in `KanbanConfig.openspec`. There + * are no credentials — the provider reads local markdown under `root_path`. + */ +export type OpenspecConfig = { +/** + * Whether this provider is enabled + */ +enabled: boolean, +/** + * Directory containing the `OpenSpec` `changes/` tree (typically `/openspec`) + */ +root_path: string, +/** + * Operator project stamped on imported tickets (defaults to the change id) + */ +project?: string | null, }; diff --git a/bindings/OpenspecSourceDto.ts b/bindings/OpenspecSourceDto.ts new file mode 100644 index 00000000..a00f36fd --- /dev/null +++ b/bindings/OpenspecSourceDto.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * `OpenSpec` source location supplied during onboarding. Not a credential — + * `OpenSpec` reads local markdown; there is no secret to validate or store. + */ +export type OpenspecSourceDto = { +/** + * Directory containing the `OpenSpec` `changes/` tree (e.g. "/repo/openspec") + */ +root_path: string, }; diff --git a/bindings/ProjectSyncConfig.ts b/bindings/ProjectSyncConfig.ts index b8b26074..2bc2c809 100644 --- a/bindings/ProjectSyncConfig.ts +++ b/bindings/ProjectSyncConfig.ts @@ -31,4 +31,9 @@ type_mappings: { [key in string]: string }, * Ticket state changes (todo→doing, doing→done) and step completions with delegator info * are reflected upstream. Default: false. */ -bidirectional: boolean, }; +bidirectional: boolean, +/** + * Operator project name stamped on tickets created from this source. + * Defaults to the external project key when unset. + */ +ticket_project?: string | null, }; diff --git a/bindings/RemoteHost.ts b/bindings/RemoteHost.ts new file mode 100644 index 00000000..1acc12f6 --- /dev/null +++ b/bindings/RemoteHost.ts @@ -0,0 +1,27 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A named remote machine that agent CLI processes can be launched on over SSH. + * + * Distinct from [`ModelServer`] (where model *inference* lives) and from + * [`RemoteAgentRef`] (an export-only agent owned by another platform): a + * `RemoteHost` is where the agent *CLI process* runs. Referenced by name from + * [`DelegatorLaunchConfig::host`]. + */ +export type RemoteHost = { +/** + * Unique name referenced by `DelegatorLaunchConfig.host` (e.g., "gpu-vm") + */ +name: string, +/** + * SSH destination, resolved via the user's `~/.ssh/config` + */ +ssh_alias: string, +/** + * Absolute path to the project root on the remote host + */ +workdir: string, +/** + * Optional display name for UI + */ +display_name: string | null, }; diff --git a/bindings/ValidateKanbanCredentialsRequest.ts b/bindings/ValidateKanbanCredentialsRequest.ts index f230f2f1..b94ecced 100644 --- a/bindings/ValidateKanbanCredentialsRequest.ts +++ b/bindings/ValidateKanbanCredentialsRequest.ts @@ -3,8 +3,9 @@ import type { GithubCredentials } from "./GithubCredentials"; import type { JiraCredentials } from "./JiraCredentials"; import type { KanbanProviderKind } from "./KanbanProviderKind"; import type { LinearCredentials } from "./LinearCredentials"; +import type { OpenspecSourceDto } from "./OpenspecSourceDto"; /** * Request to validate kanban credentials without persisting them. */ -export type ValidateKanbanCredentialsRequest = { provider: KanbanProviderKind, jira?: JiraCredentials | null, linear?: LinearCredentials | null, github?: GithubCredentials | null, }; +export type ValidateKanbanCredentialsRequest = { provider: KanbanProviderKind, jira?: JiraCredentials | null, linear?: LinearCredentials | null, github?: GithubCredentials | null, openspec?: OpenspecSourceDto | null, }; diff --git a/bindings/WriteKanbanConfigRequest.ts b/bindings/WriteKanbanConfigRequest.ts index 445c618a..8852a472 100644 --- a/bindings/WriteKanbanConfigRequest.ts +++ b/bindings/WriteKanbanConfigRequest.ts @@ -3,6 +3,7 @@ import type { KanbanProviderKind } from "./KanbanProviderKind"; import type { WriteGithubConfigBody } from "./WriteGithubConfigBody"; import type { WriteJiraConfigBody } from "./WriteJiraConfigBody"; import type { WriteLinearConfigBody } from "./WriteLinearConfigBody"; +import type { WriteOpenspecConfigBody } from "./WriteOpenspecConfigBody"; /** * Request to write or upsert a kanban config section. @@ -10,4 +11,4 @@ import type { WriteLinearConfigBody } from "./WriteLinearConfigBody"; * This endpoint does NOT take the secret — only the env var NAME * (`api_key_env`). The secret is set via `/api/v1/kanban/session-env`. */ -export type WriteKanbanConfigRequest = { provider: KanbanProviderKind, jira?: WriteJiraConfigBody | null, linear?: WriteLinearConfigBody | null, github?: WriteGithubConfigBody | null, }; +export type WriteKanbanConfigRequest = { provider: KanbanProviderKind, jira?: WriteJiraConfigBody | null, linear?: WriteLinearConfigBody | null, github?: WriteGithubConfigBody | null, openspec?: WriteOpenspecConfigBody | null, }; diff --git a/bindings/WriteOpenspecConfigBody.ts b/bindings/WriteOpenspecConfigBody.ts new file mode 100644 index 00000000..ffd00574 --- /dev/null +++ b/bindings/WriteOpenspecConfigBody.ts @@ -0,0 +1,18 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Body for writing an `OpenSpec` instance config section. + */ +export type WriteOpenspecConfigBody = { +/** + * Instance name, used as the `[kanban.openspec.]` key + */ +instance: string, +/** + * Directory containing the `OpenSpec` `changes/` tree + */ +root_path: string, +/** + * Operator project stamped on imported tickets (optional) + */ +project?: string | null, }; diff --git a/config/default.toml b/config/default.toml index 73b7622a..146eb0ff 100644 --- a/config/default.toml +++ b/config/default.toml @@ -122,7 +122,7 @@ connect_timeout_ms = 5000 # display_name = "Ollama (local)" # # # OpenRouter: one OpenAI-compatible key fronting 300+ models. -# # Set OPENROUTER_API_KEY in your environment; it is referenced, never stored. +# # Set OPENROUTER_API_KEY in the environment; it is referenced, never stored. # [[model_servers]] # name = "openrouter" # kind = "openrouter" @@ -159,6 +159,28 @@ connect_timeout_ms = 5000 # model = "qwen2.5-coder" # model_server = "ollama-local" +# Remote hosts (where agent CLI processes run, over SSH) +# Distinct from model_servers (where model inference lives): a host is a +# machine Operator launches the agent CLI on, via `ssh ` resolved +# through the ~/.ssh/config. Reference a host by name from a delegator's +# launch_config to run that delegator's agents remotely. The remote host needs +# tmux, the agent CLI on PATH, credentials in its own environment, and the +# project checked out at `workdir`. +# +# [[hosts]] +# name = "gpu-vm" +# ssh_alias = "gpu-vm" +# workdir = "/srv/agents/my-project" +# display_name = "GPU VM" +# +# # A delegator whose agents run on gpu-vm: +# [[delegators]] +# name = "claude-remote" +# llm_tool = "claude" +# model = "opus" +# [delegators.launch_config] +# host = "gpu-vm" + [version_check] # Enable automatic version checking on startup enabled = true diff --git a/docs/cli/index.md b/docs/cli/index.md index e5213666..40f28404 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -81,6 +81,15 @@ Create investigation from external alert | `--severity` | Severity (S0, S1, S2) (default: S1) | | `--project` | Affected project (optional) | +### `import` + +Import tickets from configured kanban providers (jira, linear, github, openspec) + +| Argument/Option | Description | +| --- | --- | +| `` | Provider slug (e.g. openspec). Omit to sync every configured provider | +| `` | Project/change reference (e.g. an `OpenSpec` change id, a Jira project key). Omit to sync all of the provider's configured collections | + ### `create` Create a new ticket from template diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 5512a70c..d7fb849e 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -155,6 +155,7 @@ LLM CLI tool detection and providers projects = [] delegators = [] model_servers = [] +hosts = [] [agents] max_parallel = 5 @@ -293,6 +294,8 @@ token_env = "" [kanban.github] +[kanban.openspec] + [version_check] enabled = true url = "https://operator.untra.io/VERSION" diff --git a/docs/getting-started/kanban/index.md b/docs/getting-started/kanban/index.md index 88464f63..97712215 100644 --- a/docs/getting-started/kanban/index.md +++ b/docs/getting-started/kanban/index.md @@ -14,6 +14,8 @@ Operator integrates with popular issue tracking systems to manage work items for |----------|--------|-------| | [Jira Cloud](/getting-started/kanban/jira/) | Supported | Full API integration | | [Linear](/getting-started/kanban/linear/) | Supported | Full API integration | +| [GitHub Projects](/getting-started/kanban/github/) | Supported | Projects v2 GraphQL integration | +| [OpenSpec](/getting-started/kanban/openspec/) | Experimental | Local spec-driven changes; pull-only | ## How It Works diff --git a/docs/getting-started/kanban/openspec.md b/docs/getting-started/kanban/openspec.md new file mode 100644 index 00000000..b6ea3814 --- /dev/null +++ b/docs/getting-started/kanban/openspec.md @@ -0,0 +1,69 @@ +--- +title: "OpenSpec" +description: "Import OpenSpec spec-driven change tasks as Operator tickets." +layout: doc +--- + +# OpenSpec + +Experimental + +Operator can import work from [**OpenSpec**](https://github.com/Fission-AI/OpenSpec), the spec-driven development (SDD) framework for AI coding assistants. OpenSpec keeps proposed changes as plain-markdown bundles in your repository; Operator turns their task checklists into queued tickets. + +> **Experimental.** This provider is pull-only and file-based. Operator never edits your OpenSpec files — checking off completed tasks in `tasks.md` remains yours (or your agent's) to do. + +## How the mapping works + +OpenSpec stores each proposed change at `openspec/changes//` with a `proposal.md`, an implementation checklist in `tasks.md`, and optional `design.md` + spec deltas. Operator maps that structure onto its kanban model: + +| OpenSpec | Operator | +|----------|----------| +| Active change (`changes//`) | A kanban "project" (the change id is the project key) | +| `## 1. Group` heading in `tasks.md` | One ticket per task group | +| Checklist items under the group | The ticket's task list (embedded in the body) | +| All items checked | Group counts as `done` and is skipped on import | + +Each imported ticket carries `external_provider: openspec` and `external_id: #` in its frontmatter, so re-running an import skips everything already in your queue — imports are idempotent. + +The ticket body includes the change id, the proposal's **Why** section, the group's checklist verbatim, and a pointer to the change directory so agents read the full spec (proposal, design, deltas) before starting. + +## Configuration + +Add an OpenSpec root to `operator.toml`: + +```toml +[kanban.openspec.myrepo] +enabled = true +root_path = "/path/to/your-repo/openspec" # the dir containing changes/ +project = "yourproject" # operator project for imported tickets +``` + +| Setting | Default | Description | +|---------|---------|-------------| +| `enabled` | `false` | Whether this OpenSpec root is active | +| `root_path` | — | Directory containing the OpenSpec `changes/` tree | +| `project` | change id | Operator project stamped on imported tickets | + +No credentials are needed — OpenSpec is local markdown. + +## Importing + +```bash +# Import one change's open task groups as tickets +operator import openspec add-dark-mode + +# Import every active (non-archived) change under all configured roots +operator import openspec + +# Sync all configured kanban providers, OpenSpec included +operator import +``` + +Fully-checked task groups are skipped; unchecked or partially-checked groups become `TASK` tickets (flagged `needs_issuetype_mapping` so you can retype them if desired). Re-running any of these commands only creates tickets for groups not already imported. + +## Limitations + +- **Pull-only.** Ticket completion is not written back to `tasks.md` checkboxes, and Operator cannot create OpenSpec changes. +- **Group granularity.** One ticket per `## N.` task group — individual checklist items are not split into their own tickets. +- **No dependency ordering.** Tickets are queued FIFO in group order; Operator's same-project sequencing keeps them from running concurrently, but there is no hard blocking between groups. +- `design.md` and spec deltas are referenced by path, not ingested. diff --git a/docs/getting-started/sessions/index.md b/docs/getting-started/sessions/index.md index 98d544f6..d6237910 100644 --- a/docs/getting-started/sessions/index.md +++ b/docs/getting-started/sessions/index.md @@ -18,6 +18,7 @@ Operator supports multiple session management backends for running AI coding age | [cmux](/getting-started/sessions/cmux/) | Supported | macOS terminal multiplexer, manages workspaces within cmux | | [Zellij](/getting-started/sessions/zellij/) | Supported | Terminal workspace manager, tab-per-agent model (macOS/Linux) | | [Zed](/getting-started/sessions/zed/) | Supported | Zed editor extension; MCP context server, ACP agent, slash commands | +| [Remote Hosts (SSH)](/getting-started/sessions/remote-hosts/) | Supported | Run agent CLIs on a remote machine over SSH; dashboard stays local | ## How It Works diff --git a/docs/getting-started/sessions/remote-hosts.md b/docs/getting-started/sessions/remote-hosts.md new file mode 100644 index 00000000..d90c65c3 --- /dev/null +++ b/docs/getting-started/sessions/remote-hosts.md @@ -0,0 +1,87 @@ +--- +title: "Remote Hosts (SSH)" +description: "Launch agent CLI processes on a remote machine over SSH while the Operator dashboard stays local." +layout: doc +--- + +# Remote Hosts (SSH) + +Operator can launch an agent's CLI process on a **remote machine** while the +dashboard, queue, and tracking stay local. Declare a `[[hosts]]` entry and +reference it from a delegator's `launch_config`: + +```toml +[[hosts]] +name = "gpu-vm" +ssh_alias = "gpu-vm" # resolved via your ~/.ssh/config +workdir = "/srv/agents/my-project" +display_name = "GPU VM" + +[[delegators]] +name = "claude-remote" +llm_tool = "claude" +model = "opus" +[delegators.launch_config] +host = "gpu-vm" +``` + +A host is deliberately distinct from a [model server](/configuration/): a `[[model_servers]]` entry says where model *inference* lives; a `[[hosts]]` entry says where the agent *CLI process* runs. A remote delegator can combine both. + +## How it works + +The local tmux (or cmux) pane Operator creates runs a generated wrapper script +that: + +1. Ships the prompt file and run script to + `{workdir}/.tickets/operator/` on the host over `ssh` +2. Execs `ssh -t` into a **remote tmux session** (named like the local one, + `op-…`) that runs the agent +3. Opens an SSH **reverse tunnel** for the REST port, so `opr8r` step-completion + callbacks from the remote side reach your local Operator at + `http://localhost:{port}` — the API stays loopback-only on both machines + +Because the tracked pane is local, screen scraping, attach, idle detection, and +send-keys all behave exactly as for local agents. The agent row shows an +`@{host}` annotation in the dashboard. + +## Remote host requirements + +- **SSH access** via an alias in `~/.ssh/config`, with key-based auth. + Connect once manually first (`ssh gpu-vm`) to accept host keys — launches use + `BatchMode`, which cannot answer interactive prompts. +- **tmux** installed on the remote PATH. +- **The agent CLI** (`claude`, `codex`, `gemini`) on the remote PATH, already + authenticated there (e.g. remote `~/.claude` credentials). +- **The project checked out** at `workdir`. +- **API keys in the remote environment**: model-server keys are passed by + reference (`export ANTHROPIC_API_KEY=${YOUR_VAR}`) and expand in the *remote* + shell. Export them in a file sourced by non-interactive shells, or rely on + the CLI's own auth. + +Operator preflights all of this (reachability, tmux, tool, workdir) before +creating any session and fails the launch with a specific message if a check +fails. + +## Disconnects and reconnecting + +If the SSH link drops (laptop sleep, network change), the local pane dies and +the agent shows as dead — but the **remote tmux session and agent survive**. +Relaunch the ticket from the TUI: the wrapper regenerates and +`tmux new-session -A` reattaches the surviving remote session with scrollback +intact. + +## limitations + +- **No git worktrees** for remote agents — the agent works directly in + `workdir`, regardless of `use_worktrees`. +- **No hook signals or artifact detection** (both read the local filesystem); + liveness relies on pane presence and screen content, the same posture cmux + agents have. +- **No relay MCP injection** (the relay hub is a local Unix socket). +- **No docker mode** and **no zellij wrapper** with a remote host — both are + rejected at resolution time. +- **One remote agent per host at a time** is the safe posture: concurrent + agents to the same host would collide on the reverse-tunnel port, and the + second launch fails loudly (`ExitOnForwardFailure`). +- Ticket files live on the local machine; remote agents signal progress through + `opr8r` callbacks rather than moving ticket files. diff --git a/docs/maturity/index.md b/docs/maturity/index.md index 7cf22bed..c55e2d6b 100644 --- a/docs/maturity/index.md +++ b/docs/maturity/index.md @@ -24,6 +24,7 @@ Operator integrates with many providers and tools across several **verticals**. | Jira | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | [Jira](https://operator.untra.io/getting-started/kanban/jira/) | | Linear | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | [Linear](https://operator.untra.io/getting-started/kanban/linear/) | | GitHub Projects | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | [GitHub Projects](https://operator.untra.io/getting-started/kanban/github/) | +| OpenSpec | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | [OpenSpec](https://operator.untra.io/getting-started/kanban/openspec/) | ## Model Provider diff --git a/docs/schemas/config.json b/docs/schemas/config.json index 682a9c35..ceacb8b1 100644 --- a/docs/schemas/config.json +++ b/docs/schemas/config.json @@ -123,7 +123,8 @@ "default": { "jira": {}, "linear": {}, - "github": {} + "github": {}, + "openspec": {} } }, "version_check": { @@ -151,6 +152,14 @@ }, "default": [] }, + "hosts": { + "description": "Remote machines agents can be launched on over SSH, referenced by name\nfrom `DelegatorLaunchConfig.host`.", + "type": "array", + "items": { + "$ref": "#/$defs/RemoteHost" + }, + "default": [] + }, "relay": { "description": "Relay MCP injection configuration", "$ref": "#/$defs/RelayConfig", @@ -1242,6 +1251,14 @@ "$ref": "#/$defs/GithubProjectsConfig" }, "default": {} + }, + "openspec": { + "description": "OpenSpec roots keyed by a free-form instance name (e.g., a repo alias).\nExperimental, pull-only: each active change under `/changes/`\nacts as a kanban \"project\" whose issues are the tasks.md task groups.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/OpenspecConfig" + }, + "default": {} } } }, @@ -1306,6 +1323,13 @@ "description": "When true, operator pushes status changes and activity logs back to this kanban project.\nTicket state changes (todo→doing, doing→done) and step completions with delegator info\nare reflected upstream. Default: false.", "type": "boolean", "default": false + }, + "ticket_project": { + "description": "Operator project name stamped on tickets created from this source.\nDefaults to the external project key when unset.", + "type": [ + "string", + "null" + ] } } }, @@ -1384,6 +1408,29 @@ } } }, + "OpenspecConfig": { + "description": "OpenSpec provider configuration (experimental, pull-only)\n\nThe instance name is the `HashMap` key in `KanbanConfig.openspec`. There\nare no credentials — the provider reads local markdown under `root_path`.", + "type": "object", + "properties": { + "enabled": { + "description": "Whether this provider is enabled", + "type": "boolean", + "default": false + }, + "root_path": { + "description": "Directory containing the OpenSpec `changes/` tree (typically `/openspec`)", + "type": "string", + "default": "" + }, + "project": { + "description": "Operator project stamped on imported tickets (defaults to the change id)", + "type": [ + "string", + "null" + ] + } + } + }, "VersionCheckConfig": { "description": "Version check configuration for automatic update notifications", "type": "object", @@ -1561,6 +1608,13 @@ "null" ], "default": null + }, + "host": { + "description": "Name of a declared `RemoteHost` (from `Config.hosts`) to launch the agent\nCLI on over SSH. `None` = launch locally.", + "type": [ + "string", + "null" + ] } } }, @@ -1632,6 +1686,37 @@ "kind" ] }, + "RemoteHost": { + "description": "A named remote machine that agent CLI processes can be launched on over SSH.\n\nDistinct from [`ModelServer`] (where model *inference* lives) and from\n[`RemoteAgentRef`] (an export-only agent owned by another platform): a\n`RemoteHost` is where the agent *CLI process* runs. Referenced by name from\n[`DelegatorLaunchConfig::host`].", + "type": "object", + "properties": { + "name": { + "description": "Unique name referenced by `DelegatorLaunchConfig.host` (e.g., \"gpu-vm\")", + "type": "string" + }, + "ssh_alias": { + "description": "SSH destination, resolved via the user's `~/.ssh/config`", + "type": "string" + }, + "workdir": { + "description": "Absolute path to the project root on the remote host", + "type": "string" + }, + "display_name": { + "description": "Optional display name for UI", + "type": [ + "string", + "null" + ], + "default": null + } + }, + "required": [ + "name", + "ssh_alias", + "workdir" + ] + }, "RelayConfig": { "description": "Relay MCP injection configuration", "type": "object", diff --git a/docs/schemas/config.md b/docs/schemas/config.md index 60f5df94..e74b48b4 100644 --- a/docs/schemas/config.md +++ b/docs/schemas/config.md @@ -48,6 +48,7 @@ JSON Schema for the Operator configuration file (`config.toml`). | `version_check` | → `VersionCheckConfig` | No | Version check configuration for automatic update notifications | | `delegators` | `array` | No | Agent delegator configurations for autonomous ticket launching | | `model_servers` | `array` | No | User-declared model servers (ollama, lmstudio, any OpenAI-compat host). Implicit builtin servers exist for each `llm_tool`'s vendor API and do not need declaration. | +| `hosts` | `array` | No | Remote machines agents can be launched on over SSH, referenced by name from `DelegatorLaunchConfig.host`. | | `relay` | → `RelayConfig` | No | Relay MCP injection configuration | | `mcp` | → `McpConfig` | No | Model Context Protocol (MCP) server configuration | | `acp` | → `AcpConfig` | No | Agent Client Protocol (ACP) agent configuration | @@ -422,6 +423,7 @@ Providers are keyed by domain/workspace: | `jira` | `object` | No | Jira Cloud instances keyed by domain (e.g., "foobar.atlassian.net") | | `linear` | `object` | No | Linear instances keyed by workspace slug | | `github` | `object` | No | GitHub Projects v2 instances keyed by owner login (user or org) NOTE: This is the *kanban* GitHub integration (Projects v2), distinct from `GitHubConfig` which is the *git provider* used for PRs and branches. The two use different env vars and different scopes — see `docs/getting-started/kanban/github.md` for the full disambiguation. | +| `openspec` | `object` | No | OpenSpec roots keyed by a free-form instance name (e.g., a repo alias). Experimental, pull-only: each active change under `/changes/` acts as a kanban "project" whose issues are the tasks.md task groups. | ### JiraConfig @@ -447,6 +449,7 @@ Per-project/team sync configuration for a kanban provider | `collection_name` | `string` \| `null` | No | Optional `IssueTypeCollection` name this project maps to. Not required for kanban onboarding or sync. | | `type_mappings` | `object` | No | Explicit mapping: kanban issue type ID → operator issue type key (e.g., TASK, FEAT, FIX). Multiple kanban types can map to the same operator template. | | `bidirectional` | `boolean` | No | When true, operator pushes status changes and activity logs back to this kanban project. Ticket state changes (todo→doing, doing→done) and step completions with delegator info are reflected upstream. Default: false. | +| `ticket_project` | `string` \| `null` | No | Operator project name stamped on tickets created from this source. Defaults to the external project key when unset. | ### KanbanStatusMapping @@ -498,6 +501,19 @@ require different OAuth scopes (`project` vs `repo`). See | `api_key_env` | `string` | No | Environment variable name containing the GitHub token (default: `OPERATOR_GITHUB_TOKEN`). The token must have `project` (or `read:project`) scope, NOT just `repo` — see the disambiguation guide in the kanban github docs. | | `projects` | `object` | No | Per-project sync configuration. Keys are `GraphQL` project node IDs. | +### OpenspecConfig + +OpenSpec provider configuration (experimental, pull-only) + +The instance name is the `HashMap` key in `KanbanConfig.openspec`. There +are no credentials — the provider reads local markdown under `root_path`. + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `enabled` | `boolean` | No | Whether this provider is enabled | +| `root_path` | `string` | No | Directory containing the OpenSpec `changes/` tree (typically `/openspec`) | +| `project` | `string` \| `null` | No | Operator project stamped on imported tickets (defaults to the change id) | + ### VersionCheckConfig Version check configuration for automatic update notifications @@ -547,6 +563,7 @@ semantics: `None` = inherit from global config, `Some(true/false)` = override. | `prompt_prefix` | `string` \| `null` | No | Prompt text to prepend before the generated step prompt | | `prompt_suffix` | `string` \| `null` | No | Prompt text to append after the generated step prompt | | `operator_relay` | `boolean` \| `null` | No | Override global relay auto-inject MCP setting per-delegator (None = use global setting) | +| `host` | `string` \| `null` | No | Name of a declared `RemoteHost` (from `Config.hosts`) to launch the agent CLI on over SSH. `None` = launch locally. | ### RemoteAgentRef @@ -584,6 +601,22 @@ in config. | `extra_env` | `object` | No | Additional environment variables set when spawning agents that use this server | | `display_name` | `string` \| `null` | No | Optional display name for UI | +### RemoteHost + +A named remote machine that agent CLI processes can be launched on over SSH. + +Distinct from [`ModelServer`] (where model *inference* lives) and from +[`RemoteAgentRef`] (an export-only agent owned by another platform): a +`RemoteHost` is where the agent *CLI process* runs. Referenced by name from +[`DelegatorLaunchConfig::host`]. + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `name` | `string` | Yes | Unique name referenced by `DelegatorLaunchConfig.host` (e.g., "gpu-vm") | +| `ssh_alias` | `string` | Yes | SSH destination, resolved via the user's `~/.ssh/config` | +| `workdir` | `string` | Yes | Absolute path to the project root on the remote host | +| `display_name` | `string` \| `null` | No | Optional display name for UI | + ### RelayConfig Relay MCP injection configuration diff --git a/docs/schemas/openapi.json b/docs/schemas/openapi.json index 0d903302..e3131b9a 100644 --- a/docs/schemas/openapi.json +++ b/docs/schemas/openapi.json @@ -3461,6 +3461,13 @@ }, "description": "Additional CLI flags" }, + "host": { + "type": [ + "string", + "null" + ], + "description": "Name of a declared `RemoteHost` (from `Config.hosts`) to launch the agent\nCLI on over SSH. `None` = launch locally." + }, "operator_relay": { "type": [ "boolean", @@ -3527,6 +3534,13 @@ }, "description": "Additional CLI flags" }, + "host": { + "type": [ + "string", + "null" + ], + "description": "Name of a declared `RemoteHost` to launch the agent CLI on over SSH (None = local)" + }, "operator_relay": { "type": [ "boolean", @@ -4327,7 +4341,8 @@ "enum": [ "jira", "linear", - "github" + "github", + "openspec" ] }, "KanbanStatusMapping": { @@ -4699,6 +4714,16 @@ } ] }, + "openspec": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/OpenspecSourceDto" + } + ] + }, "provider": { "$ref": "#/components/schemas/KanbanProviderKind" } @@ -4757,6 +4782,16 @@ } ] }, + "openspec": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/OpenspecSourceDto" + } + ] + }, "project_key": { "type": "string", "description": "Project/team key to list statuses for" @@ -5088,6 +5123,19 @@ } } }, + "OpenspecSourceDto": { + "type": "object", + "description": "`OpenSpec` source location supplied during onboarding. Not a credential —\n`OpenSpec` reads local markdown; there is no secret to validate or store.", + "required": [ + "root_path" + ], + "properties": { + "root_path": { + "type": "string", + "description": "Directory containing the `OpenSpec` `changes/` tree (e.g. \"/repo/openspec\")" + } + } + }, "OperatorOutput": { "type": "object", "description": "Standardized agent output for progress tracking and step transitions.\n\nAgents output a status block in their response which is parsed into this structure.\nUsed for progress tracking, loop detection, and intelligent step transitions.", @@ -6357,6 +6405,16 @@ } ] }, + "openspec": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/OpenspecSourceDto" + } + ] + }, "provider": { "$ref": "#/components/schemas/KanbanProviderKind" } @@ -6661,6 +6719,16 @@ } ] }, + "openspec": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/WriteOpenspecConfigBody" + } + ] + }, "provider": { "$ref": "#/components/schemas/KanbanProviderKind" } @@ -6719,6 +6787,31 @@ } } }, + "WriteOpenspecConfigBody": { + "type": "object", + "description": "Body for writing an `OpenSpec` instance config section.", + "required": [ + "instance", + "root_path" + ], + "properties": { + "instance": { + "type": "string", + "description": "Instance name, used as the `[kanban.openspec.]` key" + }, + "project": { + "type": [ + "string", + "null" + ], + "description": "Operator project stamped on imported tickets (optional)" + }, + "root_path": { + "type": "string", + "description": "Directory containing the `OpenSpec` `changes/` tree" + } + } + }, "XOperator": { "type": "object", "description": "The Operator-namespaced half of an [`AgentProfile`] — the fields a Delegator\ncarries that have no shared-core equivalent. AGNT ignores this bag; Operator\nround-trips it losslessly.", diff --git a/docs/schemas/state.json b/docs/schemas/state.json index 929dc128..13705282 100644 --- a/docs/schemas/state.json +++ b/docs/schemas/state.json @@ -253,6 +253,14 @@ "null" ], "default": null + }, + "remote_host": { + "description": "Name of the `RemoteHost` this agent's CLI runs on over SSH (None = local)", + "type": [ + "string", + "null" + ], + "default": null } }, "required": [ diff --git a/docs/schemas/state.md b/docs/schemas/state.md index 27025fa7..ca74a9dd 100644 --- a/docs/schemas/state.md +++ b/docs/schemas/state.md @@ -69,6 +69,7 @@ This file tracks the current state of agents, completed tickets, and system stat | `review_state` | `string` \| `null` | No | Review state for `awaiting_input` agents Values: "`pending_plan`", "`pending_visual`", "`pending_pr_creation`", "`pending_pr_merge`" | | `dev_server_pid` | `integer` \| `null` | No | Server process ID for visual review cleanup (if applicable) | | `worktree_path` | `string` \| `null` | No | Path to the git worktree for this ticket (per-ticket isolation) | +| `remote_host` | `string` \| `null` | No | Name of the `RemoteHost` this agent's CLI runs on over SSH (None = local) | ### CompletedTicket diff --git a/opr8r/src/api.rs b/opr8r/src/api.rs index 2b00d9eb..90a7ef99 100644 --- a/opr8r/src/api.rs +++ b/opr8r/src/api.rs @@ -190,6 +190,23 @@ impl std::fmt::Display for ApiError { impl std::error::Error for ApiError {} +/// Resolve the API base URL by precedence: +/// explicit flag > `OPERATOR_API_URL` env > session-file port > default port. +fn resolve_base_url( + api_url: Option<&str>, + env_url: Option, + session_port: Option, +) -> String { + if let Some(url) = api_url { + return url.to_string(); + } + if let Some(url) = env_url { + return url; + } + let port = session_port.unwrap_or(DEFAULT_API_PORT); + format!("http://localhost:{port}") +} + impl ApiClient { /// Create a new API client with the given base URL pub fn new(base_url: &str) -> Self { @@ -204,23 +221,19 @@ impl ApiClient { } } - /// Discover API endpoint from api-session.json or use default + /// Discover the API endpoint: explicit `--api-url`, then the + /// `OPERATOR_API_URL` env var (set by remote launches so callbacks route + /// through the SSH reverse tunnel), then api-session.json, then default. pub async fn discover(api_url: Option<&str>) -> Result { - if let Some(url) = api_url { - return Ok(Self::new(url)); - } + let env_url = std::env::var("OPERATOR_API_URL").ok(); // Try to read api-session.json (sync is fine for a tiny JSON file) - if let Ok(content) = std::fs::read_to_string(API_SESSION_FILE) { - if let Ok(session) = serde_json::from_str::(&content) { - let url = format!("http://localhost:{}", session.port); - return Ok(Self::new(&url)); - } - } + let session_port = std::fs::read_to_string(API_SESSION_FILE) + .ok() + .and_then(|content| serde_json::from_str::(&content).ok()) + .map(|session| session.port); - // Fall back to default - let url = format!("http://localhost:{}", DEFAULT_API_PORT); - Ok(Self::new(&url)) + Ok(Self::new(&resolve_base_url(api_url, env_url, session_port))) } /// Report step completion to the API with retry logic @@ -282,6 +295,34 @@ impl ApiClient { mod tests { use super::*; + #[test] + fn test_resolve_base_url_explicit_wins() { + let url = resolve_base_url( + Some("http://example.com:9000/"), + Some("http://tunnel:7008".to_string()), + Some(7010), + ); + assert_eq!(url, "http://example.com:9000/"); + } + + #[test] + fn test_resolve_base_url_env_beats_session_file() { + let url = resolve_base_url(None, Some("http://localhost:7008".to_string()), Some(7010)); + assert_eq!(url, "http://localhost:7008"); + } + + #[test] + fn test_resolve_base_url_session_port_beats_default() { + let url = resolve_base_url(None, None, Some(7010)); + assert_eq!(url, "http://localhost:7010"); + } + + #[test] + fn test_resolve_base_url_default() { + let url = resolve_base_url(None, None, None); + assert_eq!(url, "http://localhost:7008"); + } + #[test] fn test_step_complete_request_serialization() { let request = StepCompleteRequest { diff --git a/shared/types.ts b/shared/types.ts index e86ee792..efaaa640 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -290,6 +290,11 @@ delegators: Array, * Implicit builtin servers exist for each `llm_tool`'s vendor API and do not need declaration. */ model_servers: Array, +/** + * Remote machines agents can be launched on over SSH, referenced by name + * from `DelegatorLaunchConfig.host`. + */ +hosts: Array, /** * Relay MCP injection configuration */ @@ -645,7 +650,12 @@ prompt_suffix: string | null, /** * Override global relay auto-inject MCP setting per-delegator (None = use global setting) */ -operator_relay: boolean | null, }; +operator_relay: boolean | null, +/** + * Name of a declared `RemoteHost` (from `Config.hosts`) to launch the agent + * CLI on over SSH. `None` = launch locally. + */ +host?: string | null, }; export type AgentProfile = { /** @@ -881,15 +891,31 @@ dev_server_pid: number | null, /** * Path to the git worktree for this ticket (per-ticket isolation) */ -worktree_path: string | null, }; +worktree_path: string | null, +/** + * Name of the `RemoteHost` this agent's CLI runs on over SSH (None = local) + */ +remote_host: string | null, }; export type CompletedTicket = { ticket_id: string, ticket_type: string, project: string, summary: string, completed_at: string, pr_url: string | null, output_tickets: Array, }; -export type IssueTypeResponse = { key: string, name: string, description: string, mode: string, glyph: string, color: string | null, project_required: boolean, source: string, fields: Array, steps: Array, }; +export type IssueTypeResponse = { key: string, name: string, description: string, mode: string, glyph: string, color: string | null, project_required: boolean, source: string, +/** + * Owning collection under resolution-order lookup + */ +collection?: string, fields: Array, steps: Array, }; -export type IssueTypeSummary = { key: string, name: string, description: string, mode: string, glyph: string, color?: string, source: string, stepCount: number, }; +export type IssueTypeSummary = { key: string, name: string, description: string, mode: string, glyph: string, color?: string, source: string, +/** + * Owning collection under resolution-order lookup + */ +collection?: string, stepCount: number, }; -export type CreateIssueTypeRequest = { key: string, name: string, description: string, mode: string, glyph: string, color: string | null, project_required: boolean, fields: Array, steps: Array, }; +export type CreateIssueTypeRequest = { key: string, name: string, description: string, mode: string, glyph: string, color: string | null, project_required: boolean, fields: Array, steps: Array, +/** + * Target collection (defaults to the active collection) + */ +collection?: string, }; export type UpdateIssueTypeRequest = { name: string | null, description: string | null, mode: string | null, glyph: string | null, color: string | null, project_required: boolean | null, fields: Array | null, steps: Array | null, }; @@ -924,6 +950,34 @@ version?: string | null, * Publisher identifier (present for hosted collections). */ publisher?: string | null, +/** + * Human author/attribution (present for hosted collections). + */ +author?: string | null, +/** + * Link to the collection's source repository or project page. + */ +url?: string | null, +/** + * SPDX license id. + */ +license?: string | null, +/** + * Provenance tier: `official` or `community`. + */ +tier: string, +/** + * Bare filename of the collection's SVG icon, next to its manifest. + */ +icon_path?: string | null, +/** + * ISO-8601 date the collection was first published. + */ +created?: string | null, +/** + * ISO-8601 date of the last substantive revision. + */ +updated?: string | null, /** * Descriptive workflow hints (present for hosted collections). */ @@ -1318,7 +1372,11 @@ prompt_suffix?: string | null, /** * Override global relay auto-inject MCP setting per-delegator (None = use global setting) */ -operator_relay?: boolean | null, }; +operator_relay?: boolean | null, +/** + * Name of a declared `RemoteHost` to launch the agent CLI on over SSH (None = local) + */ +host?: string | null, }; export type LlmTask = { /** diff --git a/src/agents/delegator_resolution.rs b/src/agents/delegator_resolution.rs index 2d0542a1..22233234 100644 --- a/src/agents/delegator_resolution.rs +++ b/src/agents/delegator_resolution.rs @@ -39,6 +39,12 @@ pub enum ResolutionError { platform: String, agent_id: String, }, + #[error( + "Delegator launch_config references unknown host '{0}' (no [[hosts]] entry with that name)" + )] + UnknownRemoteHost(String), + #[error("Remote host '{host}' cannot be combined with {feature} in v1")] + RemoteHostConflict { host: String, feature: &'static str }, } /// Resolve a delegator's `ModelServer`: named lookup if set, else implicit vendor default. @@ -118,11 +124,17 @@ fn adhoc_model_server_env( Ok(crate::api::providers::model_server::env_for_server(&server)) } -/// Apply a delegator's launch config to launch options +/// Apply a delegator's launch config to launch options. +/// +/// Resolves `launch_config.host` against `config.hosts` and enforces the v1 +/// remote-launch constraints: worktrees and relay injection are forced off +/// (both assume the local filesystem), and docker mode or a zellij session +/// wrapper are hard conflicts rather than silent degradations. pub(crate) fn apply_delegator_launch_config( options: &mut LaunchOptions, launch_config: &Option, -) { + config: &Config, +) -> Result<(), ResolutionError> { if let Some(ref lc) = launch_config { options.yolo_mode = options.yolo_mode || lc.yolo; options.extra_flags.clone_from(&lc.flags); @@ -134,7 +146,32 @@ pub(crate) fn apply_delegator_launch_config( options.prompt_prefix.clone_from(&lc.prompt_prefix); options.prompt_suffix.clone_from(&lc.prompt_suffix); options.operator_relay = lc.operator_relay; + + if let Some(ref host_name) = lc.host { + let host = config + .hosts + .iter() + .find(|h| h.name == *host_name) + .cloned() + .ok_or_else(|| ResolutionError::UnknownRemoteHost(host_name.clone()))?; + if options.docker_mode { + return Err(ResolutionError::RemoteHostConflict { + host: host.name, + feature: "docker mode", + }); + } + if config.sessions.wrapper == crate::config::SessionWrapperType::Zellij { + return Err(ResolutionError::RemoteHostConflict { + host: host.name, + feature: "the zellij session wrapper", + }); + } + options.use_worktrees_override = Some(false); + options.operator_relay = Some(false); + options.remote_host = Some(host); + } } + Ok(()) } /// Resolve a default delegator when none is explicitly specified. @@ -200,7 +237,7 @@ pub fn resolve_launch_options( options.provider = Some(delegator_to_provider(config, delegator)?); options.delegator_name = Some(delegator.name.clone()); - apply_delegator_launch_config(&mut options, &delegator.launch_config); + apply_delegator_launch_config(&mut options, &delegator.launch_config, config)?; return Ok(options); } @@ -210,7 +247,7 @@ pub fn resolve_launch_options( if let Some(delegator) = resolve_delegator_by_name(config, step_agent) { options.provider = Some(delegator_to_provider(config, delegator)?); options.delegator_name = Some(delegator.name.clone()); - apply_delegator_launch_config(&mut options, &delegator.launch_config); + apply_delegator_launch_config(&mut options, &delegator.launch_config, config)?; return Ok(options); } // Step agent name doesn't match any delegator — fall through @@ -221,7 +258,7 @@ pub fn resolve_launch_options( if let Some(delegator) = resolve_delegator_by_name(config, it_agent) { options.provider = Some(delegator_to_provider(config, delegator)?); options.delegator_name = Some(delegator.name.clone()); - apply_delegator_launch_config(&mut options, &delegator.launch_config); + apply_delegator_launch_config(&mut options, &delegator.launch_config, config)?; return Ok(options); } } @@ -272,7 +309,7 @@ pub fn resolve_launch_options( if let Some(delegator) = resolve_default_delegator(config) { options.provider = Some(delegator_to_provider(config, delegator)?); options.delegator_name = Some(delegator.name.clone()); - apply_delegator_launch_config(&mut options, &delegator.launch_config); + apply_delegator_launch_config(&mut options, &delegator.launch_config, config)?; return Ok(options); } @@ -377,6 +414,124 @@ mod tests { assert!(matches!(err, ResolutionError::UnknownModelServer(_))); } + fn make_remote_config(host_name: &str) -> Config { + let mut config = Config::default(); + config.hosts.push(crate::config::RemoteHost { + name: host_name.to_string(), + ssh_alias: "vm-alias".to_string(), + workdir: "/srv/agents".to_string(), + display_name: None, + }); + let mut d = make_delegator("claude-remote", "claude", "opus"); + d.launch_config = Some(DelegatorLaunchConfig { + host: Some(host_name.to_string()), + ..Default::default() + }); + config.delegators.push(d); + config + } + + #[test] + fn test_resolve_known_host_populates_remote_host() { + let config = make_remote_config("gpu-vm"); + let options = resolve_launch_options( + &config, + Some("claude-remote"), + None, + None, + None, + false, + None, + ) + .unwrap(); + let host = options.remote_host.expect("remote host resolved"); + assert_eq!(host.name, "gpu-vm"); + assert_eq!(host.ssh_alias, "vm-alias"); + assert_eq!(host.workdir, "/srv/agents"); + } + + #[test] + fn test_resolve_unknown_host_errors() { + let mut config = make_remote_config("gpu-vm"); + config.hosts.clear(); + let err = resolve_launch_options( + &config, + Some("claude-remote"), + None, + None, + None, + false, + None, + ) + .unwrap_err(); + assert!(matches!(err, ResolutionError::UnknownRemoteHost(_))); + } + + #[test] + fn test_resolve_no_host_leaves_remote_host_none() { + let mut config = Config::default(); + config + .delegators + .push(make_delegator("local", "claude", "opus")); + let options = + resolve_launch_options(&config, Some("local"), None, None, None, false, None).unwrap(); + assert!(options.remote_host.is_none()); + } + + #[test] + fn test_remote_host_forces_worktrees_and_relay_off() { + let mut config = make_remote_config("gpu-vm"); + let lc = config.delegators[0].launch_config.as_mut().unwrap(); + lc.use_worktrees = Some(true); + lc.operator_relay = Some(true); + let options = resolve_launch_options( + &config, + Some("claude-remote"), + None, + None, + None, + false, + None, + ) + .unwrap(); + assert_eq!(options.use_worktrees_override, Some(false)); + assert_eq!(options.operator_relay, Some(false)); + } + + #[test] + fn test_remote_host_plus_docker_errors() { + let mut config = make_remote_config("gpu-vm"); + config.delegators[0].launch_config.as_mut().unwrap().docker = Some(true); + let err = resolve_launch_options( + &config, + Some("claude-remote"), + None, + None, + None, + false, + None, + ) + .unwrap_err(); + assert!(matches!(err, ResolutionError::RemoteHostConflict { .. })); + } + + #[test] + fn test_remote_host_plus_zellij_errors() { + let mut config = make_remote_config("gpu-vm"); + config.sessions.wrapper = crate::config::SessionWrapperType::Zellij; + let err = resolve_launch_options( + &config, + Some("claude-remote"), + None, + None, + None, + false, + None, + ) + .unwrap_err(); + assert!(matches!(err, ResolutionError::RemoteHostConflict { .. })); + } + #[test] fn test_resolve_single_delegator_is_default() { let mut config = Config::default(); @@ -494,6 +649,7 @@ mod tests { prompt_prefix: Some("PREFIX".to_string()), prompt_suffix: Some("SUFFIX".to_string()), operator_relay: None, + host: None, }), remote_agent: None, x_agnt: None, diff --git a/src/agents/launcher/cmux_session.rs b/src/agents/launcher/cmux_session.rs index 647059b7..a56106f5 100644 --- a/src/agents/launcher/cmux_session.rs +++ b/src/agents/launcher/cmux_session.rs @@ -148,6 +148,39 @@ pub fn launch_in_cmux_with_options( // Write prompt to file let prompt_file = write_prompt_file(config, &session_uuid, &full_prompt)?; + // Remote launch: the local workspace runs a wrapper that ships the prompt + // and payload over SSH and execs into a remote tmux session through a + // reverse tunnel. Relay injection is skipped (unix socket is local-only). + if let Some(ref host) = options.remote_host { + let session_name = super::remote::launch_remote_in_session( + config, + ticket, + &session_name, + &session_uuid, + &step_name, + host, + &tool_name, + &model, + &prompt_file, + options, + operator_env, + false, + |cmd| { + cmux.send_text(&workspace_ref, &format!("{cmd}\r")) + .map_err(|e| anyhow::anyhow!("{e}")) + }, + || { + let _ = cmux.close_workspace(&workspace_ref); + }, + )?; + return Ok(CmuxLaunchResult { + session_name, + window_ref, + workspace_ref, + session_uuid, + }); + } + // Build LLM command let mut llm_cmd = build_llm_command_with_permissions_for_tool( config, diff --git a/src/agents/launcher/mod.rs b/src/agents/launcher/mod.rs index 0d09ba2a..693b7563 100644 --- a/src/agents/launcher/mod.rs +++ b/src/agents/launcher/mod.rs @@ -10,6 +10,7 @@ pub mod interpolation; pub(crate) mod llm_command; mod options; pub(crate) mod prompt; +pub(crate) mod remote; mod step_config; mod tmux_session; pub mod worktree_setup; @@ -341,6 +342,16 @@ impl Launcher { ui_port: self.config.rest_api.port, }; + // Remote launches: verify the host is reachable and provisioned before + // any session or workspace is created. + if let Some(ref host) = options.remote_host { + let tool = options + .provider + .as_ref() + .map_or("claude", |p| p.tool.as_str()); + remote::run_preflight(host, tool)?; + } + // Dispatch based on session wrapper type let (session_name, wrapper_name, cmux_refs) = if self.config.sessions.wrapper == SessionWrapperType::Cmux { @@ -436,6 +447,11 @@ impl Launcher { state.update_agent_worktree_path(&agent_id, worktree_path)?; } + // Store remote host so the dashboard can annotate the agent + if let Some(ref host) = options.remote_host { + state.update_agent_remote_host(&agent_id, &host.name)?; + } + // Set the current step in state if !ticket.step.is_empty() { state.update_agent_step(&agent_id, &ticket.step)?; @@ -504,7 +520,9 @@ impl Launcher { crate::agents::delegator_resolution::apply_delegator_launch_config( &mut opts, &delegator.launch_config, - ); + &self.config, + ) + .map_err(|e| anyhow::anyhow!("{e}"))?; opts.session_suffix = Some(variant_key.to_string()); Ok(opts) } @@ -1341,6 +1359,17 @@ impl Launcher { ui_port: self.config.rest_api.port, }; + // Remote relaunches preflight too: the wrapper is regenerated and the + // remote session reattached, so the host must still be reachable. + if let Some(ref host) = options.launch_options.remote_host { + let tool = options + .launch_options + .provider + .as_ref() + .map_or("claude", |p| p.tool.as_str()); + remote::run_preflight(host, tool)?; + } + // Dispatch based on session wrapper type let (session_name, wrapper_name, cmux_refs) = if self.config.sessions.wrapper == SessionWrapperType::Cmux { @@ -1436,6 +1465,11 @@ impl Launcher { state.update_agent_worktree_path(&agent_id, worktree_path)?; } + // Store remote host so the dashboard can annotate the agent + if let Some(ref host) = options.launch_options.remote_host { + state.update_agent_remote_host(&agent_id, &host.name)?; + } + // Set the current step in state if !ticket.step.is_empty() { state.update_agent_step(&agent_id, &ticket.step)?; diff --git a/src/agents/launcher/options.rs b/src/agents/launcher/options.rs index d1f21f44..471b8830 100644 --- a/src/agents/launcher/options.rs +++ b/src/agents/launcher/options.rs @@ -31,6 +31,8 @@ pub struct LaunchOptions { pub session_suffix: Option, /// Enable relay MCP server injection for this launch (None = use global config) pub operator_relay: Option, + /// Resolved remote host to launch the agent CLI on over SSH (None = local). + pub remote_host: Option, } impl LaunchOptions { @@ -64,6 +66,7 @@ mod tests { prompt_suffix: None, session_suffix: None, operator_relay: Some(false), + remote_host: None, }; assert_eq!(opts.operator_relay, Some(false)); } diff --git a/src/agents/launcher/remote.rs b/src/agents/launcher/remote.rs new file mode 100644 index 00000000..12683a92 --- /dev/null +++ b/src/agents/launcher/remote.rs @@ -0,0 +1,445 @@ +//! Remote (SSH) launch support. +//! +//! v1 execution shape: the *local* multiplexer pane runs a generated wrapper +//! script that ships the prompt and payload to the remote host, then execs +//! `ssh -t` into a *remote* tmux session running the agent. The pane Operator +//! tracks stays local (scraping/attach/send-keys unchanged); the remote tmux +//! session survives disconnects and `tmux new-session -A` reattaches on +//! relaunch. Completion callbacks flow through an SSH reverse tunnel via +//! `OPERATOR_API_URL`, keeping the REST API loopback-only on both ends. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +use crate::config::{Config, RemoteHost}; +use crate::llm::tool_config::load_all_tool_configs; + +use super::prompt::shell_escape; + +/// Remote path the prompt file is shipped to. +pub(crate) fn remote_prompt_path(host: &RemoteHost, session_uuid: &str) -> String { + format!( + "{}/.tickets/operator/prompts/{session_uuid}.txt", + host.workdir + ) +} + +/// Remote path the payload (run) script is shipped to. +pub(crate) fn remote_payload_path(host: &RemoteHost, session_uuid: &str) -> String { + format!( + "{}/.tickets/operator/commands/{session_uuid}.sh", + host.workdir + ) +} + +/// Build the agent CLI command executed on the remote host. +/// +/// Uses the *embedded* tool config template (bare tool name, resolved via the +/// remote PATH) rather than the locally detected binary path, which would be +/// wrong on the remote machine. `{{config_flags}}` is dropped: permission +/// translation, MCP config, and statusline all write local files with local +/// paths — an accepted v1 degradation. +pub(crate) fn build_remote_llm_command( + tool_name: &str, + model: &str, + session_id: &str, + remote_prompt: &str, + yolo: bool, + extra_flags: &[String], +) -> Result { + let tool = load_all_tool_configs() + .into_iter() + .find(|t| t.tool_name == tool_name) + .ok_or_else(|| { + anyhow::anyhow!( + "LLM tool '{tool_name}' has no embedded tool config; remote launch supports claude/codex/gemini" + ) + })?; + + let model_flag = if tool.arg_mapping.model.is_empty() { + String::new() + } else { + format!("{} {model} ", tool.arg_mapping.model) + }; + + let mut cmd = tool + .command_template + .replace("{{config_flags}}", "") + .replace("{{model_flag}}", &model_flag) + .replace("{{model}}", model) + .replace("{{session_id}}", session_id) + .replace("{{prompt_file}}", remote_prompt); + + if yolo && !tool.yolo_flags.is_empty() { + if let Some(pos) = cmd.find(tool_name) { + let insert_pos = pos + tool_name.len(); + cmd.insert_str(insert_pos, &format!(" {}", tool.yolo_flags.join(" "))); + } + } + + if !extra_flags.is_empty() { + cmd = format!("{cmd} {}", extra_flags.join(" ")); + } + + Ok(cmd) +} + +/// Build the local wrapper script content: ship prompt + payload over SSH, +/// then exec into the remote tmux session through a reverse tunnel. +/// +/// Payloads travel as files via `ssh 'cat > …'` (portable on SFTP-only hosts, +/// and no payload quoting at all); only the short tmux line is quoted, through +/// exactly one escaping layer per shell that parses it. `exec` makes the pane +/// process *be* ssh, so pane-death ⇔ ssh-death and monitor semantics are +/// unchanged. `-A` makes relaunch idempotent (reattaches a surviving session). +/// `ExitOnForwardFailure` turns tunnel-port collisions into loud launch +/// failures instead of silently broken callbacks. The remote status bar is +/// switched off so its clock doesn't defeat content-hash idle detection. +pub(crate) fn build_remote_wrapper_script( + host: &RemoteHost, + session_name: &str, + session_uuid: &str, + local_prompt: &Path, + local_payload: &Path, + api_port: u16, +) -> String { + let alias = shell_escape(&host.ssh_alias); + let r_prompt = remote_prompt_path(host, session_uuid); + let r_payload = remote_payload_path(host, session_uuid); + + let mkdir_cmd = format!( + "mkdir -p {} {}", + shell_escape(&format!("{}/.tickets/operator/prompts", host.workdir)), + shell_escape(&format!("{}/.tickets/operator/commands", host.workdir)), + ); + let tmux_cmd = format!( + "tmux new-session -A -s {} {} \\; set-option status off", + shell_escape(session_name), + shell_escape(&format!("bash {}", shell_escape(&r_payload))), + ); + + format!( + "#!/bin/bash\nset -e\nssh {alias} {mkdir}\nssh {alias} {cat_prompt} < {local_prompt}\nssh {alias} {cat_payload} < {local_payload}\nexec ssh -t -R {port}:localhost:{port} -o ExitOnForwardFailure=yes {alias} {tmux}\n", + alias = alias, + mkdir = shell_escape(&mkdir_cmd), + cat_prompt = shell_escape(&format!("cat > {}", shell_escape(&r_prompt))), + cat_payload = shell_escape(&format!("cat > {}", shell_escape(&r_payload))), + local_prompt = shell_escape(&local_prompt.display().to_string()), + local_payload = shell_escape(&local_payload.display().to_string()), + port = api_port, + tmux = shell_escape(&tmux_cmd), + ) +} + +/// Write the wrapper script to `.tickets/operator/commands/{uuid}-remote.sh`. +/// +/// Regeneration is idempotent (keyed by session uuid) so relaunch can rebuild +/// it and `tmux new-session -A` reattaches the surviving remote session. +pub(crate) fn write_remote_wrapper_file( + config: &Config, + session_uuid: &str, + content: &str, +) -> Result { + let commands_dir = config.tickets_path().join("operator/commands"); + std::fs::create_dir_all(&commands_dir).context("Failed to create commands directory")?; + let wrapper_file = commands_dir.join(format!("{session_uuid}-remote.sh")); + std::fs::write(&wrapper_file, content).context("Failed to write remote wrapper script")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&wrapper_file, std::fs::Permissions::from_mode(0o755)) + .context("Failed to set remote wrapper permissions")?; + } + Ok(wrapper_file) +} + +/// Shared remote-launch tail for session backends (tmux, cmux). +/// +/// Builds the remote agent command, writes the payload script (cd'ing to the +/// *remote* workdir, exporting `OPERATOR_API_URL` for the reverse tunnel) and +/// the local wrapper, then types `bash {wrapper}` into the already-created +/// local session via `send`. `cleanup` tears the session down if that fails. +#[allow(clippy::too_many_arguments)] // Cohesive launch context; a struct would just relabel it. +pub(crate) fn launch_remote_in_session( + config: &Config, + ticket: &crate::queue::Ticket, + session_name: &str, + session_uuid: &str, + step_name: &str, + host: &RemoteHost, + tool_name: &str, + model: &str, + prompt_file: &Path, + options: &super::options::LaunchOptions, + operator_env: &super::prompt::OperatorEnvVars, + is_resume: bool, + send: impl FnOnce(&str) -> Result<()>, + cleanup: impl FnOnce(), +) -> Result { + let r_prompt = remote_prompt_path(host, session_uuid); + let mut llm_cmd = build_remote_llm_command( + tool_name, + model, + session_uuid, + &r_prompt, + options.yolo_mode, + &options.extra_flags, + )?; + + // Resume the existing agent session if the remote tmux session died and + // the payload actually runs fresh (a surviving session ignores it via -A). + if is_resume { + if let Some(pos) = llm_cmd.find(tool_name) { + let insert_pos = pos + tool_name.len(); + llm_cmd.insert_str(insert_pos, &format!(" --resume {session_uuid}")); + } + } + + let mut provider_env = options + .provider + .as_ref() + .map(|p| p.env.clone()) + .unwrap_or_default(); + provider_env.insert( + "OPERATOR_API_URL".to_string(), + format!("http://localhost:{}", operator_env.ui_port), + ); + + let payload_file = super::prompt::write_command_file( + config, + session_uuid, + &host.workdir, + &llm_cmd, + Some(operator_env), + Some(&provider_env), + )?; + + let wrapper_content = build_remote_wrapper_script( + host, + session_name, + session_uuid, + prompt_file, + &payload_file, + operator_env.ui_port, + ); + let wrapper_file = write_remote_wrapper_file(config, session_uuid, &wrapper_content)?; + + let bash_cmd = format!("bash {}", wrapper_file.display()); + if let Err(e) = send(&bash_cmd) { + cleanup(); + anyhow::bail!("Failed to start remote agent in session: {e}"); + } + + tracing::info!( + session = %session_name, + session_uuid = %session_uuid, + project = %ticket.project, + ticket = %ticket.id, + step = %step_name, + tool = %tool_name, + host = %host.name, + remote_workdir = %host.workdir, + wrapper_file = %wrapper_file.display(), + "Launched agent on remote host" + ); + + Ok(session_name.to_string()) +} + +/// Distinct preflight failure exit codes used by [`preflight_script`]. +const PREFLIGHT_NO_TMUX: i32 = 40; +const PREFLIGHT_NO_TOOL: i32 = 41; +const PREFLIGHT_NO_WORKDIR: i32 = 42; + +/// The check script run on the remote host by [`run_preflight`]. +fn preflight_script(host: &RemoteHost, tool_name: &str) -> String { + format!( + "command -v tmux >/dev/null || exit {PREFLIGHT_NO_TMUX}; command -v {tool} >/dev/null || exit {PREFLIGHT_NO_TOOL}; test -d {workdir} || exit {PREFLIGHT_NO_WORKDIR}", + tool = shell_escape(tool_name), + workdir = shell_escape(&host.workdir), + ) +} + +/// Check the remote host can run the agent before any session is created: +/// reachable over SSH (`BatchMode` so a password prompt can't wedge the TUI), +/// tmux and the tool on the remote PATH, and the workdir present. +pub(crate) fn run_preflight(host: &RemoteHost, tool_name: &str) -> Result<()> { + let status = std::process::Command::new("ssh") + .args(["-o", "BatchMode=yes", "-o", "ConnectTimeout=5"]) + .arg(&host.ssh_alias) + .arg(preflight_script(host, tool_name)) + .status() + .context("Failed to run ssh for remote preflight")?; + + match status.code() { + Some(0) => Ok(()), + Some(c) if c == PREFLIGHT_NO_TMUX => anyhow::bail!( + "Remote host '{}' has no tmux on PATH; install tmux there first", + host.name + ), + Some(c) if c == PREFLIGHT_NO_TOOL => anyhow::bail!( + "Remote host '{}' has no '{tool_name}' on PATH; install the agent CLI there first", + host.name + ), + Some(c) if c == PREFLIGHT_NO_WORKDIR => anyhow::bail!( + "Remote host '{}' is missing workdir '{}'; check out the project there first", + host.name, + host.workdir + ), + _ => anyhow::bail!( + "Cannot reach remote host '{}' via `ssh {}` (BatchMode). Verify the alias in ~/.ssh/config and connect once manually to accept host keys", + host.name, + host.ssh_alias + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn host() -> RemoteHost { + RemoteHost { + name: "gpu-vm".to_string(), + ssh_alias: "gpu-alias".to_string(), + workdir: "/srv/agents/proj".to_string(), + display_name: None, + } + } + + #[test] + fn test_remote_paths_are_under_workdir() { + let h = host(); + assert_eq!( + remote_prompt_path(&h, "uuid-1"), + "/srv/agents/proj/.tickets/operator/prompts/uuid-1.txt" + ); + assert_eq!( + remote_payload_path(&h, "uuid-1"), + "/srv/agents/proj/.tickets/operator/commands/uuid-1.sh" + ); + } + + #[test] + fn test_build_remote_llm_command_uses_bare_tool_and_remote_prompt() { + let cmd = build_remote_llm_command( + "claude", + "opus", + "uuid-1", + "/srv/agents/proj/.tickets/operator/prompts/uuid-1.txt", + false, + &[], + ) + .unwrap(); + assert!(cmd.starts_with("claude "), "bare tool name, got: {cmd}"); + assert!(cmd.contains("--session-id uuid-1")); + assert!(cmd.contains("/srv/agents/proj/.tickets/operator/prompts/uuid-1.txt")); + assert!( + !cmd.contains("{{"), + "all template variables substituted, got: {cmd}" + ); + } + + #[test] + fn test_build_remote_llm_command_applies_yolo_and_extra_flags() { + let cmd = build_remote_llm_command( + "claude", + "opus", + "uuid-1", + "/tmp/p.txt", + true, + &["--verbose".to_string()], + ) + .unwrap(); + assert!(cmd.contains("--dangerously-skip-permissions")); + assert!(cmd.ends_with("--verbose")); + } + + #[test] + fn test_build_remote_llm_command_unknown_tool_errors() { + let err = build_remote_llm_command("agy", "m", "s", "/p", false, &[]).unwrap_err(); + assert!(err.to_string().contains("no embedded tool config")); + } + + #[test] + fn test_wrapper_ships_files_then_execs_tunneled_tmux() { + let h = host(); + let script = build_remote_wrapper_script( + &h, + "op-FEAT-42", + "uuid-1", + Path::new("/local/.tickets/operator/prompts/uuid-1.txt"), + Path::new("/local/.tickets/operator/commands/uuid-1.sh"), + 7008, + ); + assert!(script.starts_with("#!/bin/bash\nset -e\n")); + // Ships both files via `cat >` before the exec line. + assert!(script.contains("cat > ")); + assert!(script.contains("uuid-1.txt")); + assert!(script.contains("uuid-1.sh")); + // Reverse tunnel with loud forward failure. + assert!(script.contains("-R 7008:localhost:7008")); + assert!(script.contains("-o ExitOnForwardFailure=yes")); + // Exec so the pane process is ssh; -A so relaunch reattaches. + assert!(script.contains("exec ssh -t")); + assert!(script.contains("tmux new-session -A -s ")); + assert!(script.contains("op-FEAT-42")); + // Remote status bar off so its clock can't defeat idle detection. + assert!(script.contains("set-option status off")); + let exec_pos = script.find("exec ssh").unwrap(); + let last_cat = script.rfind("cat > ").unwrap(); + assert!(last_cat < exec_pos, "files ship before exec"); + } + + #[test] + fn test_wrapper_escapes_workdir_with_spaces() { + let mut h = host(); + h.workdir = "/srv/agent workdir/proj".to_string(); + let script = build_remote_wrapper_script( + &h, + "op-X", + "u1", + Path::new("/l/p.txt"), + Path::new("/l/c.sh"), + 7008, + ); + // The workdir must never appear unquoted (space-split) in any remote command. + assert!(!script.contains(" /srv/agent workdir/proj/")); + assert!(script.contains("agent workdir")); + } + + #[test] + fn test_preflight_script_distinct_exit_codes() { + let s = preflight_script(&host(), "claude"); + assert!(s.contains("command -v tmux >/dev/null || exit 40")); + assert!(s.contains("command -v 'claude' >/dev/null || exit 41")); + assert!(s.contains("test -d '/srv/agents/proj' || exit 42")); + } + + #[test] + fn test_write_remote_wrapper_file_named_by_uuid() { + use tempfile::tempdir; + let temp = tempdir().unwrap(); + let config = Config { + paths: crate::config::PathsConfig { + tickets: temp.path().to_string_lossy().to_string(), + projects: temp.path().to_string_lossy().to_string(), + state: temp.path().join("operator").to_string_lossy().to_string(), + worktrees: temp.path().join("wt").to_string_lossy().to_string(), + }, + ..Default::default() + }; + let path = write_remote_wrapper_file(&config, "uuid-9", "#!/bin/bash\n").unwrap(); + assert!(path.exists()); + assert_eq!(path.file_name().unwrap(), "uuid-9-remote.sh"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o755 + ); + } + } +} diff --git a/src/agents/launcher/tests.rs b/src/agents/launcher/tests.rs index e9ea915c..7cf0f4ef 100644 --- a/src/agents/launcher/tests.rs +++ b/src/agents/launcher/tests.rs @@ -646,6 +646,74 @@ fn test_launch_in_tmux_sends_cd_command() { ); } +#[test] +fn test_launch_in_tmux_remote_host_sends_wrapper_and_skips_relay() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + let mock = Arc::new(MockTmuxClient::new()); + let tmux: Arc = mock.clone(); + let ticket = make_test_ticket("test-project"); + let project_path = temp_dir + .path() + .join("projects") + .join("test-project") + .to_string_lossy() + .to_string(); + let options = LaunchOptions { + remote_host: Some(crate::config::RemoteHost { + name: "gpu-vm".to_string(), + ssh_alias: "gpu-alias".to_string(), + workdir: "/srv/agents/proj".to_string(), + display_name: None, + }), + ..Default::default() + }; + + let result = launch_in_tmux_with_options( + &config, + &tmux, + &ticket, + &project_path, + "Test prompt", + &options, + &make_test_operator_env(), + ); + + assert!(result.is_ok(), "Remote launch failed: {:?}", result.err()); + let session_name = result.unwrap(); + let keys_sent = mock.get_session_keys_sent(&session_name).unwrap(); + + // Exactly one command typed into the pane: the remote wrapper. No relay + // export line — the relay unix socket is meaningless on a remote host. + assert_eq!(keys_sent.len(), 1, "got: {keys_sent:?}"); + let sent_cmd = keys_sent[0].trim_end_matches(" [Enter]"); + assert!( + sent_cmd.starts_with("bash ") && sent_cmd.ends_with("-remote.sh"), + "pane must run the remote wrapper, got: {sent_cmd}" + ); + + // The wrapper ships files then execs tunneled ssh into remote tmux. + let wrapper_content = read_command_file_content(sent_cmd).expect("wrapper script should exist"); + assert!(wrapper_content.contains("exec ssh -t -R 7008:localhost:7008")); + assert!(wrapper_content.contains("gpu-alias")); + assert!(wrapper_content.contains("tmux new-session -A -s ")); + assert!(wrapper_content.contains(&session_name)); + + // The shipped payload cds to the REMOTE workdir and exports the tunneled + // API URL; its llm command uses the bare tool name and the REMOTE prompt path. + let payload_path = sent_cmd + .trim_start_matches("bash ") + .replace("-remote.sh", ".sh"); + let payload = std::fs::read_to_string(&payload_path).expect("payload script should exist"); + assert!( + payload.contains("cd '/srv/agents/proj'"), + "payload must cd to remote workdir, got: {payload}" + ); + assert!(payload.contains("export OPERATOR_API_URL='http://localhost:7008'")); + assert!(payload.contains("/srv/agents/proj/.tickets/operator/prompts/")); + assert!(payload.contains("exec claude ")); +} + #[test] fn test_launch_in_tmux_sends_llm_command() { let temp_dir = TempDir::new().unwrap(); @@ -1039,6 +1107,75 @@ fn test_relaunch_fresh_start_new_uuid() { assert!(session_name.starts_with("op-")); } +#[test] +fn test_relaunch_remote_resume_reuses_session_and_adds_resume_flag() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + let mock = Arc::new(MockTmuxClient::new()); + let tmux: Arc = mock.clone(); + let ticket = make_test_ticket("test-project"); + let project_path = temp_dir + .path() + .join("projects") + .join("test-project") + .to_string_lossy() + .to_string(); + + // Pre-existing prompt file so resume mode is taken. + let prompts_dir = config.tickets_path().join("operator").join("prompts"); + std::fs::create_dir_all(&prompts_dir).unwrap(); + std::fs::write(prompts_dir.join("resume-uuid-1.txt"), "Old prompt").unwrap(); + + let options = RelaunchOptions { + launch_options: LaunchOptions { + remote_host: Some(crate::config::RemoteHost { + name: "gpu-vm".to_string(), + ssh_alias: "gpu-alias".to_string(), + workdir: "/srv/agents/proj".to_string(), + display_name: None, + }), + ..Default::default() + }, + resume_session_id: Some("resume-uuid-1".to_string()), + retry_reason: None, + }; + + let result = launch_in_tmux_with_relaunch_options( + &config, + &tmux, + &ticket, + &project_path, + "Test prompt", + &options, + &make_test_operator_env(), + ); + + assert!(result.is_ok(), "Remote relaunch failed: {:?}", result.err()); + let session_name = result.unwrap(); + let keys_sent = mock.get_session_keys_sent(&session_name).unwrap(); + + // The pane runs the regenerated remote wrapper (same uuid → same remote + // session name → tmux new -A reattaches a surviving remote session). + assert_eq!(keys_sent.len(), 1, "got: {keys_sent:?}"); + let sent_cmd = keys_sent[0].trim_end_matches(" [Enter]"); + assert!( + sent_cmd.ends_with("resume-uuid-1-remote.sh"), + "wrapper keyed by resumed uuid, got: {sent_cmd}" + ); + + // The shipped payload resumes the existing agent session if the remote + // session died and the command actually runs fresh. + let payload_path = sent_cmd + .trim_start_matches("bash ") + .replace("-remote.sh", ".sh"); + let payload = std::fs::read_to_string(&payload_path).unwrap(); + assert!( + payload.contains("--resume resume-uuid-1"), + "payload must carry the resume flag, got: {payload}" + ); + assert!(payload.contains("cd '/srv/agents/proj'")); +} + #[test] fn test_relaunch_inherits_yolo_mode() { let temp_dir = TempDir::new().unwrap(); diff --git a/src/agents/launcher/tmux_session.rs b/src/agents/launcher/tmux_session.rs index c658444d..70f5fcc6 100644 --- a/src/agents/launcher/tmux_session.rs +++ b/src/agents/launcher/tmux_session.rs @@ -18,6 +18,7 @@ use super::prompt::{ generate_session_uuid, get_agent_prompt, get_template_prompt, write_command_file, write_prompt_file, OperatorEnvVars, }; +use super::remote::launch_remote_in_session; use super::SESSION_PREFIX; /// Launch Claude in a tmux session with specific options @@ -162,6 +163,30 @@ pub fn launch_in_tmux_with_options( // Write prompt to file (avoids newline issues with tmux send-keys) let prompt_file = write_prompt_file(config, &session_uuid, &full_prompt)?; + // Remote launch: the local pane runs a wrapper that ships the prompt and + // payload over SSH and execs into a remote tmux session through a reverse + // tunnel. Relay injection is skipped (unix socket is local-only). + if let Some(ref host) = options.remote_host { + return launch_remote_in_session( + config, + ticket, + &session_name, + &session_uuid, + &step_name, + host, + &tool_name, + &model, + &prompt_file, + options, + operator_env, + false, + |cmd| tmux.send_keys(&session_name, cmd, true).map_err(Into::into), + || { + let _ = tmux.kill_session(&session_name); + }, + ); + } + // Build command using the detected tool's template (with permissions) let mut llm_cmd = build_llm_command_with_permissions_for_tool( config, @@ -389,6 +414,29 @@ pub fn launch_in_tmux_with_relaunch_options( (default_tool, default_model) }; + // Remote relaunch: regenerate the wrapper (idempotent, keyed by session + // uuid); `tmux new -A` on the remote host reattaches a surviving session. + if let Some(ref host) = options.launch_options.remote_host { + return launch_remote_in_session( + config, + ticket, + &session_name, + &session_uuid, + &step_name, + host, + &tool_name, + &model, + &prompt_file, + &options.launch_options, + operator_env, + is_resume, + |cmd| tmux.send_keys(&session_name, cmd, true).map_err(Into::into), + || { + let _ = tmux.kill_session(&session_name); + }, + ); + } + // Build command using the detected tool's template (with permissions) let mut llm_cmd = build_llm_command_with_permissions_for_tool( config, diff --git a/src/api/providers/kanban/mod.rs b/src/api/providers/kanban/mod.rs index c8cbbcf0..ccd5841e 100644 --- a/src/api/providers/kanban/mod.rs +++ b/src/api/providers/kanban/mod.rs @@ -8,11 +8,16 @@ mod github_projects; mod jira; mod linear; pub mod onboarding; +mod openspec; pub use github_projects::{GithubProjectInfo, GithubProjectsProvider, GithubValidationDetails}; pub use jira::{JiraProvider, JiraValidationDetails}; pub use linear::{LinearProvider, LinearTeamInfo, LinearValidationDetails}; pub use onboarding::{DiscoveredProject, KanbanOnboarding, ValidatedWorkspace, WorkspaceExtra}; +pub use openspec::{ + parse_proposal, parse_tasks_md, OpenspecProvider, ProposalMeta, TaskGroup, TaskItem, + OPENSPEC_STATUS_DONE, OPENSPEC_STATUS_TODO, +}; // Re-export Jira API response types for schema/binding generation pub use jira::{ @@ -269,6 +274,7 @@ pub enum KanbanProviderType { Jira, Linear, Github, + Openspec, } impl KanbanProviderType { @@ -278,10 +284,11 @@ impl KanbanProviderType { /// surface (TUI status section, web `/#/kanban`, the REST provider catalog /// endpoint, and the VS Code onboarding picker) derives its list from here /// so the options can't drift apart. - pub const ALL: [KanbanProviderType; 3] = [ + pub const ALL: [KanbanProviderType; 4] = [ KanbanProviderType::Jira, KanbanProviderType::Linear, KanbanProviderType::Github, + KanbanProviderType::Openspec, ]; /// Get the display name @@ -290,6 +297,7 @@ impl KanbanProviderType { KanbanProviderType::Jira => "Jira Cloud", KanbanProviderType::Linear => "Linear", KanbanProviderType::Github => "GitHub Projects", + KanbanProviderType::Openspec => "OpenSpec", } } @@ -300,6 +308,7 @@ impl KanbanProviderType { KanbanProviderType::Jira => "jira", KanbanProviderType::Linear => "linear", KanbanProviderType::Github => "github", + KanbanProviderType::Openspec => "openspec", } } @@ -316,6 +325,7 @@ impl KanbanProviderType { KanbanProviderType::Jira => "Connect to Jira Cloud", KanbanProviderType::Linear => "Connect to Linear", KanbanProviderType::Github => "Connect to GitHub Projects", + KanbanProviderType::Openspec => "Import OpenSpec changes (experimental)", } } @@ -329,6 +339,10 @@ impl KanbanProviderType { } KanbanProviderType::Linear => "https://linear.app/settings/api", KanbanProviderType::Github => "https://github.com/settings/personal-access-tokens", + // No token page exists — OpenSpec is local files; link the docs. + KanbanProviderType::Openspec => { + "https://operator.untra.io/getting-started/kanban/openspec/" + } } } @@ -338,6 +352,7 @@ impl KanbanProviderType { KanbanProviderType::Jira => "operator-atlassian", KanbanProviderType::Linear => "operator-linear", KanbanProviderType::Github => "github", + KanbanProviderType::Openspec => "checklist", } } @@ -347,6 +362,8 @@ impl KanbanProviderType { KanbanProviderType::Jira => "OPERATOR_JIRA_API_KEY", KanbanProviderType::Linear => "OPERATOR_LINEAR_API_KEY", KanbanProviderType::Github => "OPERATOR_GITHUB_TOKEN", + // OpenSpec reads local files; there is no credential to name. + KanbanProviderType::Openspec => "", } } } @@ -409,6 +426,8 @@ impl DetectedKanbanProvider { .iter() .any(|v| v.contains("TOKEN") || v.contains("API_KEY")) } + // OpenSpec needs no env vars — configuration is a local path. + KanbanProviderType::Openspec => true, } } } @@ -661,6 +680,10 @@ pub async fn test_provider_credentials(provider: &DetectedKanbanProvider) -> Res Ok(()) } + KanbanProviderType::Openspec => Err( + "OpenSpec has no credentials to test; configure [kanban.openspec.] root_path" + .to_string(), + ), } } @@ -676,6 +699,7 @@ pub fn get_provider(name: &str) -> Option> { "github" => GithubProjectsProvider::from_env() .ok() .map(|p| Box::new(p) as Box), + // openspec cannot be built from env — use get_provider_from_config _ => None, } } @@ -720,8 +744,25 @@ pub fn get_provider_from_config( GithubProjectsProvider::from_config(owner, cfg) .map(|p| Box::new(p) as Box) } + "openspec" => { + let (instance, cfg) = kanban + .openspec + .iter() + .find(|(_, cfg)| { + cfg.enabled + && std::path::Path::new(&cfg.root_path) + .join("changes") + .join(project_key) + .is_dir() + }) + .or_else(|| kanban.openspec.iter().find(|(_, cfg)| cfg.enabled)) + .ok_or_else(|| { + ApiError::not_configured("No enabled OpenSpec provider configured") + })?; + Ok(Box::new(OpenspecProvider::from_config(instance, cfg)) as Box) + } _ => Err(ApiError::not_configured(format!( - "Unknown provider: '{provider_name}'. Supported: jira, linear, github" + "Unknown provider: '{provider_name}'. Supported: jira, linear, github, openspec" ))), } } @@ -981,14 +1022,15 @@ mod tests { } #[test] - fn test_provider_type_all_covers_three_providers() { - assert_eq!(KanbanProviderType::ALL.len(), 3); + fn test_provider_type_all_covers_four_providers() { + assert_eq!(KanbanProviderType::ALL.len(), 4); assert_eq!( KanbanProviderType::ALL, [ KanbanProviderType::Jira, KanbanProviderType::Linear, KanbanProviderType::Github, + KanbanProviderType::Openspec, ] ); } diff --git a/src/api/providers/kanban/onboarding.rs b/src/api/providers/kanban/onboarding.rs index 1e5f3a7c..b6904cac 100644 --- a/src/api/providers/kanban/onboarding.rs +++ b/src/api/providers/kanban/onboarding.rs @@ -41,6 +41,8 @@ pub enum WorkspaceExtra { Linear, /// GitHub Projects needs no extra data beyond what's in `ValidatedWorkspace`. Github, + /// `OpenSpec` carries its root path (no credentials exist). + Openspec { root_path: String }, } /// A project discovered from a kanban provider. diff --git a/src/api/providers/kanban/openspec.rs b/src/api/providers/kanban/openspec.rs new file mode 100644 index 00000000..dc2a29ab --- /dev/null +++ b/src/api/providers/kanban/openspec.rs @@ -0,0 +1,598 @@ +//! `OpenSpec` (spec-driven development) kanban provider — alpha. +//! +//! Reads local `OpenSpec` change bundles (`openspec/changes//{proposal,tasks}.md`) +//! and exposes each change as a kanban "project" whose issues are the `## N.` +//! task groups from `tasks.md`. Pull-only: this provider is intentionally not +//! wired into the bidirectional push path, and its mutating trait methods +//! return errors. + +use std::path::{Path, PathBuf}; + +use async_trait::async_trait; + +use super::{ + CreateIssueRequest, CreateIssueResponse, ExternalIssue, ExternalIssueType, ExternalUser, + KanbanProvider, ProjectInfo, UpdateStatusRequest, +}; +use crate::api::error::ApiError; +use crate::config::OpenspecConfig; + +const PROVIDER_NAME: &str = "openspec"; +const CHANGES_DIR: &str = "changes"; +const ARCHIVE_DIR: &str = "archive"; +const TASKS_FILE: &str = "tasks.md"; +const PROPOSAL_FILE: &str = "proposal.md"; + +pub const OPENSPEC_STATUS_TODO: &str = "todo"; +pub const OPENSPEC_STATUS_DONE: &str = "done"; + +fn openspec_error(status: u16, message: String) -> ApiError { + ApiError::HttpError { + provider: PROVIDER_NAME.to_string(), + status, + message, + } +} + +// ─── Markdown parsing ──────────────────────────────────────────────────────── + +/// A single checklist item within a task group +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TaskItem { + pub checked: bool, + pub text: String, +} + +/// A `## N. Title` group of checklist items from tasks.md +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TaskGroup { + pub number: u32, + pub title: String, + pub items: Vec, +} + +impl TaskGroup { + /// A group is done when it has items and every one is checked + pub fn is_done(&self) -> bool { + !self.items.is_empty() && self.items.iter().all(|i| i.checked) + } +} + +/// Proposal metadata extracted from proposal.md +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ProposalMeta { + pub title: Option, + pub why_excerpt: Option, +} + +fn parse_checklist_item(line: &str) -> Option { + let trimmed = line.trim_start(); + let rest = trimmed + .strip_prefix("- ") + .or_else(|| trimmed.strip_prefix("* "))?; + let (checked, text) = if let Some(t) = rest.strip_prefix("[ ]") { + (false, t) + } else if let Some(t) = rest + .strip_prefix("[x]") + .or_else(|| rest.strip_prefix("[X]")) + { + (true, t) + } else { + return None; + }; + Some(TaskItem { + checked, + text: text.trim().to_string(), + }) +} + +/// Parse a `## ...` heading into (number, title); headings without a leading +/// number get the provided fallback ordinal. +fn parse_group_heading(heading: &str, fallback_number: u32) -> (u32, String) { + let text = heading.trim(); + let digits: String = text.chars().take_while(char::is_ascii_digit).collect(); + if !digits.is_empty() { + if let Ok(n) = digits.parse::() { + let title = text[digits.len()..] + .trim_start_matches(['.', ')', ':']) + .trim() + .to_string(); + if !title.is_empty() { + return (n, title); + } + } + } + (fallback_number, text.to_string()) +} + +/// Parse an `OpenSpec` tasks.md into its task groups. +/// +/// Checklist items before the first `## ` heading are collected into an +/// implicit group 1 titled "Tasks". +pub fn parse_tasks_md(content: &str) -> Vec { + let mut groups: Vec = Vec::new(); + let mut current: Option = None; + let mut in_code_fence = false; + + for line in content.lines() { + if line.trim_start().starts_with("```") { + in_code_fence = !in_code_fence; + continue; + } + if in_code_fence { + continue; + } + if let Some(heading) = line.strip_prefix("## ") { + if let Some(group) = current.take() { + groups.push(group); + } + let fallback = groups.len() as u32 + 1; + let (number, title) = parse_group_heading(heading, fallback); + current = Some(TaskGroup { + number, + title, + items: Vec::new(), + }); + continue; + } + if let Some(item) = parse_checklist_item(line) { + let group = current.get_or_insert_with(|| TaskGroup { + number: 1, + title: "Tasks".to_string(), + items: Vec::new(), + }); + group.items.push(item); + } + } + if let Some(group) = current.take() { + groups.push(group); + } + // Drop headings that contained no checklist items (prose sections) + groups.retain(|g| !g.items.is_empty()); + groups +} + +/// Extract the H1 title and the first `## Why` / `## Intent` section body. +pub fn parse_proposal(content: &str) -> ProposalMeta { + let mut meta = ProposalMeta::default(); + let mut why_lines: Vec = Vec::new(); + let mut in_why = false; + + for line in content.lines() { + if let Some(h1) = line.strip_prefix("# ") { + if meta.title.is_none() { + meta.title = Some(h1.trim().to_string()); + } + continue; + } + if let Some(h2) = line.strip_prefix("## ") { + let name = h2.trim().to_lowercase(); + in_why = matches!(name.as_str(), "why" | "intent"); + continue; + } + if in_why { + why_lines.push(line.to_string()); + } + } + let excerpt = why_lines.join("\n").trim().to_string(); + if !excerpt.is_empty() { + meta.why_excerpt = Some(excerpt); + } + meta +} + +// ─── Provider ──────────────────────────────────────────────────────────────── + +/// Kanban provider over a local `OpenSpec` root directory +pub struct OpenspecProvider { + /// Instance key from config (`[kanban.openspec.]`) + instance_key: String, + /// Directory containing `changes/` (typically `/openspec`) + root_path: PathBuf, +} + +impl OpenspecProvider { + pub fn new(instance_key: impl Into, root_path: impl Into) -> Self { + Self { + instance_key: instance_key.into(), + root_path: root_path.into(), + } + } + + pub fn from_config(instance_key: &str, config: &OpenspecConfig) -> Self { + Self::new(instance_key, PathBuf::from(&config.root_path)) + } + + pub fn instance_key(&self) -> &str { + &self.instance_key + } + + fn changes_dir(&self) -> PathBuf { + self.root_path.join(CHANGES_DIR) + } + + fn change_dir(&self, change_id: &str) -> Result { + // Change ids are directory names; refuse anything path-like + if change_id.contains(['/', '\\']) || change_id == ".." { + return Err(openspec_error( + 400, + format!("invalid openspec change id '{change_id}'"), + )); + } + let dir = self.changes_dir().join(change_id); + if !dir.is_dir() { + return Err(openspec_error( + 404, + format!( + "openspec change '{change_id}' not found under {}", + self.changes_dir().display() + ), + )); + } + Ok(dir) + } + + /// List active (non-archived) change ids, sorted + pub fn list_change_ids(&self) -> Result, ApiError> { + let changes = self.changes_dir(); + let entries = std::fs::read_dir(&changes).map_err(|e| { + openspec_error( + 404, + format!( + "cannot read openspec changes dir {}: {e}", + changes.display() + ), + ) + })?; + let mut ids: Vec = entries + .filter_map(Result::ok) + .filter(|e| e.path().is_dir()) + .filter_map(|e| e.file_name().into_string().ok()) + .filter(|name| name != ARCHIVE_DIR && !name.starts_with('.')) + .collect(); + ids.sort(); + Ok(ids) + } + + fn proposal_meta(&self, change_dir: &Path) -> ProposalMeta { + std::fs::read_to_string(change_dir.join(PROPOSAL_FILE)) + .map(|content| parse_proposal(&content)) + .unwrap_or_default() + } + + fn issue_for_group( + &self, + change_id: &str, + change_dir: &Path, + proposal: &ProposalMeta, + group: &TaskGroup, + ) -> ExternalIssue { + let key = format!("{change_id}#{}", group.number); + let status = if group.is_done() { + OPENSPEC_STATUS_DONE + } else { + OPENSPEC_STATUS_TODO + }; + + let mut description = String::new(); + if let Some(title) = &proposal.title { + description.push_str(&format!("OpenSpec change **{change_id}** — {title}\n\n")); + } else { + description.push_str(&format!("OpenSpec change **{change_id}**\n\n")); + } + if let Some(why) = &proposal.why_excerpt { + description.push_str(&format!("## Why\n\n{why}\n\n")); + } + description.push_str(&format!("## Tasks ({})\n\n", group.title)); + for item in &group.items { + let mark = if item.checked { "x" } else { " " }; + description.push_str(&format!("- [{mark}] {}\n", item.text)); + } + description.push_str(&format!( + "\n## Spec Context\n\nRead the full change (proposal, design, spec deltas) at `{}` before starting.\n", + change_dir.display() + )); + + ExternalIssue { + id: key.clone(), + key, + summary: group.title.clone(), + description: Some(description), + kanban_issue_types: Vec::new(), + status: status.to_string(), + assignee: None, + url: format!("file://{}", change_dir.join(TASKS_FILE).display()), + priority: None, + } + } +} + +#[async_trait] +impl KanbanProvider for OpenspecProvider { + fn name(&self) -> &str { + PROVIDER_NAME + } + + fn is_configured(&self) -> bool { + self.changes_dir().is_dir() + } + + async fn list_projects(&self) -> Result, ApiError> { + let ids = self.list_change_ids()?; + Ok(ids + .into_iter() + .map(|id| { + let name = self + .proposal_meta(&self.changes_dir().join(&id)) + .title + .unwrap_or_else(|| id.clone()); + ProjectInfo { + id: id.clone(), + key: id, + name, + } + }) + .collect()) + } + + async fn get_issue_types( + &self, + _project_key: &str, + ) -> Result, ApiError> { + Ok(Vec::new()) + } + + async fn test_connection(&self) -> Result { + Ok(self.changes_dir().is_dir()) + } + + async fn list_users(&self, _project_key: &str) -> Result, ApiError> { + Ok(Vec::new()) + } + + async fn list_statuses(&self, _project_key: &str) -> Result, ApiError> { + Ok(vec![ + OPENSPEC_STATUS_TODO.to_string(), + OPENSPEC_STATUS_DONE.to_string(), + ]) + } + + async fn list_issues( + &self, + project_key: &str, + _user_id: &str, + statuses: &[String], + ) -> Result, ApiError> { + let change_dir = self.change_dir(project_key)?; + let tasks_path = change_dir.join(TASKS_FILE); + let content = std::fs::read_to_string(&tasks_path).map_err(|e| { + openspec_error(404, format!("cannot read {}: {e}", tasks_path.display())) + })?; + let proposal = self.proposal_meta(&change_dir); + let issues = parse_tasks_md(&content) + .iter() + .map(|group| self.issue_for_group(project_key, &change_dir, &proposal, group)) + .filter(|issue| statuses.is_empty() || statuses.contains(&issue.status)) + .collect(); + Ok(issues) + } + + async fn create_issue( + &self, + _project_key: &str, + _request: CreateIssueRequest, + ) -> Result { + Err(openspec_error( + 400, + "openspec provider is pull-only; edit tasks.md directly".to_string(), + )) + } + + async fn update_issue_status( + &self, + _issue_key: &str, + _request: UpdateStatusRequest, + ) -> Result { + Err(openspec_error( + 400, + "openspec provider is pull-only; check off tasks.md directly".to_string(), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE_TASKS: &str = "\ +# Tasks + +## 1. Theme Infrastructure +- [ ] 1.1 Create ThemeContext with light/dark state +- [x] 1.2 Add CSS custom properties for colors + +## 2. UI Components +- [ ] 2.1 Create ThemeToggle component + +## Notes +Some prose without checkboxes. +"; + + #[test] + fn test_parse_tasks_md_groups_and_items() { + let groups = parse_tasks_md(SAMPLE_TASKS); + assert_eq!(groups.len(), 2); + assert_eq!(groups[0].number, 1); + assert_eq!(groups[0].title, "Theme Infrastructure"); + assert_eq!(groups[0].items.len(), 2); + assert!(!groups[0].items[0].checked); + assert!(groups[0].items[1].checked); + assert_eq!( + groups[0].items[0].text, + "1.1 Create ThemeContext with light/dark state" + ); + assert_eq!(groups[1].number, 2); + assert_eq!(groups[1].title, "UI Components"); + } + + #[test] + fn test_parse_tasks_md_unnumbered_headings_get_ordinals() { + let groups = parse_tasks_md("## Setup\n- [ ] do a thing\n## Teardown\n- [x] undo it\n"); + assert_eq!(groups.len(), 2); + assert_eq!((groups[0].number, groups[0].title.as_str()), (1, "Setup")); + assert_eq!( + (groups[1].number, groups[1].title.as_str()), + (2, "Teardown") + ); + } + + #[test] + fn test_parse_tasks_md_items_before_heading_form_implicit_group() { + let groups = parse_tasks_md("- [ ] loose item\n- [X] another\n"); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].number, 1); + assert_eq!(groups[0].title, "Tasks"); + assert_eq!(groups[0].items.len(), 2); + assert!(groups[0].items[1].checked); + } + + #[test] + fn test_parse_tasks_md_empty_and_prose_only() { + assert!(parse_tasks_md("").is_empty()); + assert!(parse_tasks_md("# Tasks\n\njust prose\n\n## Heading\nmore prose\n").is_empty()); + } + + #[test] + fn test_parse_tasks_md_ignores_code_fences() { + let content = + "## 1. Real\n- [ ] real item\n```\n- [ ] fake item in code\n## 9. fake\n```\n"; + let groups = parse_tasks_md(content); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].items.len(), 1); + } + + #[test] + fn test_task_group_is_done() { + let groups = + parse_tasks_md("## 1. A\n- [x] one\n- [x] two\n## 2. B\n- [x] one\n- [ ] two\n"); + assert!(groups[0].is_done()); + assert!(!groups[1].is_done()); + } + + #[test] + fn test_parse_proposal_title_and_why() { + let meta = parse_proposal( + "# Proposal: Add dark mode\n\n## Why\n\nUsers asked.\nA lot.\n\n## What Changes\n\nstuff\n", + ); + assert_eq!(meta.title.as_deref(), Some("Proposal: Add dark mode")); + assert_eq!(meta.why_excerpt.as_deref(), Some("Users asked.\nA lot.")); + } + + #[test] + fn test_parse_proposal_intent_section_and_missing() { + let meta = parse_proposal("# T\n## Intent\nbecause\n"); + assert_eq!(meta.why_excerpt.as_deref(), Some("because")); + assert_eq!(parse_proposal("no headings"), ProposalMeta::default()); + } + + // ── Provider tests over a fixture tree ────────────────────────────────── + + fn fixture_root() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + let change = dir.path().join("changes/add-dark-mode"); + std::fs::create_dir_all(&change).unwrap(); + std::fs::write( + change.join("proposal.md"), + "# Proposal: Add dark mode\n\n## Why\n\nUsers asked.\n", + ) + .unwrap(); + std::fs::write(change.join("tasks.md"), SAMPLE_TASKS).unwrap(); + // Archived + hidden entries must be skipped + std::fs::create_dir_all(dir.path().join("changes/archive/2026-01-01-old")).unwrap(); + std::fs::create_dir_all(dir.path().join("changes/.hidden")).unwrap(); + dir + } + + #[tokio::test] + async fn test_openspec_list_projects_skips_archive() { + let root = fixture_root(); + let provider = OpenspecProvider::new("demo", root.path()); + let projects = provider.list_projects().await.unwrap(); + assert_eq!(projects.len(), 1); + assert_eq!(projects[0].key, "add-dark-mode"); + assert_eq!(projects[0].name, "Proposal: Add dark mode"); + } + + #[tokio::test] + async fn test_openspec_list_issues_maps_groups() { + let root = fixture_root(); + let provider = OpenspecProvider::new("demo", root.path()); + let issues = provider + .list_issues("add-dark-mode", "", &[]) + .await + .unwrap(); + assert_eq!(issues.len(), 2); + assert_eq!(issues[0].key, "add-dark-mode#1"); + assert_eq!(issues[0].summary, "Theme Infrastructure"); + assert_eq!(issues[0].status, OPENSPEC_STATUS_TODO); + let desc = issues[0].description.as_deref().unwrap(); + assert!(desc.contains("Users asked.")); + assert!(desc.contains("- [ ] 1.1 Create ThemeContext")); + assert!(desc.contains("## Spec Context")); + assert!(issues[0].url.starts_with("file://")); + } + + #[tokio::test] + async fn test_openspec_list_issues_status_filter() { + let root = fixture_root(); + let change = root.path().join("changes/add-dark-mode"); + std::fs::write( + change.join("tasks.md"), + "## 1. A\n- [x] done item\n## 2. B\n- [ ] open\n", + ) + .unwrap(); + let provider = OpenspecProvider::new("demo", root.path()); + let todo = provider + .list_issues("add-dark-mode", "", &[OPENSPEC_STATUS_TODO.to_string()]) + .await + .unwrap(); + assert_eq!(todo.len(), 1); + assert_eq!(todo[0].key, "add-dark-mode#2"); + } + + #[tokio::test] + async fn test_openspec_unknown_change_and_path_traversal_rejected() { + let root = fixture_root(); + let provider = OpenspecProvider::new("demo", root.path()); + assert!(provider.list_issues("nope", "", &[]).await.is_err()); + assert!(provider.list_issues("../etc", "", &[]).await.is_err()); + } + + #[tokio::test] + async fn test_openspec_pull_only_stubs() { + let root = fixture_root(); + let provider = OpenspecProvider::new("demo", root.path()); + let create = provider + .create_issue( + "add-dark-mode", + CreateIssueRequest { + summary: "x".into(), + description: None, + assignee_id: None, + status: None, + priority: None, + }, + ) + .await; + assert!(create.is_err()); + let update = provider + .update_issue_status( + "add-dark-mode#1", + UpdateStatusRequest { + status: "done".into(), + }, + ) + .await; + assert!(update.is_err()); + } +} diff --git a/src/app/kanban_onboarding.rs b/src/app/kanban_onboarding.rs index f254aea4..f95ecefe 100644 --- a/src/app/kanban_onboarding.rs +++ b/src/app/kanban_onboarding.rs @@ -78,6 +78,7 @@ impl App { }), linear: None, github: None, + openspec: None, }; let resp = match kanban_onboarding::validate_credentials(req).await { Ok(r) => r, @@ -112,6 +113,7 @@ impl App { }), linear: None, github: None, + openspec: None, }; let projects = match kanban_onboarding::list_projects(list_req).await { Ok(r) => r.projects, @@ -149,6 +151,7 @@ impl App { api_key: api_key.clone(), }), github: None, + openspec: None, }; let resp = match kanban_onboarding::validate_credentials(req).await { Ok(r) => r, @@ -253,6 +256,7 @@ impl App { }), linear: None, github: None, + openspec: None, }; kanban_onboarding::write_config(write_req, None) .map_err(|e| anyhow::anyhow!("write_config failed: {e:?}"))?; @@ -302,6 +306,7 @@ impl App { status_mapping: None, }), github: None, + openspec: None, }; kanban_onboarding::write_config(write_req, None) .map_err(|e| anyhow::anyhow!("write_config failed: {e:?}"))?; diff --git a/src/config.rs b/src/config.rs index 04337f13..5d04537f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -66,6 +66,10 @@ pub struct Config { /// Implicit builtin servers exist for each `llm_tool`'s vendor API and do not need declaration. #[serde(default)] pub model_servers: Vec, + /// Remote machines agents can be launched on over SSH, referenced by name + /// from `DelegatorLaunchConfig.host`. + #[serde(default)] + pub hosts: Vec, /// Relay MCP injection configuration #[serde(default)] pub relay: RelayConfig, @@ -897,6 +901,7 @@ impl Default for Config { version_check: VersionCheckConfig::default(), delegators: Vec::new(), model_servers: Vec::new(), + hosts: Vec::new(), relay: RelayConfig::default(), mcp: McpConfig::default(), acp: AcpConfig::default(), diff --git a/src/config/agent_profile.rs b/src/config/agent_profile.rs index 5c94d0bb..a8942bbf 100644 --- a/src/config/agent_profile.rs +++ b/src/config/agent_profile.rs @@ -231,6 +231,7 @@ mod tests { prompt_prefix: Some("PREFIX".to_string()), prompt_suffix: Some("SUFFIX".to_string()), operator_relay: Some(true), + host: Some("gpu-vm".to_string()), }), model_server: Some("anthropic-api".to_string()), remote_agent: None, diff --git a/src/config/config_tests.rs b/src/config/config_tests.rs index fc28ae98..a40f52ba 100644 --- a/src/config/config_tests.rs +++ b/src/config/config_tests.rs @@ -50,6 +50,34 @@ fn test_delegator_serde_roundtrip() { assert!(parsed.model_server.is_none()); } +#[test] +fn test_config_hosts_default_empty() { + assert!(Config::default().hosts.is_empty()); + + // Legacy config serialized before the hosts field existed still deserializes. + let mut v = serde_json::to_value(Config::default()).unwrap(); + v.as_object_mut().unwrap().remove("hosts"); + let config: Config = serde_json::from_value(v).unwrap(); + assert!(config.hosts.is_empty()); +} + +#[test] +fn test_config_hosts_roundtrip() { + let mut config = Config::default(); + config.hosts.push(RemoteHost { + name: "gpu-vm".to_string(), + ssh_alias: "gpu-vm-alias".to_string(), + workdir: "/srv/agents".to_string(), + display_name: None, + }); + let json = serde_json::to_string(&config).unwrap(); + let parsed: Config = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.hosts.len(), 1); + assert_eq!(parsed.hosts[0].name, "gpu-vm"); + assert_eq!(parsed.hosts[0].ssh_alias, "gpu-vm-alias"); + assert_eq!(parsed.hosts[0].workdir, "/srv/agents"); +} + #[test] fn test_model_server_toml_roundtrip() { let toml_str = r#" diff --git a/src/config/kanban.rs b/src/config/kanban.rs index b4226495..950e1b29 100644 --- a/src/config/kanban.rs +++ b/src/config/kanban.rs @@ -27,6 +27,11 @@ pub struct KanbanConfig { /// `docs/getting-started/kanban/github.md` for the full disambiguation. #[serde(default)] pub github: std::collections::HashMap, + /// `OpenSpec` roots keyed by a free-form instance name (e.g., a repo alias). + /// Experimental, pull-only: each active change under `/changes/` + /// acts as a kanban "project" whose issues are the tasks.md task groups. + #[serde(default)] + pub openspec: std::collections::HashMap, } /// Jira Cloud provider configuration @@ -138,6 +143,24 @@ impl Default for GithubProjectsConfig { } } +/// `OpenSpec` provider configuration (experimental, pull-only) +/// +/// The instance name is the `HashMap` key in `KanbanConfig.openspec`. There +/// are no credentials — the provider reads local markdown under `root_path`. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS, Default)] +#[ts(export)] +pub struct OpenspecConfig { + /// Whether this provider is enabled + #[serde(default)] + pub enabled: bool, + /// Directory containing the `OpenSpec` `changes/` tree (typically `/openspec`) + #[serde(default)] + pub root_path: String, + /// Operator project stamped on imported tickets (defaults to the change id) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project: Option, +} + impl KanbanConfig { /// Insert or update a Jira project entry in the config. /// @@ -166,6 +189,7 @@ impl KanbanConfig { collection_name: None, type_mappings: std::collections::HashMap::new(), bidirectional: false, + ticket_project: None, }, ); } @@ -195,6 +219,7 @@ impl KanbanConfig { collection_name: None, type_mappings: std::collections::HashMap::new(), bidirectional: false, + ticket_project: None, }, ); } @@ -227,6 +252,7 @@ impl KanbanConfig { collection_name: None, type_mappings: std::collections::HashMap::new(), bidirectional: false, + ticket_project: None, }, ); } @@ -265,6 +291,21 @@ impl KanbanConfig { &workspace.sync_user_id, KanbanStatusMapping::default(), ), + // OpenSpec has no credentials or per-project sync entries; the + // instance itself is the whole configuration. + WorkspaceExtra::Openspec { root_path } => { + self.upsert_openspec_root(&workspace.workspace_key, root_path, None); + } + } + } + + /// Insert or update an `OpenSpec` root entry (no credentials, just a path). + pub fn upsert_openspec_root(&mut self, instance: &str, root_path: &str, project: Option<&str>) { + let entry = self.openspec.entry(instance.to_string()).or_default(); + entry.enabled = true; + entry.root_path = root_path.to_string(); + if project.is_some() { + entry.project = project.map(ToString::to_string); } } } @@ -334,6 +375,10 @@ pub struct ProjectSyncConfig { /// are reflected upstream. Default: false. #[serde(default)] pub bidirectional: bool, + /// Operator project name stamped on tickets created from this source. + /// Defaults to the external project key when unset. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ticket_project: Option, } impl ProjectSyncConfig { diff --git a/src/config/llm_tools.rs b/src/config/llm_tools.rs index 4e2244d2..f7724841 100644 --- a/src/config/llm_tools.rs +++ b/src/config/llm_tools.rs @@ -231,6 +231,26 @@ pub struct ModelServer { pub display_name: Option, } +/// A named remote machine that agent CLI processes can be launched on over SSH. +/// +/// Distinct from [`ModelServer`] (where model *inference* lives) and from +/// [`RemoteAgentRef`] (an export-only agent owned by another platform): a +/// `RemoteHost` is where the agent *CLI process* runs. Referenced by name from +/// [`DelegatorLaunchConfig::host`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS, utoipa::ToSchema)] +#[ts(export)] +pub struct RemoteHost { + /// Unique name referenced by `DelegatorLaunchConfig.host` (e.g., "gpu-vm") + pub name: String, + /// SSH destination, resolved via the user's `~/.ssh/config` + pub ssh_alias: String, + /// Absolute path to the project root on the remote host + pub workdir: String, + /// Optional display name for UI + #[serde(default)] + pub display_name: Option, +} + /// Returns the implicit builtin `ModelServer` associated with a given `llm_tool`. /// /// Used when a `Delegator` has no explicit `model_server`. Unknown tools @@ -272,6 +292,36 @@ mod tests { assert!(d.unmapped_core.is_none()); } + #[test] + fn remote_host_deserializes_from_toml() { + let toml = r#" + name = "gpu-vm" + ssh_alias = "gpu-vm" + workdir = "/srv/agents/project" + "#; + let h: RemoteHost = toml::from_str(toml).expect("remote host deserializes"); + assert_eq!(h.name, "gpu-vm"); + assert_eq!(h.ssh_alias, "gpu-vm"); + assert_eq!(h.workdir, "/srv/agents/project"); + assert!(h.display_name.is_none()); + } + + #[test] + fn delegator_launch_config_host_default_none() { + // Pre-existing launch config JSON without the host field still parses. + let json = r#"{ "yolo": true }"#; + let lc: DelegatorLaunchConfig = + serde_json::from_str(json).expect("legacy launch config still deserializes"); + assert!(lc.host.is_none()); + } + + #[test] + fn delegator_launch_config_serializes_omits_none_host() { + let lc = DelegatorLaunchConfig::default(); + let v = serde_json::to_value(&lc).unwrap(); + assert!(v.get("host").is_none(), "None host is omitted"); + } + #[test] fn delegator_serializes_omits_none_new_fields() { let d = Delegator { @@ -335,4 +385,8 @@ pub struct DelegatorLaunchConfig { /// Override global relay auto-inject MCP setting per-delegator (None = use global setting) #[serde(default)] pub operator_relay: Option, + /// Name of a declared `RemoteHost` (from `Config.hosts`) to launch the agent + /// CLI on over SSH. `None` = launch locally. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub host: Option, } diff --git a/src/docs_gen/cli.rs b/src/docs_gen/cli.rs index fef73f83..aac93cc8 100644 --- a/src/docs_gen/cli.rs +++ b/src/docs_gen/cli.rs @@ -247,6 +247,7 @@ mod tests { assert!(result.contains("### `resume`")); assert!(result.contains("### `stalled`")); assert!(result.contains("### `alert`")); + assert!(result.contains("### `import`")); assert!(result.contains("### `create`")); assert!(result.contains("### `docs`")); } diff --git a/src/integrations/catalog.rs b/src/integrations/catalog.rs index a285e83d..a31e043d 100644 --- a/src/integrations/catalog.rs +++ b/src/integrations/catalog.rs @@ -143,6 +143,14 @@ pub fn all_integrations() -> Vec { true, Beta, ), + entry( + Kanban, + "openspec", + "OpenSpec", + Some("getting-started/kanban/openspec"), + false, + Alpha, + ), // --- Model providers (mirror ModelServerKind::ALL; slug == kind slug) --- entry( Model, diff --git a/src/main.rs b/src/main.rs index 4ea65b6a..6bbf56c3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -211,6 +211,16 @@ enum Commands { project: Option, }, + /// Import tickets from configured kanban providers (jira, linear, github, openspec) + Import { + /// Provider slug (e.g. openspec). Omit to sync every configured provider. + provider: Option, + + /// Project/change reference (e.g. an `OpenSpec` change id, a Jira project key). + /// Omit to sync all of the provider's configured collections. + reference: Option, + }, + /// Create a new ticket from template Create { /// Template type (feature, fix, spike, investigation) @@ -392,6 +402,12 @@ async fn main() -> Result<()> { }) => { cmd_alert(&config, source, message, severity, project).await?; } + Some(Commands::Import { + provider, + reference, + }) => { + cmd_import(&config, provider, reference).await?; + } Some(Commands::Create { template, project }) => { cmd_create(&config, template, project).await?; } @@ -727,6 +743,72 @@ async fn cmd_alert( Ok(()) } +async fn cmd_import( + config: &Config, + provider: Option, + reference: Option, +) -> Result<()> { + use api::providers::kanban::KanbanProviderType; + use services::kanban_sync::KanbanSyncService; + + let service = KanbanSyncService::new(config); + + let provider = match provider { + None => { + let result = service.sync_all().await?; + println!("{}", result.summary()); + print_sync_details(&result); + return Ok(()); + } + Some(p) => p.to_lowercase(), + }; + + if KanbanProviderType::from_slug(&provider).is_none() { + anyhow::bail!( + "Unknown kanban provider: {provider}. Use 'jira', 'linear', 'github', or 'openspec'." + ); + } + + let collections: Vec<(String, String)> = if let Some(reference) = reference { + vec![(provider.clone(), reference)] + } else { + let configured: Vec<(String, String)> = service + .configured_collections() + .into_iter() + .filter(|c| c.provider == provider) + .map(|c| (c.provider, c.project_key)) + .collect(); + if configured.is_empty() { + anyhow::bail!( + "No {provider} collections configured. Add a [kanban.{provider}.] entry to operator.toml." + ); + } + configured + }; + + for (provider_name, project_key) in collections { + match service.sync_collection(&provider_name, &project_key).await { + Ok(result) => { + println!("{provider_name}/{project_key}: {}", result.summary()); + print_sync_details(&result); + } + Err(e) => { + eprintln!("{provider_name}/{project_key}: sync failed: {e}"); + } + } + } + Ok(()) +} + +fn print_sync_details(result: &services::kanban_sync::SyncResult) { + for key in &result.created { + println!(" created {key}"); + } + for error in &result.errors { + eprintln!(" error {error}"); + } +} + async fn cmd_create( config: &Config, template: Option, @@ -933,11 +1015,11 @@ fn cmd_setup( // Validate kanban provider if specified if let Some(ref provider) = kanban_provider { - match provider.to_lowercase().as_str() { - "jira" | "linear" => {} - other => { - anyhow::bail!("Unknown kanban provider: {other}. Use 'jira' or 'linear'."); - } + if api::providers::kanban::KanbanProviderType::from_slug(&provider.to_lowercase()).is_none() + { + anyhow::bail!( + "Unknown kanban provider: {provider}. Use 'jira', 'linear', 'github', or 'openspec'." + ); } } diff --git a/src/rest/dto/configuration.rs b/src/rest/dto/configuration.rs index 4967b064..7161448a 100644 --- a/src/rest/dto/configuration.rs +++ b/src/rest/dto/configuration.rs @@ -185,6 +185,9 @@ pub struct DelegatorLaunchConfigDto { /// Override global relay auto-inject MCP setting per-delegator (None = use global setting) #[serde(default, skip_serializing_if = "Option::is_none")] pub operator_relay: Option, + /// Name of a declared `RemoteHost` to launch the agent CLI on over SSH (None = local) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub host: Option, } /// Response listing all delegators diff --git a/src/rest/dto/kanban.rs b/src/rest/dto/kanban.rs index 37df5e2c..d9d76610 100644 --- a/src/rest/dto/kanban.rs +++ b/src/rest/dto/kanban.rs @@ -103,6 +103,7 @@ pub enum KanbanProviderKind { Jira, Linear, Github, + Openspec, } /// Ephemeral Jira credentials supplied by a client during onboarding. @@ -141,6 +142,15 @@ pub struct GithubCredentials { pub token: String, } +/// `OpenSpec` source location supplied during onboarding. Not a credential — +/// `OpenSpec` reads local markdown; there is no secret to validate or store. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct OpenspecSourceDto { + /// Directory containing the `OpenSpec` `changes/` tree (e.g. "/repo/openspec") + pub root_path: String, +} + /// Request to validate kanban credentials without persisting them. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] #[ts(export)] @@ -152,6 +162,8 @@ pub struct ValidateKanbanCredentialsRequest { pub linear: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub github: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub openspec: Option, } /// Jira-specific validation details (returned on success). @@ -245,6 +257,8 @@ pub struct ListKanbanProjectsRequest { pub linear: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub github: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub openspec: Option, } /// A project/team entry returned by `list_projects`. @@ -309,6 +323,19 @@ pub struct WriteGithubConfigBody { pub status_mapping: Option, } +/// Body for writing an `OpenSpec` instance config section. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct WriteOpenspecConfigBody { + /// Instance name, used as the `[kanban.openspec.]` key + pub instance: String, + /// Directory containing the `OpenSpec` `changes/` tree + pub root_path: String, + /// Operator project stamped on imported tickets (optional) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project: Option, +} + /// Request to list workflow statuses/columns for a specific project using /// ephemeral creds (onboarding wizard — before any config is persisted). #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] @@ -323,6 +350,8 @@ pub struct ListKanbanStatusesRequest { pub linear: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub github: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub openspec: Option, } /// Response wrapper for list-statuses: the external board's column names, @@ -347,6 +376,8 @@ pub struct WriteKanbanConfigRequest { pub linear: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub github: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub openspec: Option, } /// Response after writing a kanban config section. @@ -434,6 +465,10 @@ mod tests { serde_json::to_string(&KanbanProviderKind::Github).unwrap(), "\"github\"" ); + assert_eq!( + serde_json::to_string(&KanbanProviderKind::Openspec).unwrap(), + "\"openspec\"" + ); } #[test] @@ -463,6 +498,7 @@ mod tests { }), linear: None, github: None, + openspec: None, }; let json = serde_json::to_string(&req).unwrap(); assert!(json.contains("\"provider\":\"jira\"")); @@ -642,6 +678,7 @@ mod tests { sync_user_id: "123".to_string(), status_mapping: None, }), + openspec: None, }; let json = serde_json::to_string(&req).unwrap(); assert!(json.contains("\"provider\":\"github\"")); diff --git a/src/rest/routes/delegators.rs b/src/rest/routes/delegators.rs index 090b061e..43625e24 100644 --- a/src/rest/routes/delegators.rs +++ b/src/rest/routes/delegators.rs @@ -165,6 +165,7 @@ fn dto_to_launch_config(lc: DelegatorLaunchConfigDto) -> DelegatorLaunchConfig { prompt_prefix: lc.prompt_prefix, prompt_suffix: lc.prompt_suffix, operator_relay: lc.operator_relay, + host: lc.host, } } @@ -180,6 +181,7 @@ fn launch_config_to_dto(lc: &DelegatorLaunchConfig) -> DelegatorLaunchConfigDto prompt_prefix: lc.prompt_prefix.clone(), prompt_suffix: lc.prompt_suffix.clone(), operator_relay: lc.operator_relay, + host: lc.host.clone(), } } @@ -497,6 +499,7 @@ mod tests { prompt_prefix: Some("Always follow TDD.".to_string()), prompt_suffix: Some("Run tests before finishing.".to_string()), operator_relay: None, + host: None, }), remote_agent: None, x_agnt: None, @@ -626,10 +629,23 @@ mod tests { prompt_prefix: None, prompt_suffix: None, operator_relay: Some(true), + host: None, }; let dto = launch_config_to_dto(&config); assert_eq!(dto.operator_relay, Some(true)); let round_tripped = dto_to_launch_config(dto); assert_eq!(round_tripped.operator_relay, Some(true)); } + + #[test] + fn test_dto_round_trips_host() { + let config = DelegatorLaunchConfig { + host: Some("gpu-vm".to_string()), + ..Default::default() + }; + let dto = launch_config_to_dto(&config); + assert_eq!(dto.host.as_deref(), Some("gpu-vm")); + let round_tripped = dto_to_launch_config(dto); + assert_eq!(round_tripped.host.as_deref(), Some("gpu-vm")); + } } diff --git a/src/rest/routes/kanban.rs b/src/rest/routes/kanban.rs index 41dfaf24..d7325c96 100644 --- a/src/rest/routes/kanban.rs +++ b/src/rest/routes/kanban.rs @@ -25,6 +25,7 @@ fn build_provider_catalog(kanban: &KanbanConfig) -> Vec !kanban.jira.is_empty(), KanbanProviderType::Linear => !kanban.linear.is_empty(), KanbanProviderType::Github => !kanban.github.is_empty(), + KanbanProviderType::Openspec => !kanban.openspec.is_empty(), }; KanbanProviderCatalogEntry { slug: p.slug().to_string(), @@ -262,7 +263,7 @@ mod tests { } #[test] - fn test_build_provider_catalog_lists_all_three_with_configured_flags() { + fn test_build_provider_catalog_lists_all_providers_with_configured_flags() { let mut kanban = crate::config::kanban::KanbanConfig::default(); kanban.github.insert( "my-org".into(), @@ -272,7 +273,7 @@ mod tests { let catalog = build_provider_catalog(&kanban); let slugs: Vec<&str> = catalog.iter().map(|e| e.slug.as_str()).collect(); - assert_eq!(slugs, vec!["jira", "linear", "github"]); + assert_eq!(slugs, vec!["jira", "linear", "github", "openspec"]); let github = catalog.iter().find(|e| e.slug == "github").unwrap(); assert!(github.configured); @@ -285,6 +286,10 @@ mod tests { let jira = catalog.iter().find(|e| e.slug == "jira").unwrap(); assert!(!jira.configured); + + let openspec = catalog.iter().find(|e| e.slug == "openspec").unwrap(); + assert!(!openspec.configured); + assert_eq!(openspec.display_name, "OpenSpec"); } #[test] diff --git a/src/rest/routes/launch.rs b/src/rest/routes/launch.rs index 64bef363..15ee5dd1 100644 --- a/src/rest/routes/launch.rs +++ b/src/rest/routes/launch.rs @@ -516,6 +516,7 @@ mod tests { prompt_prefix: Some("PREFIX".to_string()), prompt_suffix: Some("SUFFIX".to_string()), operator_relay: None, + host: None, }), remote_agent: None, x_agnt: None, @@ -745,6 +746,7 @@ mod tests { prompt_prefix: Some("BEGIN".to_string()), prompt_suffix: Some("END".to_string()), operator_relay: None, + host: None, }), remote_agent: None, x_agnt: None, diff --git a/src/rest/state.rs b/src/rest/state.rs index 9f7d755d..bb51b98a 100644 --- a/src/rest/state.rs +++ b/src/rest/state.rs @@ -101,6 +101,7 @@ mod tests { collection_name: None, type_mappings: HashMap::new(), bidirectional: true, + ticket_project: None, }; let _ = &mut project_sync; // suppress unused_mut if needed @@ -122,6 +123,7 @@ mod tests { jira: jira_map, linear: HashMap::new(), github: HashMap::new(), + openspec: HashMap::new(), }, ..Default::default() }; diff --git a/src/services/kanban_onboarding.rs b/src/services/kanban_onboarding.rs index e0266966..4e6bfd98 100644 --- a/src/services/kanban_onboarding.rs +++ b/src/services/kanban_onboarding.rs @@ -9,7 +9,9 @@ use std::path::PathBuf; use tracing::info; -use crate::api::providers::kanban::{GithubProjectsProvider, JiraProvider, LinearProvider}; +use crate::api::providers::kanban::{ + GithubProjectsProvider, JiraProvider, LinearProvider, OpenspecProvider, +}; use crate::config::Config; use crate::rest::dto::{ GithubProjectInfoDto, GithubValidationDetailsDto, JiraValidationDetailsDto, KanbanProjectInfo, @@ -161,6 +163,32 @@ pub async fn validate_credentials( }), } } + KanbanProviderKind::Openspec => { + let source = req.openspec.ok_or_else(|| { + ApiError::BadRequest("Missing `openspec` field for openspec provider".to_string()) + })?; + let changes = std::path::Path::new(&source.root_path).join("changes"); + if changes.is_dir() { + Ok(ValidateKanbanCredentialsResponse { + valid: true, + error: None, + jira: None, + linear: None, + github: None, + }) + } else { + Ok(ValidateKanbanCredentialsResponse { + valid: false, + error: Some(format!( + "No OpenSpec changes directory at {}", + changes.display() + )), + jira: None, + linear: None, + github: None, + }) + } + } } } @@ -204,6 +232,16 @@ pub async fn list_projects( .await .map_err(|e| ApiError::BadRequest(provider_error_message(&e)))? } + KanbanProviderKind::Openspec => { + let source = req.openspec.ok_or_else(|| { + ApiError::BadRequest("Missing `openspec` field for openspec provider".to_string()) + })?; + let provider = OpenspecProvider::new("onboarding", source.root_path); + provider + .list_projects() + .await + .map_err(|e| ApiError::BadRequest(provider_error_message(&e)))? + } }; Ok(ListKanbanProjectsResponse { @@ -260,6 +298,16 @@ pub async fn list_statuses( .await .map_err(|e| ApiError::BadRequest(provider_error_message(&e)))? } + KanbanProviderKind::Openspec => { + let source = req.openspec.ok_or_else(|| { + ApiError::BadRequest("Missing `openspec` field for openspec provider".to_string()) + })?; + let provider = OpenspecProvider::new("onboarding", source.root_path); + provider + .list_statuses(&req.project_key) + .await + .map_err(|e| ApiError::BadRequest(provider_error_message(&e)))? + } }; Ok(ListKanbanStatusesResponse { statuses }) @@ -326,6 +374,17 @@ pub fn write_config( ); format!("[kanban.github.\"{}\"]", body.owner) } + KanbanProviderKind::Openspec => { + let body = req.openspec.ok_or_else(|| { + ApiError::BadRequest("Missing `openspec` field for openspec provider".to_string()) + })?; + config.kanban.upsert_openspec_root( + &body.instance, + &body.root_path, + body.project.as_deref(), + ); + format!("[kanban.openspec.\"{}\"]", body.instance) + } }; let written_path = if let Some(p) = config_override_path { @@ -422,6 +481,9 @@ pub fn set_session_env(req: SetKanbanSessionEnvRequest) -> SetKanbanSessionEnvRe }; } } + // OpenSpec has no secrets — nothing to set; fall through to the + // empty envelope below. + KanbanProviderKind::Openspec => {} } // No body supplied for the selected provider — return empty envelope. @@ -481,6 +543,7 @@ mod tests { }), linear: None, github: None, + openspec: None, }; let resp = write_config(req, Some(&path)).unwrap(); @@ -510,6 +573,7 @@ mod tests { status_mapping: None, }), github: None, + openspec: None, }; let resp = write_config(req, Some(&path)).unwrap(); @@ -541,6 +605,7 @@ mod tests { }), linear: None, github: None, + openspec: None, }, Some(&path), ) @@ -560,6 +625,7 @@ mod tests { }), linear: None, github: None, + openspec: None, }, Some(&path), ) @@ -617,6 +683,7 @@ mod tests { sync_user_id: "12345678".to_string(), status_mapping: None, }), + openspec: None, }; let resp = write_config(req, Some(&path)).unwrap(); @@ -652,6 +719,7 @@ mod tests { }), linear: None, github: None, + openspec: None, }; write_config(req, Some(&path)).unwrap(); @@ -671,6 +739,7 @@ mod tests { jira: None, linear: None, github: None, + openspec: None, }; let rt = tokio::runtime::Runtime::new().unwrap(); let result = rt.block_on(list_statuses(req)); @@ -684,6 +753,7 @@ mod tests { jira: None, linear: None, github: None, + openspec: None, }; let rt = tokio::runtime::Runtime::new().unwrap(); let result = rt.block_on(validate_credentials(req)); @@ -697,6 +767,7 @@ mod tests { jira: None, linear: None, github: None, + openspec: None, }; let rt = tokio::runtime::Runtime::new().unwrap(); let result = rt.block_on(validate_credentials(req)); diff --git a/src/services/kanban_sync.rs b/src/services/kanban_sync.rs index 034ca491..dd9a86e3 100644 --- a/src/services/kanban_sync.rs +++ b/src/services/kanban_sync.rs @@ -15,7 +15,9 @@ use std::fs; use std::path::Path; use tracing::{debug, info, warn}; -use crate::api::providers::kanban::{get_provider, ExternalIssue}; +use crate::api::providers::kanban::{ + get_provider, get_provider_from_config, ExternalIssue, OpenspecProvider, +}; use crate::config::{Config, KanbanStatusMapping, ProjectSyncConfig}; use crate::issuetypes::kanban_type::KanbanIssueTypeRef; @@ -126,6 +128,30 @@ impl KanbanSyncService { } } + // OpenSpec instances: each active change directory is a syncable collection + for (instance, openspec_config) in &self.config.kanban.openspec { + if !openspec_config.enabled { + continue; + } + let provider = OpenspecProvider::from_config(instance, openspec_config); + match provider.list_change_ids() { + Ok(change_ids) => { + for change_id in change_ids { + collections.push(SyncableCollection { + provider: "openspec".to_string(), + project_key: change_id, + collection_name: None, + sync_user_id: String::new(), + status_mapping: openspec_status_mapping(), + }); + } + } + Err(e) => { + warn!("Cannot enumerate openspec changes for '{instance}': {e}"); + } + } + } + collections } @@ -139,9 +165,13 @@ impl KanbanSyncService { let mut result = SyncResult::default(); - // Get the provider - let provider = get_provider(provider_name) - .ok_or_else(|| anyhow::anyhow!("Provider '{provider_name}' not configured"))?; + // Get the provider: env-configured first, then config-backed + // (openspec has no env form and always resolves from config) + let provider = match get_provider(provider_name) { + Some(p) => p, + None => get_provider_from_config(&self.config.kanban, provider_name, project_key) + .map_err(|e| anyhow::anyhow!("Provider '{provider_name}' not configured: {e}"))?, + }; // Get the project config let project_config = self @@ -188,7 +218,7 @@ impl KanbanSyncService { match self.create_ticket_from_issue( &issue, provider_name, - project_key, + ticket_project(&project_config, project_key).as_str(), type_mappings, project_config.collection_name.as_deref(), ) { @@ -281,6 +311,24 @@ impl KanbanSyncService { } None } + "github" => { + for github_config in self.config.kanban.github.values() { + if let Some(config) = github_config.projects.get(project_key) { + return Some(config.clone()); + } + } + None + } + // OpenSpec has no per-change config; synthesize one from the + // enabled instance (pull unchecked groups only). + "openspec" => { + let cfg = self.config.kanban.openspec.values().find(|c| c.enabled)?; + Some(ProjectSyncConfig { + status_mapping: openspec_status_mapping(), + ticket_project: cfg.project.clone(), + ..Default::default() + }) + } _ => None, } } @@ -393,7 +441,7 @@ external_url: {} external_provider: {}{} ---", ticket_type, - issue.key.replace('-', ""), + sanitize_external_key(&issue.key), map_priority(&issue.priority), collection_line, issue.key, @@ -465,6 +513,33 @@ fn map_priority(priority: &Option) -> &'static str { } } +/// Default status mapping for openspec: pull unchecked ("todo") groups only +fn openspec_status_mapping() -> crate::config::KanbanStatusMapping { + crate::config::KanbanStatusMapping { + todo: Some(crate::api::providers::kanban::OPENSPEC_STATUS_TODO.to_string()), + doing: None, + done: None, + } +} + +/// Effective operator project for tickets from this source. Filename parsing +/// splits on hyphens, so the project component must not contain any. +fn ticket_project(config: &ProjectSyncConfig, project_key: &str) -> String { + let raw = config.ticket_project.as_deref().unwrap_or(project_key); + let cleaned: String = raw.chars().filter(char::is_ascii_alphanumeric).collect(); + if cleaned.is_empty() { + "external".to_string() + } else { + cleaned + } +} + +/// External issue keys become the numeric-ish part of the ticket id; keep it +/// to alphanumerics so ids stay one hyphen-delimited token. +fn sanitize_external_key(key: &str) -> String { + key.chars().filter(char::is_ascii_alphanumeric).collect() +} + /// Convert a string to a URL-safe slug fn slugify(s: &str, max_len: usize) -> String { let slug: String = s @@ -667,4 +742,102 @@ status: queued assert!(!result.is_success()); } + + // ── OpenSpec end-to-end sync ──────────────────────────────────────────── + + fn openspec_test_config(root: &std::path::Path) -> Config { + let mut config = Config::default(); + config.paths.tickets = root.join(".tickets").to_string_lossy().into_owned(); + config.kanban.openspec.insert( + "demo".to_string(), + crate::config::OpenspecConfig { + enabled: true, + root_path: root.join("openspec").to_string_lossy().into_owned(), + project: Some("myproj".to_string()), + }, + ); + config + } + + fn write_openspec_fixture(root: &std::path::Path) { + let change = root.join("openspec/changes/add-dark-mode"); + fs::create_dir_all(&change).unwrap(); + fs::write( + change.join("proposal.md"), + "# Proposal: Add dark mode\n\n## Why\n\nUsers asked.\n", + ) + .unwrap(); + fs::write( + change.join("tasks.md"), + "# Tasks\n\n## 1. Theme Infrastructure\n- [ ] 1.1 Create ThemeContext\n\n## 2. UI Components\n- [ ] 2.1 Create toggle\n\n## 3. Done Already\n- [x] 3.1 finished\n", + ) + .unwrap(); + fs::create_dir_all(root.join("openspec/changes/archive/2026-01-01-old")).unwrap(); + } + + #[tokio::test] + async fn test_openspec_sync_creates_tickets_and_reimport_skips() { + let temp = tempfile::tempdir().unwrap(); + write_openspec_fixture(temp.path()); + let config = openspec_test_config(temp.path()); + let service = KanbanSyncService::new(&config); + + // Configured collections enumerate the active change (not the archive) + let collections = service.configured_collections(); + let openspec: Vec<_> = collections + .iter() + .filter(|c| c.provider == "openspec") + .collect(); + assert_eq!(openspec.len(), 1); + assert_eq!(openspec[0].project_key, "add-dark-mode"); + + let result = service + .sync_collection("openspec", "add-dark-mode") + .await + .unwrap(); + // Group 3 is fully checked ("done") and filtered out by the pull statuses + assert_eq!(result.created.len(), 2, "errors: {:?}", result.errors); + assert!(result.created.contains(&"add-dark-mode#1".to_string())); + + // Ticket files land in the queue with openspec provenance and the + // configured operator project (not the hyphenated change id) + let queue = temp.path().join(".tickets/queue"); + let files: Vec = fs::read_dir(&queue) + .unwrap() + .filter_map(Result::ok) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!(files.len(), 2); + assert!(files.iter().all(|f| f.contains("-myproj-")), "{files:?}"); + + let content = + fs::read_to_string(queue.join(files.iter().find(|f| f.contains("theme")).unwrap())) + .unwrap(); + assert!(content.contains("external_provider: openspec")); + assert!(content.contains("external_id: add-dark-mode#1")); + assert!(content.contains("- [ ] 1.1 Create ThemeContext")); + assert!(content.contains("Users asked.")); + + // Re-import is idempotent: everything skips, nothing new is created + let rerun = service + .sync_collection("openspec", "add-dark-mode") + .await + .unwrap(); + assert!(rerun.created.is_empty()); + assert_eq!(rerun.skipped.len(), 2); + assert_eq!(fs::read_dir(&queue).unwrap().count(), 2); + } + + #[tokio::test] + async fn test_openspec_sync_unknown_change_errors() { + let temp = tempfile::tempdir().unwrap(); + write_openspec_fixture(temp.path()); + let config = openspec_test_config(temp.path()); + let service = KanbanSyncService::new(&config); + + assert!(service + .sync_collection("openspec", "no-such-change") + .await + .is_err()); + } } diff --git a/src/state.rs b/src/state.rs index 4588c120..c50a0a50 100644 --- a/src/state.rs +++ b/src/state.rs @@ -114,6 +114,9 @@ pub struct AgentState { /// Path to the git worktree for this ticket (per-ticket isolation) #[serde(default)] pub worktree_path: Option, + /// Name of the `RemoteHost` this agent's CLI runs on over SSH (None = local) + #[serde(default)] + pub remote_host: Option, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] @@ -328,6 +331,7 @@ impl State { review_state: None, dev_server_pid: None, worktree_path: None, + remote_host: None, }); self.save()?; @@ -382,6 +386,7 @@ impl State { review_state: None, dev_server_pid: None, worktree_path: None, + remote_host: None, }); self.save()?; @@ -500,6 +505,14 @@ impl State { self.save() } + /// Record which remote host an agent's CLI process runs on + pub fn update_agent_remote_host(&mut self, agent_id: &str, host_name: &str) -> Result<()> { + if let Some(agent) = self.agents.iter_mut().find(|a| a.id == agent_id) { + agent.remote_host = Some(host_name.to_string()); + } + self.save() + } + /// Update the content hash for an agent (for change detection) pub fn update_agent_content_hash(&mut self, agent_id: &str, hash: &str) -> Result { if let Some(agent) = self.agents.iter_mut().find(|a| a.id == agent_id) { @@ -1049,6 +1062,42 @@ mod tests { // ─── Load/Save Tests ───────────────────────────────────────────────────────── + #[test] + fn test_agent_remote_host_roundtrip_and_legacy_default() { + let temp_dir = TempDir::new().unwrap(); + let config = test_config(&temp_dir); + + let mut state = State::load(&config).unwrap(); + let id = state + .add_agent_with_full_options( + "FEAT-001".to_string(), + "FEAT".to_string(), + "proj".to_string(), + false, + Some("claude".to_string()), + None, + None, + ) + .unwrap(); + state.update_agent_remote_host(&id, "gpu-vm").unwrap(); + + let reloaded = State::load(&config).unwrap(); + assert_eq!( + reloaded.agents[0].remote_host.as_deref(), + Some("gpu-vm"), + "remote_host persists across save/load" + ); + + // Legacy state JSON without the field still deserializes to None. + let mut v = serde_json::to_value(&reloaded).unwrap(); + v["agents"][0] + .as_object_mut() + .unwrap() + .remove("remote_host"); + let legacy: State = serde_json::from_value(v).unwrap(); + assert!(legacy.agents[0].remote_host.is_none()); + } + #[test] fn test_state_load_missing_file() { let temp_dir = TempDir::new().unwrap(); diff --git a/src/ui/in_progress_panel.rs b/src/ui/in_progress_panel.rs index d1cb0880..a02e399f 100644 --- a/src/ui/in_progress_panel.rs +++ b/src/ui/in_progress_panel.rs @@ -189,6 +189,14 @@ impl InProgressPanel { Span::styled(elapsed_display, Style::default().fg(Color::DarkGray)), ]; + // Remote-host annotation (agent CLI runs on another machine) + if let Some(ref host) = a.remote_host { + line2_spans.push(Span::styled( + format!(" @{host}"), + Style::default().fg(Color::Cyan), + )); + } + // Add cmux workspace/window refs (abbreviated to first 6 chars) if a.session_wrapper.as_deref() == Some("cmux") { if let Some(ref ws_ref) = a.session_context_ref { @@ -345,6 +353,7 @@ mod tests { review_state: None, dev_server_pid: None, worktree_path: None, + remote_host: None, } } diff --git a/src/ui/sections/kanban_section.rs b/src/ui/sections/kanban_section.rs index d07dde3b..cbd9d6be 100644 --- a/src/ui/sections/kanban_section.rs +++ b/src/ui/sections/kanban_section.rs @@ -180,13 +180,13 @@ mod tests { } #[test] - fn test_kanban_children_empty_shows_all_three_configure_options() { + fn test_kanban_children_empty_shows_all_configure_options() { let section = KanbanSection; let snap = base_snapshot(); let children = section.children(&snap); // Every supported provider is offered when none are connected. - assert_eq!(children.len(), 3); + assert_eq!(children.len(), 4); assert_eq!(children[0].label, "Configure Jira Cloud"); assert_eq!(children[0].description, "Connect to Jira Cloud"); assert_eq!( @@ -210,6 +210,13 @@ mod tests { provider: "github".into() } ); + assert_eq!(children[3].label, "Configure OpenSpec"); + assert_eq!( + children[3].actions.primary, + StatusAction::ConfigureKanbanProvider { + provider: "openspec".into() + } + ); } #[test] @@ -222,8 +229,8 @@ mod tests { }); let children = section.children(&snap); - // The connected provider row, plus "add" rows for the two not yet connected. - assert_eq!(children.len(), 3); + // The connected provider row, plus "add" rows for those not yet connected. + assert_eq!(children.len(), 4); assert_eq!(children[0].label, "jira"); assert_eq!(children[0].description, "myteam.atlassian.net"); assert_eq!(children[0].actions.primary, StatusAction::None); @@ -235,6 +242,6 @@ mod tests { _ => None, }) .collect(); - assert_eq!(configure, vec!["linear", "github"]); + assert_eq!(configure, vec!["linear", "github", "openspec"]); } } diff --git a/src/ui/session_preview.rs b/src/ui/session_preview.rs index 469369d0..cb271c7e 100644 --- a/src/ui/session_preview.rs +++ b/src/ui/session_preview.rs @@ -344,6 +344,7 @@ mod tests { review_state: None, dev_server_pid: None, worktree_path: None, + remote_host: None, session_wrapper: None, session_window_ref: None, session_context_ref: None, diff --git a/src/ui/setup/steps/kanban.rs b/src/ui/setup/steps/kanban.rs index b6435ff9..c68eddac 100644 --- a/src/ui/setup/steps/kanban.rs +++ b/src/ui/setup/steps/kanban.rs @@ -32,7 +32,7 @@ impl SetupScreen { Constraint::Length(2), // Description Constraint::Length(1), // Spacer Constraint::Length(3), // Supported providers header - Constraint::Length(5), // Supported providers list (3 providers) + Constraint::Length(6), // Supported providers list (4 providers) Constraint::Length(1), // Spacer Constraint::Length(2), // Detected header Constraint::Min(6), // Detected providers list @@ -98,6 +98,16 @@ impl SetupScreen { ), Span::raw(")"), ]), + Line::from(vec![ + Span::raw(" • "), + Span::styled("OpenSpec", Style::default().fg(Color::White)), + Span::raw(" ("), + Span::styled( + "local files, experimental", + Style::default().fg(Color::DarkGray), + ), + Span::raw(")"), + ]), ]); frame.render_widget(supported, chunks[4]); @@ -129,6 +139,7 @@ impl SetupScreen { KanbanProviderType::Jira => "Jira", KanbanProviderType::Linear => "Linear", KanbanProviderType::Github => "GitHub", + KanbanProviderType::Openspec => "OpenSpec", }; let status_text = match &provider.status { @@ -206,6 +217,7 @@ impl SetupScreen { KanbanProviderType::Jira => "Jira", KanbanProviderType::Linear => "Linear", KanbanProviderType::Github => "GitHub", + KanbanProviderType::Openspec => "OpenSpec", }; format!(" Setup: {} - {} ", provider_name, p.domain) } else { diff --git a/src/ui/setup/types.rs b/src/ui/setup/types.rs index 187d1007..b718ad45 100644 --- a/src/ui/setup/types.rs +++ b/src/ui/setup/types.rs @@ -141,6 +141,7 @@ fn provider_base_url(p: &DetectedKanbanProvider) -> String { } KanbanProviderType::Linear => "https://linear.app".to_string(), KanbanProviderType::Github => "https://github.com".to_string(), + KanbanProviderType::Openspec => p.provider_type.setup_url().to_string(), } } diff --git a/src/ui/status_panel.rs b/src/ui/status_panel.rs index bfcc3258..c984627a 100644 --- a/src/ui/status_panel.rs +++ b/src/ui/status_panel.rs @@ -696,6 +696,12 @@ impl StatusSnapshot { domain: owner.clone(), }); } + for instance in config.kanban.openspec.keys() { + kanban_providers.push(KanbanProviderInfo { + provider_type: KanbanProviderType::Openspec.slug().to_string(), + domain: instance.clone(), + }); + } let llm_tools: Vec = config .llm_tools @@ -1573,7 +1579,7 @@ mod tests { fn test_build_section_dtos_kanban_configure_rows_have_link_actions() { // End-to-end projection: the Kanban section's TreeRows must survive // `web_actions` with populated, clickable links so the web `/#/kanban` - // view shows all three providers as actionable options. This guards the + // view shows every provider as an actionable option. This guards the // `children() -> web_actions -> SectionRowDto` composition, not just the // pieces in isolation. let snap = StatusSnapshot::from_config(&crate::config::Config::default(), vec![]); @@ -1583,8 +1589,8 @@ mod tests { .find(|d| d.id == "kanban") .expect("kanban section present"); - // All three providers are offered when none are connected. - assert_eq!(kanban.children.len(), 3); + // Every supported provider is offered when none are connected. + assert_eq!(kanban.children.len(), 4); for (id, expected_url) in [ ("configure-jira", "id.atlassian.com"), @@ -1593,6 +1599,10 @@ mod tests { "configure-github", "github.com/settings/personal-access-tokens", ), + ( + "configure-openspec", + "operator.untra.io/getting-started/kanban/openspec", + ), ] { let row = kanban .children diff --git a/vscode-extension/docs/schemas/jira-api.json b/vscode-extension/docs/schemas/jira-api.json index c4a7c4de..fb904c10 100644 --- a/vscode-extension/docs/schemas/jira-api.json +++ b/vscode-extension/docs/schemas/jira-api.json @@ -117,6 +117,14 @@ "description": "Reference to an issue type", "type": "object", "properties": { + "id": { + "description": "Issue type ID (e.g., \"10001\")", + "type": [ + "string", + "null" + ], + "default": null + }, "name": { "description": "Issue type name (e.g., \"Bug\", \"Story\", \"Task\")", "type": "string" diff --git a/vscode-extension/docs/schemas/state.json b/vscode-extension/docs/schemas/state.json index 4bf159d9..13705282 100644 --- a/vscode-extension/docs/schemas/state.json +++ b/vscode-extension/docs/schemas/state.json @@ -31,12 +31,20 @@ "default": {} }, "project_collection_prefs": { - "description": "Per-project issue type collection preferences (project_name -> collection_name)", + "description": "Per-project issue type collection preferences (`project_name` -> `collection_name`)", "type": "object", "additionalProperties": { "type": "string" }, "default": {} + }, + "multi_agent_groups": { + "description": "Active multi-agent step groups (`multi_model`, `multi_prompt`, `matrixed`)", + "type": "array", + "items": { + "$ref": "#/$defs/MultiAgentGroup" + }, + "default": [] } }, "required": [ @@ -96,24 +104,24 @@ ], "default": null }, - "cmux_window_ref": { - "description": "cmux window reference ID", + "session_window_ref": { + "description": "Session window reference ID (top-level grouping: cmux window, tmux session, etc.)", "type": [ "string", "null" ], "default": null }, - "cmux_workspace_ref": { - "description": "cmux workspace reference ID", + "session_context_ref": { + "description": "Session context reference ID (mid-level: cmux workspace, tmux window, etc.)", "type": [ "string", "null" ], "default": null }, - "cmux_surface_ref": { - "description": "cmux surface reference ID", + "session_pane_ref": { + "description": "Session pane reference ID (leaf-level: cmux surface, tmux pane, etc.)", "type": [ "string", "null" @@ -181,7 +189,7 @@ "default": null }, "pr_status": { - "description": "Last known PR status (\"open\", \"approved\", \"changes_requested\", \"merged\", \"closed\")", + "description": "Last known PR status (\"open\", \"approved\", \"`changes_requested`\", \"merged\", \"closed\")", "type": [ "string", "null" @@ -221,7 +229,7 @@ "default": null }, "review_state": { - "description": "Review state for awaiting_input agents\nValues: \"pending_plan\", \"pending_visual\", \"pending_pr_creation\", \"pending_pr_merge\"", + "description": "Review state for `awaiting_input` agents\nValues: \"`pending_plan`\", \"`pending_visual`\", \"`pending_pr_creation`\", \"`pending_pr_merge`\"", "type": [ "string", "null" @@ -245,6 +253,14 @@ "null" ], "default": null + }, + "remote_host": { + "description": "Name of the `RemoteHost` this agent's CLI runs on over SSH (None = local)", + "type": [ + "string", + "null" + ], + "default": null } }, "required": [ @@ -437,6 +453,128 @@ "required": [ "model" ] + }, + "MultiAgentGroup": { + "description": "Tracks a group of agents working on a single multi-agent step", + "type": "object", + "properties": { + "group_id": { + "description": "Unique group identifier", + "type": "string" + }, + "ticket_id": { + "description": "Ticket this group belongs to", + "type": "string" + }, + "step_name": { + "description": "Step name being executed", + "type": "string" + }, + "step_type": { + "description": "Step type (`multi_model`, `multi_prompt`, `matrixed`)", + "type": "string" + }, + "agent_ids": { + "description": "Agent IDs in this group (populated as sub-agents launch)", + "type": "array", + "items": { + "type": "string" + } + }, + "phase": { + "description": "Current execution phase", + "$ref": "#/$defs/MultiAgentPhase" + }, + "individual_outputs": { + "description": "Collected outputs from completed sub-agents, keyed by `variant_key`\n(delegator name for `multi_model`, index for `multi_prompt`,\n`{delegator}:{prompt_idx}` for `matrixed`).", + "type": "object", + "additionalProperties": true, + "default": {} + }, + "aggregated_output": { + "description": "Final aggregated output (set when phase = Complete)", + "default": null + }, + "expected_total": { + "description": "Total sub-agents expected (`agent_ids.len() + pending_launches.len()`).", + "type": "integer", + "format": "uint", + "minimum": 0, + "default": 0 + }, + "pending_launches": { + "description": "Sub-agents that still need launching (waiting for a free slot).", + "type": "array", + "items": { + "$ref": "#/$defs/PendingSubAgent" + }, + "default": [] + }, + "agent_variant_keys": { + "description": "Maps launched `agent_id` to the `variant_key` used as the output key.", + "type": "object", + "additionalProperties": { + "type": "string" + }, + "default": {} + } + }, + "required": [ + "group_id", + "ticket_id", + "step_name", + "step_type", + "agent_ids", + "phase" + ] + }, + "MultiAgentPhase": { + "description": "Execution phase for a multi-agent group", + "oneOf": [ + { + "description": "Phase 1: all sub-agents running the initial prompt", + "type": "string", + "const": "fan_out" + }, + { + "description": "Phase 2: voting/selection round (`multi_model` with `share_answers`)", + "type": "string", + "const": "voting" + }, + { + "description": "All done, aggregated output ready", + "type": "string", + "const": "complete" + }, + { + "description": "One or more sub-agents failed", + "type": "string", + "const": "failed" + } + ] + }, + "PendingSubAgent": { + "description": "A sub-agent that has been planned but not yet launched (slot queue).", + "type": "object", + "properties": { + "delegator_name": { + "description": "Delegator (from `config.delegators`) this sub-agent should use.", + "type": "string" + }, + "prompt": { + "description": "Fully-rendered prompt text for this sub-agent.", + "type": "string" + }, + "variant_key": { + "description": "Key under which this sub-agent's output is recorded (see `individual_outputs`).", + "type": "string" + } + }, + "required": [ + "delegator_name", + "prompt", + "variant_key" + ] } } } \ No newline at end of file diff --git a/vscode-extension/shared/types.ts b/vscode-extension/shared/types.ts new file mode 100644 index 00000000..efaaa640 --- /dev/null +++ b/vscode-extension/shared/types.ts @@ -0,0 +1,1700 @@ +// ============================================================================= +// AUTO-GENERATED FILE - DO NOT EDIT MANUALLY +// ============================================================================= +// Generated by: cargo run --bin generate_types +// Source: src/**/*.rs (types with #[derive(TS)] and #[ts(export)]) +// +// To regenerate: cargo run --bin generate_types +// To verify: cargo run --bin generate_types --check +// ============================================================================= + +export type Project = { +/** + * Unique identifier + */ +id: string, +/** + * Human-readable project name + */ +name: string, +/** + * Filesystem path to the project root + */ +path: string, +/** + * Associated git repositories (can be multiple for monorepos) + */ +repos: Array, +/** + * Default branch for merging (e.g., "main", "master") + */ +default_branch: string | null, +/** + * Path to the AI context file (CLAUDE.md, GEMINI.md, etc.) + */ +ai_context_path: string | null, +/** + * Project taxonomy kind (tier 1-5) + */ +kind: string | null, +/** + * Tags for categorization + */ +tags: Array, +/** + * Development server startup command + */ +dev_script: string | null, +/** + * Working directory for dev script execution + */ +dev_script_working_dir: string | null, +/** + * Default working directory for AI agents + */ +default_agent_working_dir: string | null, +/** + * Setup script to run before agent execution + */ +setup_script: string | null, +/** + * Cleanup script to run after agent execution + */ +cleanup_script: string | null, +/** + * Link to shared organizational project + */ +remote_project_id: string | null, created_at: string, updated_at: string, }; + +export type ProjectRepo = { +/** + * Unique identifier for this repo association + */ +id: string, +/** + * Absolute path to the repository root + */ +path: string, +/** + * Whether this is the primary repo for the project + */ +is_primary: boolean, +/** + * Setup script to run when creating worktrees for this repo + */ +setup_script: string | null, +/** + * Cleanup script to run when deleting worktrees + */ +cleanup_script: string | null, +/** + * Files to copy into worktrees (e.g., .env files) + */ +copy_files: Array, }; + +export type StepAttempt = { +/** + * Unique identifier for this attempt + */ +id: string, +/** + * Parent ticket ID (e.g., "FEAT-1234") + */ +ticket_id: string, +/** + * Which step this attempt is executing (e.g., "plan", "implement", "test") + */ +step_name: string, +/** + * Project name this attempt belongs to + */ +project: string, +/** + * Git branch for this attempt (e.g., "feat/abc123-add-login") + */ +branch: string, +/** + * Target branch to merge into (e.g., "main") + */ +target_branch: string, +/** + * Path to isolated git worktree (if using worktree isolation) + */ +worktree_path: string | null, +/** + * Whether the worktree has been cleaned up + */ +worktree_deleted: boolean, +/** + * Which LLM tool is executing (e.g., "claude", "gemini", "codex") + */ +executor: string, +/** + * Executor model override (e.g., "claude-sonnet-4-20250514") + */ +executor_model: string | null, +/** + * Current session ID for conversational continuity + */ +session_id: string | null, +/** + * Launch mode: "default", "yolo", "docker", "docker-yolo" + */ +launch_mode: string | null, +/** + * Current status of this attempt + */ +status: AttemptStatus, +/** + * Whether this attempt requires human pairing (SPIKE/INV modes) + */ +paired: boolean, +/** + * Terminal session name (for Operator's terminal-based execution) + */ +terminal_session: string | null, +/** + * Which session wrapper manages this attempt: "tmux", "vscode", or "cmux" + */ +session_wrapper: string | null, +/** + * Hash of last captured terminal content (for change detection) + */ +content_hash: string | null, +/** + * Pull request URL if created + */ +pr_url: string | null, +/** + * PR number for API tracking + */ +pr_number: bigint | null, +/** + * GitHub repo in format "owner/repo" + */ +github_repo: string | null, +/** + * Current PR status ("open", "approved", "`changes_requested`", "merged", "closed") + */ +pr_status: string | null, +/** + * When setup completed (worktree created, scripts run) + */ +setup_completed_at: string | null, +/** + * Last activity timestamp + */ +last_activity: string, +/** + * Last time terminal content changed (for hung detection) + */ +last_content_change: string | null, created_at: string, updated_at: string, }; + +export type AttemptStatus = "pending" | "setting" | "running" | "awaitinginput" | "inreview" | "completed" | "failed" | "cancelled" | "orphaned"; + +export type ExecutionProcess = { +/** + * Unique identifier + */ +id: string, +/** + * Parent attempt ID + */ +attempt_id: string, +/** + * Why this process was spawned + */ +run_reason: RunReason, +/** + * Action chain configuration (for sequential execution) + */ +executor_action: JsonValue | null, +/** + * Git HEAD before execution started + */ +before_head_commit: string | null, +/** + * Git HEAD after execution completed + */ +after_head_commit: string | null, +/** + * Current process status + */ +status: ProcessStatus, +/** + * Process exit code (if completed) + */ +exit_code: number | null, +/** + * Whether this process is excluded from the timeline (e.g., after retry) + */ +dropped: boolean, started_at: string, completed_at: string | null, }; + +export type ProcessStatus = "running" | "completed" | "failed" | "killed"; + +export type RunReason = "setupscript" | "codingagent" | "cleanup" | "followup"; + +export type Session = { +/** + * Unique identifier + */ +id: string, +/** + * Parent attempt ID + */ +attempt_id: string, +/** + * Terminal session name for terminal-based execution + */ +terminal_session_name: string | null, +/** + * Which session wrapper manages this session: "tmux", "vscode", or "cmux" + */ +session_wrapper: string | null, +/** + * Hash of terminal content for change detection + */ +content_hash: string | null, +/** + * Agent's internal session ID (e.g., Claude's conversation ID) + */ +agent_session_id: string | null, +/** + * Summary extracted from message store + */ +summary: string | null, created_at: string, updated_at: string, }; + +export type Config = { +/** + * List of projects operator can assign work to + */ +projects: Array, agents: AgentsConfig, notifications: NotificationsConfig, queue: QueueConfig, paths: PathsConfig, ui: UiConfig, launch: LaunchConfig, templates: TemplatesConfig, api: ApiConfig, logging: LoggingConfig, tmux: TmuxConfig, +/** + * Session wrapper configuration (tmux, vscode, or cmux) + */ +sessions: SessionsConfig, llm_tools: LlmToolsConfig, rest_api: RestApiConfig, git: GitConfig, +/** + * Kanban provider configuration for syncing issues from Jira, Linear, etc. + */ +kanban: KanbanConfig, +/** + * Version check configuration for automatic update notifications + */ +version_check: VersionCheckConfig, +/** + * Agent delegator configurations for autonomous ticket launching + */ +delegators: Array, +/** + * User-declared model servers (ollama, lmstudio, any OpenAI-compat host). + * Implicit builtin servers exist for each `llm_tool`'s vendor API and do not need declaration. + */ +model_servers: Array, +/** + * Remote machines agents can be launched on over SSH, referenced by name + * from `DelegatorLaunchConfig.host`. + */ +hosts: Array, +/** + * Relay MCP injection configuration + */ +relay: RelayConfig, +/** + * Model Context Protocol (MCP) server configuration + */ +mcp: McpConfig, +/** + * Agent Client Protocol (ACP) agent configuration + */ +acp: AcpConfig, }; + +export type AgentsConfig = { max_parallel: number, cores_reserved: number, +/** + * Maximum concurrent agents per project/repo (default: 1). + * Requires `git.use_worktrees` = true when > 1 to avoid conflicts. + */ +max_agents_per_repo: number, health_check_interval: bigint, +/** + * Timeout in seconds for each agent generation (default: 300 = 5 min) + */ +generation_timeout_secs: bigint, +/** + * Interval in seconds between ticket-session syncs (default: 60) + */ +sync_interval: bigint, +/** + * Maximum seconds a step can run before timing out (default: 1800 = 30 min) + */ +step_timeout: bigint, +/** + * Seconds of tmux silence before considering agent awaiting input (default: 30) + */ +silence_threshold: bigint, }; + +export type NotificationsConfig = { +/** + * Global enabled flag for all notifications + */ +enabled: boolean, +/** + * OS notification configuration + */ +os: OsNotificationConfig, +/** + * Single webhook configuration (for simple setups) + */ +webhook: WebhookConfig | null, +/** + * Multiple webhook configurations + */ +webhooks: Array, }; + +export type QueueConfig = { auto_assign: boolean, priority_order: Array, poll_interval_ms: bigint, }; + +export type PathsConfig = { tickets: string, projects: string, state: string, +/** + * Base directory for per-ticket worktrees (default: ~/.operator/worktrees) + */ +worktrees: string, }; + +export type UiConfig = { refresh_rate_ms: bigint, completed_history_hours: bigint, summary_max_length: number, panel_names: PanelNamesConfig, }; + +export type PanelNamesConfig = { status: string, queue: string, in_progress: string, completed: string, }; + +export type LaunchConfig = { confirm_autonomous: boolean, confirm_paired: boolean, launch_delay_ms: bigint, +/** + * Docker execution configuration + */ +docker: DockerConfig, +/** + * YOLO (auto-accept) mode configuration + */ +yolo: YoloConfig, }; + +export type DockerConfig = { +/** + * Whether docker mode option is available in launch dialog + */ +enabled: boolean, +/** + * Docker image to use (required if enabled) + */ +image: string, +/** + * Additional docker run arguments + */ +extra_args: Array, +/** + * Container mount path for the project (default: /workspace) + */ +mount_path: string, +/** + * Environment variables to pass through to the container + */ +env_vars: Array, }; + +export type YoloConfig = { +/** + * Whether YOLO mode option is available in launch dialog + */ +enabled: boolean, }; + +export type TmuxConfig = { +/** + * Whether custom tmux config has been generated + */ +config_generated: boolean, }; + +export type RestApiConfig = { +/** + * Whether the REST API is enabled + */ +enabled: boolean, +/** + * Address the REST API binds to. Defaults to `127.0.0.1` (local only) so + * the server — which reports the project directory name — is not reachable + * from other hosts. Set to `0.0.0.0` to expose it on all interfaces. + */ +host: string, +/** + * Port for the REST API server + */ +port: number, +/** + * CORS allowed origins (empty = allow all) + */ +cors_origins: Array, }; + +export type LlmToolsConfig = { +/** + * Detected CLI tools (populated on first startup) + */ +detected: Array, +/** + * Available {tool, model} pairs for launching tickets + * Built from detected tools + their model aliases + */ +providers: Array, +/** + * Whether detection has been completed + */ +detection_complete: boolean, +/** + * User's preferred default LLM tool (e.g., "claude") + */ +default_tool: string | null, +/** + * User's preferred default model alias (e.g., "opus") + */ +default_model: string | null, +/** + * Per-tool overrides for skill directories (keyed by `tool_name`) + */ +skill_directory_overrides: { [key in string]: SkillDirectoriesOverride }, }; + +export type DetectedTool = { +/** + * Tool name (e.g., "claude") + */ +name: string, +/** + * Path to the binary + */ +path: string, +/** + * Version string + */ +version: string, +/** + * Minimum required version for Operator compatibility + */ +min_version: string | null, +/** + * Whether the installed version meets the minimum requirement + */ +version_ok: boolean, +/** + * Available model aliases (e.g., ["opus", "sonnet", "haiku"]) + */ +model_aliases: Array, +/** + * Command template with {{model}}, {{`session_id`}}, {{`prompt_file`}} placeholders + */ +command_template: string, +/** + * Tool capabilities + */ +capabilities: ToolCapabilities, +/** + * CLI flags for YOLO (auto-accept) mode + */ +yolo_flags: Array, }; + +export type ToolCapabilities = { +/** + * Whether the tool supports session continuity via UUID + */ +supports_sessions: boolean, +/** + * Whether the tool can run in headless/non-interactive mode + */ +supports_headless: boolean, }; + +export type LlmProvider = { +/** + * CLI tool name (e.g., "claude", "codex", "gemini") + */ +tool: string, +/** + * Model alias or name (e.g., "opus", "sonnet", "gpt-4.1") + */ +model: string, +/** + * Optional display name for UI (e.g., "Claude Opus", "Codex High") + */ +display_name: string | null, +/** + * Additional CLI flags for this provider (e.g., ["--dangerously-skip-permissions"]) + */ +flags: Array, +/** + * Environment variables to set when launching + */ +env: { [key in string]: string }, +/** + * Whether this provider requires approval gates + */ +approvals: boolean, +/** + * Whether to run in plan-only mode + */ +plan_only: boolean, +/** + * Reasoning effort level (Codex: "low", "medium", "high") + */ +reasoning_effort: string | null, +/** + * Sandbox mode (Codex: "danger-full-access", "workspace-write") + */ +sandbox: string | null, }; + +export type SkillDirectoriesOverride = { +/** + * Additional global skill directories + */ +global: Array, +/** + * Additional project-relative skill directories + */ +project: Array, }; + +export type Delegator = { +/** + * Unique name for this delegator (e.g., "claude-opus-auto") + */ +name: string, +/** + * LLM tool name (must match a detected tool, e.g., "claude", "codex") + */ +llm_tool: string, +/** + * Model alias (e.g., "opus", "sonnet", "gpt-4o") + */ +model: string, +/** + * Optional display name for UI + */ +display_name: string | null, +/** + * Arbitrary model properties (e.g., `reasoning_effort`, sandbox) + */ +model_properties: { [key in string]: string }, +/** + * Optional launch configuration + */ +launch_config: DelegatorLaunchConfig | null, +/** + * Name of a declared `ModelServer` (from `Config.model_servers`). + * `None` means use the `llm_tool`'s implicit vendor default + * (claude → anthropic-api, codex → openai-api, gemini → google-api). + */ +model_server: string | null, +/** + * Declarative reference to a remote, named agent on another platform + * (e.g. an AGNT agent or an `OpenAI` Assistant; see [`crate::config::AgentProfile`]). + * + * Export-only: Operator has no runtime client for those platforms, so a + * delegator carrying this CANNOT be launched locally — resolution errors out + * (see `delegator_resolution`). It is stored, listed, serialized into an + * `AgentProfile`, and — for `platform == "agnt"` — surfaced in the + * `--format agnt` workflow export as a native AGNT `agnt-agent` node, whose + * `agentId` is this reference's `id` (AGNT identifies agents by UUID, so the + * `id` must be the agent's UUID, not its display name). `None` = ordinary, + * locally launchable delegator. + */ +remote_agent?: RemoteAgentRef | null, +/** + * Opaque AGNT-namespaced extension fields, preserved verbatim across an + * `AgentProfile` round-trip so re-export is lossless (e.g. `memory`, + * `assignedWorkflows`, `creditLimit`). Operator never interprets this. + */ +x_agnt?: JsonValue | null, +/** + * Opaque OpenAI-namespaced extension fields, preserved verbatim across an + * `AgentProfile` round-trip (e.g. `instructions`, `tools`, `tool_resources`, + * `metadata`, thread refs). Mirror of [`Self::x_agnt`]; never interpreted. + */ +x_openai?: JsonValue | null, +/** + * Opaque carry for `AgentProfile` shared-core fields Operator cannot model + * first-class (`system_prompt` / `skills` / `mcp_servers` / `tools`) so an + * import→export round-trip is lossless. Distinct from `x_agnt`: these are + * shared-core fields, not AGNT-specific, so folding them into `x_agnt` would + * corrupt that namespace. Operator never interprets this. + */ +unmapped_core?: JsonValue | null, }; + +export type DelegatorLaunchConfig = { +/** + * Run in YOLO (auto-accept) mode + */ +yolo: boolean, +/** + * Permission mode override + */ +permission_mode: string | null, +/** + * Additional CLI flags + */ +flags: Array, +/** + * Override global `git.use_worktrees` per-delegator (None = use global setting) + */ +use_worktrees: boolean | null, +/** + * Whether to create a git branch for the ticket (None = default behavior) + */ +create_branch: boolean | null, +/** + * Run in docker container (None = use global `launch.docker.enabled`) + */ +docker: boolean | null, +/** + * Prompt text to prepend before the generated step prompt + */ +prompt_prefix: string | null, +/** + * Prompt text to append after the generated step prompt + */ +prompt_suffix: string | null, +/** + * Override global relay auto-inject MCP setting per-delegator (None = use global setting) + */ +operator_relay: boolean | null, +/** + * Name of a declared `RemoteHost` (from `Config.hosts`) to launch the agent + * CLI on over SSH. `None` = launch locally. + */ +host?: string | null, }; + +export type AgentProfile = { +/** + * Unique agent name (maps to [`Delegator::name`]). + */ +name: string, +/** + * Inference provider / CLI tool (maps to [`Delegator::llm_tool`]). + */ +provider: string, +/** + * Model alias or id (maps to [`Delegator::model`]). + */ +model: string, +/** + * System prompt. Operator has no first-class system prompt, so this is + * preserved opaquely across import (see [`Delegator::unmapped_core`]). + */ +system_prompt?: string | null, +/** + * Named skills. Preserved opaquely across import. + */ +skills: Array, +/** + * MCP server names. Preserved opaquely across import. + */ +mcp_servers: Array, +/** + * Tool names. Preserved opaquely across import. + */ +tools: Array, +/** + * Declarative reference to a remote, named agent (AGNT, `OpenAI`, ...). + * `None` = a locally launchable agent, not bound to a remote platform. + */ +remote_agent?: RemoteAgentRef | null, +/** + * Operator-owned extension fields (typed). `None` when the agent carries no + * Operator-specific configuration. + */ +x_operator?: XOperator | null, +/** + * AGNT-owned extension fields, opaque (`memory`, `assignedWorkflows`, + * `creditLimit`, ...). Operator never interprets this — pure pass-through. + */ +x_agnt?: JsonValue | null, +/** + * OpenAI-owned extension fields, opaque (`instructions`, `tools`, + * `tool_resources`, `metadata`, thread refs, ...). Mirror of `x_agnt` for a + * second platform — never interpreted. This field is the whole per-tool cost + * of adding `OpenAI`: a passthrough bag, no mapping logic. + */ +x_openai?: JsonValue | null, }; + +export type XOperator = { +/** + * Optional display name for UI. + */ +display_name?: string | null, +/** + * Arbitrary model properties (e.g. `reasoning_effort`, sandbox). + */ +model_properties?: { [key in string]: string }, +/** + * Name of a declared `ModelServer` (`None` = implicit vendor default). + */ +model_server?: string | null, +/** + * Launch configuration (permission mode, flags, worktree/docker, prompt + * wrapping, ...). + */ +launch_config?: DelegatorLaunchConfig | null, }; + +export type RemoteAgentRef = { +/** + * Hosting platform (e.g. `"agnt"`, `"openai"`). + */ +platform: string, +/** + * Platform-native agent identifier (e.g. an AGNT agent UUID, an `OpenAI` `asst_…` id). + */ +id: string, }; + +export type CollectionPreset = "simple" | "dev_kanban" | "devops_kanban" | "custom"; + +export type TemplatesConfig = { +/** + * Named preset for issue type collection + * Options: simple, `dev_kanban`, `devops_kanban`, custom + */ +preset: CollectionPreset, +/** + * Custom issuetype collection (only used when preset = custom) + * List of issue type keys: TASK, FEAT, FIX, SPIKE, INV + */ +collection: Array, +/** + * Active collection name (overrides preset if set) + * Can be a builtin preset name or a user-defined collection + */ +active_collection: string | null, +/** + * Enable fetching hosted issuetype collections during setup. + * When disabled, only the embedded (offline) collections are offered. + */ +collections_fetch_enabled: boolean, +/** + * URL of the hosted collection index manifest, fetched during setup. + * Points at a `CollectionIndex` JSON document listing available collections. + */ +collections_manifest_url: string | null, +/** + * Timeout in seconds for hosted collection fetch HTTP requests. + */ +collections_fetch_timeout_secs: bigint, }; + +export type LoggingConfig = { +/** + * Log level filter (trace, debug, info, warn, error) + */ +level: string, +/** + * Whether to log to file in TUI mode (false = stderr for debugging) + */ +to_file: boolean, }; + +export type ApiConfig = { +/** + * Interval in seconds between PR status checks (default: 60) + */ +pr_check_interval_secs: bigint, +/** + * Interval in seconds between rate limit checks (default: 300) + */ +rate_limit_check_interval_secs: bigint, +/** + * Show warning when rate limit remaining is below this percentage (default: 0.2) + */ +rate_limit_warning_threshold: number, }; + +export type State = { paused: boolean, agents: Array, completed: Array, +/** + * Per-project LLM usage statistics + */ +project_llm_stats: { [key in string]: ProjectLlmStats }, +/** + * Per-project issue type collection preferences (`project_name` -> `collection_name`) + */ +project_collection_prefs: { [key in string]: string }, +/** + * Active multi-agent step groups (`multi_model`, `multi_prompt`, `matrixed`) + */ +multi_agent_groups: Array, }; + +export type AgentState = { id: string, ticket_id: string, ticket_type: string, project: string, status: string, started_at: string, last_activity: string, last_message: string | null, paired: boolean, +/** + * The terminal session name for this agent (for recovery) + */ +session_name: string | null, +/** + * Which session wrapper manages this agent: "tmux", "vscode", or "cmux" (None = legacy tmux) + */ +session_wrapper: string | null, +/** + * Session window reference ID (top-level grouping: cmux window, tmux session, etc.) + */ +session_window_ref: string | null, +/** + * Session context reference ID (mid-level: cmux workspace, tmux window, etc.) + */ +session_context_ref: string | null, +/** + * Session pane reference ID (leaf-level: cmux surface, tmux pane, etc.) + */ +session_pane_ref: string | null, +/** + * Hash of the last captured pane content (for change detection) + */ +content_hash: string | null, +/** + * Current step in the ticket workflow (e.g., "plan", "implement", "test") + */ +current_step: string | null, +/** + * When the current step started (for timeout detection) + */ +step_started_at: string | null, +/** + * Last time content changed in the session (for hung detection) + */ +last_content_change: string | null, +/** + * PR URL if created during "pr" step + */ +pr_url: string | null, +/** + * PR number for GitHub API tracking + */ +pr_number: bigint | null, +/** + * GitHub repo in format "owner/repo" + */ +github_repo: string | null, +/** + * Last known PR status ("open", "approved", "`changes_requested`", "merged", "closed") + */ +pr_status: string | null, +/** + * Completed steps for this ticket + */ +completed_steps: Array, +/** + * LLM tool used (e.g., "claude", "gemini", "codex") + */ +llm_tool: string | null, +/** + * LLM model alias (e.g., "opus", "sonnet", "gpt-4o") + */ +llm_model: string | null, +/** + * Launch mode: "default", "yolo", "docker", "docker-yolo" + */ +launch_mode: string | null, +/** + * Review state for `awaiting_input` agents + * Values: "`pending_plan`", "`pending_visual`", "`pending_pr_creation`", "`pending_pr_merge`" + */ +review_state: string | null, +/** + * Server process ID for visual review cleanup (if applicable) + */ +dev_server_pid: number | null, +/** + * Path to the git worktree for this ticket (per-ticket isolation) + */ +worktree_path: string | null, +/** + * Name of the `RemoteHost` this agent's CLI runs on over SSH (None = local) + */ +remote_host: string | null, }; + +export type CompletedTicket = { ticket_id: string, ticket_type: string, project: string, summary: string, completed_at: string, pr_url: string | null, output_tickets: Array, }; + +export type IssueTypeResponse = { key: string, name: string, description: string, mode: string, glyph: string, color: string | null, project_required: boolean, source: string, +/** + * Owning collection under resolution-order lookup + */ +collection?: string, fields: Array, steps: Array, }; + +export type IssueTypeSummary = { key: string, name: string, description: string, mode: string, glyph: string, color?: string, source: string, +/** + * Owning collection under resolution-order lookup + */ +collection?: string, stepCount: number, }; + +export type CreateIssueTypeRequest = { key: string, name: string, description: string, mode: string, glyph: string, color: string | null, project_required: boolean, fields: Array, steps: Array, +/** + * Target collection (defaults to the active collection) + */ +collection?: string, }; + +export type UpdateIssueTypeRequest = { name: string | null, description: string | null, mode: string | null, glyph: string | null, color: string | null, project_required: boolean | null, fields: Array | null, steps: Array | null, }; + +export type FieldResponse = { name: string, description: string, field_type: string, required: boolean, default: string | null, options: Array, placeholder: string | null, max_length: number | null, user_editable: boolean, }; + +export type CreateFieldRequest = { name: string, description: string, field_type: string, required: boolean, default: string | null, options: Array, placeholder: string | null, max_length: number | null, user_editable: boolean, }; + +export type StepResponse = { name: string, display_name: string | null, prompt: string, outputs: Array, allowed_tools: Array, +/** + * Type of review required: "none", "plan", "visual", "pr" + */ +review_type: string, next_step: string | null, permission_mode: string, }; + +export type CreateStepRequest = { name: string, display_name: string | null, prompt: string, outputs: Array, allowed_tools: Array, +/** + * Type of review required: "none", "plan", "visual", "pr" + */ +review_type: string, next_step: string | null, permission_mode: string, }; + +export type UpdateStepRequest = { display_name: string | null, prompt: string | null, outputs: Array | null, allowed_tools: Array | null, +/** + * Type of review required: "none", "plan", "visual", "pr" + */ +review_type: string | null, next_step: string | null, permission_mode: string | null, }; + +export type CollectionResponse = { name: string, description: string, types: Array, is_active: boolean, +/** + * Collection semver (present for hosted collections). + */ +version?: string | null, +/** + * Publisher identifier (present for hosted collections). + */ +publisher?: string | null, +/** + * Human author/attribution (present for hosted collections). + */ +author?: string | null, +/** + * Link to the collection's source repository or project page. + */ +url?: string | null, +/** + * SPDX license id. + */ +license?: string | null, +/** + * Provenance tier: `official` or `community`. + */ +tier: string, +/** + * Bare filename of the collection's SVG icon, next to its manifest. + */ +icon_path?: string | null, +/** + * ISO-8601 date the collection was first published. + */ +created?: string | null, +/** + * ISO-8601 date of the last substantive revision. + */ +updated?: string | null, +/** + * Descriptive workflow hints (present for hosted collections). + */ +workflow_hints?: WorkflowHintsDto | null, }; + +export type WorkflowHintsDto = { loop_kind: string | null, memory_surfaces: Array, review_gates: Array, external_tools: Array, stop_conditions: Array, runner_semantics: string, }; + +export type HealthResponse = { status: string, version: string, +/** + * Top-level directory name of the operator working root (e.g. "acme"). + */ +directory_name: string, +/** + * Non-reversible fingerprint of the working root's canonical path. + */ +directory_id: string, }; + +export type StatusResponse = { status: string, version: string, +/** + * Top-level directory name of the operator working root (e.g. "acme"). + */ +directory_name: string, +/** + * Non-reversible fingerprint of the working root's canonical path. + */ +directory_id: string, issuetype_count: number, collection_count: number, active_collection: string, }; + +export type SectionDto = { +/** + * Stable section id (e.g. "config", "connections", "kanban"). + */ +id: string, label: string, +/** + * Health: "green" | "yellow" | "red" | "gray". + */ +health: string, description: string, +/** + * Section ids that must be Green before this section is usable. + */ +prerequisites: Array, +/** + * Whether all prerequisites are met. Sections are always returned (the web + * UI styles unmet ones as locked) rather than hidden by progressive disclosure. + */ +met: boolean, children: Array, }; + +export type SectionRowDto = { +/** + * Stable, section-scoped row id. Clients use it as a tree key and to route + * row-specific commands without matching on the (mutable) display label. + * Dynamic rows carry their entity key (issue-type key, project name); + * static rows carry a fixed slug (e.g. "git-token"). + */ +id: string, +/** + * Nesting depth within the section (1 = direct child, 2 = grandchild). + * Lets clients rebuild the tree (e.g. LLM tools → model aliases). + */ +depth: number, label: string, description: string, +/** + * Icon hint (e.g. "check", "warning", "tool", "folder"). + */ +icon: string, +/** + * Optional vendor-brand basename (e.g. "ollama"). When set, the web UI + * renders `/icons/{brand_icon}.svg` instead of the semantic `icon`. + */ +brand_icon: string | null, +/** + * Health: "green" | "yellow" | "red" | "gray". + */ +health: string, +/** + * Browser-openable actions for this row (links shown in the web UI). + */ +actions: Array, }; + +export type SupportStatus = "proto" | "alpha" | "beta" | "ga"; + +export type IntegrationCatalogEntryDto = { +/** + * Vertical slug (e.g. "kanban", "model", "git", "session", "editor"). + */ +vertical: string, +/** + * Human label for the vertical (e.g. "Kanban Provider"). + */ +vertical_label: string, +/** + * Stable entry slug within the vertical (e.g. "jira", "anthropic-api"). + */ +slug: string, +/** + * Display label for the entry (e.g. "Jira", "Anthropic"). + */ +label: string, +/** + * Absolute docs URL, or `null` if undocumented. + */ +docs_url: string | null, +/** + * Whether this entry carries a curated README badge. + */ +readme_badge: boolean, +/** + * Official support / maturity status. + */ +status: SupportStatus, }; + +export type KanbanProviderCatalogEntry = { +/** + * Stable lowercase slug ("jira" | "linear" | "github"). + */ +slug: string, +/** + * Human-readable name (e.g. "Jira Cloud", "GitHub Projects"). + */ +display_name: string, +/** + * One-line connect description shown next to the provider. + */ +description: string, +/** + * Credential/token page opened when the user chooses to configure it. + */ +setup_url: string, +/** + * VS Code codicon hint (rendered as `$(icon)` in the picker). + */ +icon: string, +/** + * Whether at least one instance of this provider is already configured. + */ +configured: boolean, }; + +export type WorkflowExportResponse = { +/** + * The ticket the workflow was generated from. + */ +ticket_id: string, +/** + * The issue type key that supplied the step structure. + */ +issuetype_key: string, +/** + * Suggested filename for saving the workflow (`.workflow.js`). + */ +suggested_filename: string, +/** + * The generated `.js` workflow source. + */ +contents: string, }; + +export type WorkflowPreviewResponse = { +/** + * The issue type key the preview was generated from. + */ +issuetype_key: string, +/** + * Suggested filename for saving the preview (`.preview.workflow.js`). + */ +suggested_filename: string, +/** + * The generated `.js` workflow source (placeholder ticket values). + */ +contents: string, }; + +export type WorkflowFormatDto = { +/** + * Stable slug (e.g. "claude", "agnt") — the value the `format` query param takes. + */ +slug: string, +/** + * Display label (e.g. "Claude Workflow"). + */ +label: string, +/** + * File extension of the emitted artifact, no leading dot (e.g. "js", "json"). + */ +extension: string, +/** + * Official support / maturity status (from the catalog). + */ +status: SupportStatus, +/** + * Absolute docs URL, or `null` if undocumented. + */ +docs_url: string | null, }; + +export type CreateTicketRequest = { +/** + * Template type key (feature, fix, spike, investigation, task). + */ +template: string, +/** + * Project the ticket targets (filled into the template's `project` value). + */ +project: string | null, +/** + * One-line summary (filled into the template's `summary` value). + */ +summary: string | null, +/** + * Additional Handlebars values for the template. Explicit `project`/ + * `summary` fields take precedence over the same keys here. + */ +values: { [key in string]: string }, }; + +export type CreateTicketResponse = { +/** + * The created ticket's id (e.g. `FEAT-1234`). + */ +id: string, +/** + * The ticket filename written to the queue. + */ +filename: string, +/** + * The absolute path the ticket was written to. + */ +path: string, }; + +export type CreateAlertRequest = { +/** + * Where the alert came from (e.g. `pagerduty`, `sentry`). + */ +source: string, +/** + * The alert message / summary. + */ +message: string, +/** + * Severity label (e.g. `S1`, `S2`). + */ +severity: string, +/** + * Optional project the investigation targets. + */ +project: string | null, }; + +export type CreateAlertResponse = { +/** + * The created investigation ticket's id. + */ +id: string, +/** + * The investigation ticket filename written to the queue. + */ +filename: string, }; + +export type SkillEntry = { +/** + * Tool this skill belongs to (e.g., "claude", "codex") + */ +tool_name: string, +/** + * Filename of the skill (e.g., "commit.md") + */ +filename: string, +/** + * Full path to the skill file + */ +file_path: string, +/** + * Scope: "global" or "project" + */ +scope: string, }; + +export type SkillsResponse = { +/** + * List of discovered skills + */ +skills: Array, +/** + * Total count + */ +total: number, }; + +export type DelegatorResponse = { +/** + * Unique name + */ +name: string, +/** + * LLM tool name (e.g., "claude") + */ +llm_tool: string, +/** + * Model alias (e.g., "opus") + */ +model: string, +/** + * Optional display name + */ +display_name: string | null, +/** + * Arbitrary model properties + */ +model_properties: { [key in string]: string }, +/** + * Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default. + */ +model_server: string | null, +/** + * Optional launch configuration + */ +launch_config: DelegatorLaunchConfigDto | null, +/** + * Declarative reference to a remote, named agent (AGNT, `OpenAI`, ...). When + * set, the delegator is export-only and cannot be launched locally. + */ +remote_agent: RemoteAgentRef | null, }; + +export type DelegatorsResponse = { +/** + * List of delegators + */ +delegators: Array, +/** + * Total count + */ +total: number, }; + +export type CreateDelegatorRequest = { +/** + * Unique name for the delegator + */ +name: string, +/** + * LLM tool name (must match a detected tool) + */ +llm_tool: string, +/** + * Model alias + */ +model: string, +/** + * Optional display name + */ +display_name: string | null, +/** + * Arbitrary model properties + */ +model_properties: { [key in string]: string }, +/** + * Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default. + */ +model_server: string | null, +/** + * Optional launch configuration + */ +launch_config: DelegatorLaunchConfigDto | null, +/** + * Declarative reference to a remote, named agent (AGNT, `OpenAI`, ...). When + * set, the delegator is export-only and cannot be launched locally. + */ +remote_agent: RemoteAgentRef | null, }; + +export type DelegatorLaunchConfigDto = { +/** + * Run in YOLO mode + */ +yolo: boolean, +/** + * Permission mode override + */ +permission_mode?: string | null, +/** + * Additional CLI flags + */ +flags: Array, +/** + * Override global `git.use_worktrees` (None = use global setting) + */ +use_worktrees?: boolean | null, +/** + * Whether to create a git branch for the ticket (None = default behavior) + */ +create_branch?: boolean | null, +/** + * Run in docker container (None = use global `launch.docker.enabled`) + */ +docker?: boolean | null, +/** + * Prompt text to prepend before the generated step prompt + */ +prompt_prefix?: string | null, +/** + * Prompt text to append after the generated step prompt + */ +prompt_suffix?: string | null, +/** + * Override global relay auto-inject MCP setting per-delegator (None = use global setting) + */ +operator_relay?: boolean | null, +/** + * Name of a declared `RemoteHost` to launch the agent CLI on over SSH (None = local) + */ +host?: string | null, }; + +export type LlmTask = { +/** + * LLM task ID (e.g., Claude delegate mode task UUID) + */ +id?: string | null, +/** + * LLM task status: "open" or "resolved" + */ +status?: string | null, +/** + * List of task IDs that must resolve before this task + */ +blocked_by?: Array, }; + +export type JiraSearchResponse = { +/** + * List of issues matching the JQL query + */ +issues: Array, }; + +export type JiraIssue = { +/** + * Internal Jira issue ID + */ +id: string, +/** + * Issue key (e.g., "PROJ-123") + */ +key: string, +/** + * Issue fields containing summary, status, etc. + */ +fields: JiraIssueFields, }; + +export type JiraIssueFields = { +/** + * Issue summary/title + */ +summary: string, +/** + * Issue description in ADF format + */ +description: JiraDescription | null, +/** + * Issue type (Bug, Story, Task, etc.) + */ +issuetype: JiraIssueTypeRef, +/** + * Current workflow status + */ +status: JiraStatusRef, +/** + * Assigned user (if any) + */ +assignee: JiraUser | null, +/** + * Issue priority (if set) + */ +priority: JiraPriority | null, }; + +export type JiraUser = { +/** + * Atlassian account ID (e.g., "5e3f7acd9876543210abcdef") + */ +accountId: string, +/** + * User's display name + */ +displayName: string, +/** + * User's email address (may be hidden by privacy settings) + */ +emailAddress: string | null, +/** + * Avatar URLs in various sizes + */ +avatarUrls: JiraAvatarUrls | null, }; + +export type JiraAvatarUrls = { +/** + * 48x48 pixel avatar URL + */ +"48x48": string | null, }; + +export type JiraDescription = { +/** + * ADF content nodes - parsed to extract plain text + */ +content: Array | null, }; + +export type JiraIssueTypeRef = { +/** + * Issue type ID (e.g., "10001") + */ +id: string | null, +/** + * Issue type name (e.g., "Bug", "Story", "Task") + */ +name: string, }; + +export type JiraStatusRef = { +/** + * Status name (e.g., "To Do", "In Progress", "Done") + */ +name: string, }; + +export type JiraPriority = { +/** + * Priority name (e.g., "Highest", "High", "Medium", "Low", "Lowest") + */ +name: string, }; + +export type JiraProjectStatus = { +/** + * List of statuses available for this issue type + */ +statuses: Array, }; + +export type JiraStatus = { +/** + * Status name (e.g., "To Do", "In Progress", "Done") + */ +name: string, }; + +export type VsCodeSessionInfo = { +/** + * Wrapper type identifier (always "vscode") + */ +wrapper: string, +/** + * Actual port the webhook server is listening on + */ +port: number, +/** + * Process ID of VS Code + */ +pid: number, +/** + * Extension version + */ +version: string, +/** + * ISO timestamp when server started + */ +startedAt: string, +/** + * Workspace folder path + */ +workspace: string, }; + +export type VsCodeHealthResponse = { +/** + * Health status (always "ok" when healthy) + */ +status: string, +/** + * Extension version + */ +version: string, +/** + * Port the server is listening on + */ +port: number, }; + +export type VsCodeActivityState = "idle" | "running" | "unknown"; + +export type VsCodeTerminalState = { +/** + * Terminal name + */ +name: string, +/** + * Process ID if available + */ +pid?: number, +/** + * Current activity state + */ +activity: VsCodeActivityState, +/** + * Unix timestamp when terminal was created (milliseconds) + */ +createdAt: number, }; + +export type VsCodeTerminalCreateOptions = { +/** + * Terminal name (e.g., "op-FEAT-123") + */ +name: string, +/** + * Working directory for the terminal + */ +workingDir?: string, +/** + * Environment variables to set + */ +env?: { [key in string]: string }, }; + +export type VsCodeSendCommandRequest = { +/** + * Command to execute + */ +command: string, }; + +export type VsCodeSuccessResponse = { +/** + * Whether the operation succeeded + */ +success: boolean, +/** + * Optional terminal name (for create operations) + */ +name?: string, }; + +export type VsCodeExistsResponse = { +/** + * Whether the terminal exists + */ +exists: boolean, }; + +export type VsCodeActivityResponse = { +/** + * Current activity state + */ +activity: VsCodeActivityState, }; + +export type VsCodeListResponse = { +/** + * List of managed terminals + */ +terminals: Array, }; + +export type VsCodeErrorResponse = { +/** + * Error message + */ +error: string, }; + +export type VsCodeTicketStatus = "in-progress" | "queue" | "completed"; + +export type VsCodeTicketInfo = { +/** + * Ticket ID (e.g., "FEAT-123") + */ +id: string, +/** + * Ticket title from markdown heading + */ +title: string, +/** + * Ticket type key (e.g., "FEAT", "FIX", or any custom type) + */ +type: string, +/** + * Current status + */ +status: VsCodeTicketStatus, +/** + * Path to the ticket markdown file + */ +filePath: string, +/** + * Terminal name if in-progress (e.g., "op-FEAT-123") + */ +terminalName?: string, }; + +export type VsCodeModelOption = "sonnet" | "opus" | "haiku"; + +export type VsCodeLaunchOptions = { +/** + * Named delegator to use (takes precedence over model) + */ +delegator: string | null, +/** + * Model to use (sonnet, opus, haiku) — fallback when no delegator + */ +model: VsCodeModelOption, +/** + * YOLO mode - auto-accept all prompts + */ +yoloMode: boolean, +/** + * Resume from existing session (uses `session_id` from ticket) + */ +resumeSession: boolean, }; + +export type VsCodeTicketMetadata = { +/** + * Ticket ID + */ +id: string, +/** + * Current status + */ +status: string, +/** + * Current step name + */ +step: string, +/** + * Priority level + */ +priority: string, +/** + * Project name + */ +project: string, +/** + * Session UUIDs by step name + */ +sessions?: { [key in string]: string }, +/** + * Git worktree path if using per-ticket worktrees + */ +worktreePath?: string, +/** + * Git branch name + */ +branch?: string, }; + diff --git a/vscode-extension/src/config-panel.ts b/vscode-extension/src/config-panel.ts index 86c73295..9cc461be 100644 --- a/vscode-extension/src/config-panel.ts +++ b/vscode-extension/src/config-panel.ts @@ -619,6 +619,7 @@ export const KANBAN_PROVIDERS: Record = { jira: { instanceKeyField: 'domain', defaultInstanceKey: 'your-org.atlassian.net' }, linear: { instanceKeyField: 'team_id', defaultInstanceKey: 'default-team' }, github: { instanceKeyField: 'owner', defaultInstanceKey: 'your-org' }, + openspec: { instanceKeyField: 'instance', defaultInstanceKey: 'my-repo' }, }; /** Slugs of every supported kanban provider, in catalog order. */ diff --git a/vscode-extension/src/schemas/issuetype_schema.json b/vscode-extension/src/schemas/issuetype_schema.json new file mode 100644 index 00000000..4ff65d6f --- /dev/null +++ b/vscode-extension/src/schemas/issuetype_schema.json @@ -0,0 +1,1457 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "TemplateSchema", + "description": "Schema definition for an issuetype template", + "type": "object", + "properties": { + "key": { + "description": "Unique issuetype key (e.g., FEAT, FIX, SPIKE, INV, TASK)", + "type": "string" + }, + "name": { + "description": "Display name of the template type", + "type": "string" + }, + "description": { + "description": "Brief description of when to use this template", + "type": "string" + }, + "mode": { + "description": "Whether this issuetype runs autonomously or requires human pairing", + "$ref": "#/$defs/ExecutionMode" + }, + "glyph": { + "description": "Glyph character displayed in UI for this issuetype", + "type": "string" + }, + "color": { + "description": "Optional color for glyph display in TUI", + "type": [ + "string", + "null" + ], + "default": null + }, + "project_required": { + "description": "Whether a project must be specified for this issuetype", + "type": "boolean", + "default": true + }, + "fields": { + "description": "Field definitions for this template", + "type": "array", + "items": { + "$ref": "#/$defs/FieldSchema" + } + }, + "steps": { + "description": "Lifecycle steps for completing this ticket type", + "type": "array", + "items": { + "$ref": "#/$defs/StepSchema" + } + }, + "prompt": { + "description": "Optional prompt for work launching (interpolated with handlebars)", + "type": [ + "string", + "null" + ], + "default": null + }, + "agent_prompt": { + "description": "Prompt for generating this issue type's operator agent via `claude -p`", + "type": [ + "string", + "null" + ], + "default": null + }, + "agent": { + "description": "Default delegator name for this issuetype (overridden by step.agent)", + "type": [ + "string", + "null" + ], + "default": null + } + }, + "required": [ + "key", + "name", + "description", + "mode", + "glyph", + "fields", + "steps" + ], + "$defs": { + "ExecutionMode": { + "description": "Execution mode for an issuetype", + "oneOf": [ + { + "description": "Runs without human interaction", + "type": "string", + "const": "autonomous" + }, + { + "description": "Requires human pairing/interaction", + "type": "string", + "const": "paired" + } + ] + }, + "FieldSchema": { + "description": "Schema definition for a single field in a template", + "type": "object", + "properties": { + "name": { + "description": "Field identifier (matches handlebar variable name)", + "type": "string" + }, + "description": { + "description": "Help text for the field", + "type": "string" + }, + "type": { + "description": "Type of the field", + "$ref": "#/$defs/FieldType" + }, + "required": { + "description": "Whether this field must be filled", + "type": "boolean", + "default": false + }, + "default": { + "description": "Default value if any", + "type": [ + "string", + "null" + ], + "default": null + }, + "auto": { + "description": "Auto-generation strategy for this field", + "anyOf": [ + { + "$ref": "#/$defs/AutoGenStrategy" + }, + { + "type": "null" + } + ], + "default": null + }, + "options": { + "description": "Options for enum fields", + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "placeholder": { + "description": "Placeholder text shown in template", + "type": [ + "string", + "null" + ], + "default": null + }, + "max_length": { + "description": "Maximum length for string fields", + "type": [ + "integer", + "null" + ], + "format": "uint", + "minimum": 0, + "default": null + }, + "display_order": { + "description": "Display order in form (lower = first)", + "type": [ + "integer", + "null" + ], + "format": "int32", + "default": null + }, + "user_editable": { + "description": "Whether the user can edit this field (false for auto-generated)", + "type": "boolean", + "default": true + } + }, + "required": [ + "name", + "description", + "type" + ] + }, + "FieldType": { + "description": "Types of fields supported in template schemas", + "oneOf": [ + { + "description": "Single-line text input", + "type": "string", + "const": "string" + }, + { + "description": "Selection from predefined options", + "type": "string", + "const": "enum" + }, + { + "description": "True/false checkbox", + "type": "string", + "const": "bool" + }, + { + "description": "Date field (YYYY-MM-DD format)", + "type": "string", + "const": "date" + }, + { + "description": "Multi-line text input", + "type": "string", + "const": "text" + }, + { + "description": "Integer number input", + "type": "string", + "const": "integer" + } + ] + }, + "AutoGenStrategy": { + "description": "Auto-generation strategies for fields", + "oneOf": [ + { + "description": "Generate ID from timestamp (e.g., FEAT-1234)", + "type": "string", + "const": "id" + }, + { + "description": "Generate current date (YYYY-MM-DD)", + "type": "string", + "const": "date" + }, + { + "description": "Generate branch name from type and summary", + "type": "string", + "const": "branch" + }, + { + "description": "Set initial status", + "type": "string", + "const": "status" + } + ] + }, + "StepSchema": { + "description": "Schema definition for a lifecycle step", + "type": "object", + "properties": { + "name": { + "description": "Step identifier (lowercase)", + "type": "string" + }, + "display_name": { + "description": "Human-readable step name", + "type": [ + "string", + "null" + ], + "default": null + }, + "type": { + "description": "Step type discriminator (defaults to \"task\" for backward compatibility)", + "$ref": "#/$defs/StepTypeTag", + "default": "task" + }, + "outputs": { + "description": "Types of outputs this step produces", + "type": "array", + "items": { + "$ref": "#/$defs/StepOutput" + } + }, + "prompt": { + "description": "Initial prompt template for the Claude agent", + "type": "string" + }, + "review_type": { + "description": "Type of review required for this step (none, plan, visual, pr)", + "$ref": "#/$defs/ReviewType", + "default": "none" + }, + "visual_config": { + "description": "Configuration for visual review (required when `review_type` is \"visual\")", + "anyOf": [ + { + "$ref": "#/$defs/VisualReviewConfig" + }, + { + "type": "null" + } + ], + "default": null + }, + "on_reject": { + "description": "What to do if step output is rejected", + "anyOf": [ + { + "$ref": "#/$defs/OnReject" + }, + { + "type": "null" + } + ], + "default": null + }, + "next_step": { + "description": "Name of the next step (None for final step)", + "type": [ + "string", + "null" + ], + "default": null + }, + "allowed_tools": { + "description": "Claude Code tools allowed in this step", + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "agent": { + "description": "Optional agent (delegator) name for this step (overrides ticket's default agent)", + "type": [ + "string", + "null" + ], + "default": null + }, + "permissions": { + "description": "Provider-agnostic permissions for this step", + "anyOf": [ + { + "$ref": "#/$defs/StepPermissions" + }, + { + "type": "null" + } + ], + "default": null + }, + "cli_args": { + "description": "Arbitrary CLI arguments per provider", + "anyOf": [ + { + "$ref": "#/$defs/ProviderCliArgs" + }, + { + "type": "null" + } + ], + "default": null + }, + "permission_mode": { + "description": "Preferred LLM permission mode for this step", + "$ref": "#/$defs/PermissionMode", + "default": "default" + }, + "jsonSchema": { + "description": "Inline JSON schema for structured output (Claude-specific)", + "default": null + }, + "jsonSchemaFile": { + "description": "Path to JSON schema file for structured output (Claude-specific)", + "type": [ + "string", + "null" + ], + "default": null + }, + "artifact_patterns": { + "description": "File glob patterns in the worktree that signal this step is complete", + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "classifier_config": { + "description": "Configuration for classifier steps (required when type=classifier)", + "anyOf": [ + { + "$ref": "#/$defs/ClassifierConfig" + }, + { + "type": "null" + } + ], + "default": null + }, + "rag_config": { + "description": "Configuration for RAG steps (required when type=rag)", + "anyOf": [ + { + "$ref": "#/$defs/RagConfig" + }, + { + "type": "null" + } + ], + "default": null + }, + "delegator_config": { + "description": "Configuration for delegator steps (required when type=delegator)", + "anyOf": [ + { + "$ref": "#/$defs/DelegatorStepConfig" + }, + { + "type": "null" + } + ], + "default": null + }, + "mcp_config": { + "description": "Configuration for MCP steps (required when type=mcp)", + "anyOf": [ + { + "$ref": "#/$defs/McpStepConfig" + }, + { + "type": "null" + } + ], + "default": null + }, + "multi_model_config": { + "description": "Configuration for multi-model steps (required when `type=multi_model`)", + "anyOf": [ + { + "$ref": "#/$defs/MultiModelConfig" + }, + { + "type": "null" + } + ], + "default": null + }, + "multi_prompt_config": { + "description": "Configuration for multi-prompt steps (required when `type=multi_prompt`)", + "anyOf": [ + { + "$ref": "#/$defs/MultiPromptConfig" + }, + { + "type": "null" + } + ], + "default": null + }, + "matrixed_config": { + "description": "Configuration for matrixed steps (required when type=matrixed)", + "anyOf": [ + { + "$ref": "#/$defs/MatrixedConfig" + }, + { + "type": "null" + } + ], + "default": null + }, + "pipeline_config": { + "description": "Configuration for pipeline steps (required when type=pipeline)", + "anyOf": [ + { + "$ref": "#/$defs/PipelineConfig" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "name", + "outputs", + "prompt" + ] + }, + "StepTypeTag": { + "description": "Discriminator tag for step types", + "oneOf": [ + { + "description": "Default pass-through task step", + "type": "string", + "const": "task" + }, + { + "description": "Structured typed output (boolean, number, string, enum)", + "type": "string", + "const": "classifier" + }, + { + "description": "Context-augmented prompting with retrieved sources", + "type": "string", + "const": "rag" + }, + { + "description": "Runs with a specific delegator and prompt flavor", + "type": "string", + "const": "delegator" + }, + { + "description": "Ensures specific MCP tools are available", + "type": "string", + "const": "mcp" + }, + { + "description": "Fan-out to N delegators, then aggregate via voting", + "type": "string", + "const": "multi_model" + }, + { + "description": "N prompt variations with one model, then select best", + "type": "string", + "const": "multi_prompt" + }, + { + "description": "N x M delegators x prompt variations", + "type": "string", + "const": "matrixed" + }, + { + "description": "Iterate a list of items through ordered stages with no barrier", + "type": "string", + "const": "pipeline" + } + ] + }, + "StepOutput": { + "description": "Types of outputs a step can produce", + "oneOf": [ + { + "description": "Implementation plan", + "type": "string", + "const": "plan" + }, + { + "description": "Source code changes", + "type": "string", + "const": "code" + }, + { + "description": "Test code/results", + "type": "string", + "const": "test" + }, + { + "description": "Pull request", + "type": "string", + "const": "pr" + }, + { + "description": "New ticket(s)", + "type": "string", + "const": "ticket" + }, + { + "description": "Review output", + "type": "string", + "const": "review" + }, + { + "description": "Investigation/research report", + "type": "string", + "const": "report" + }, + { + "description": "Documentation", + "type": "string", + "const": "documentation" + } + ] + }, + "ReviewType": { + "description": "Type of review required for a step", + "oneOf": [ + { + "description": "No review required - proceed automatically", + "type": "string", + "const": "none" + }, + { + "description": "Review the plan/output before proceeding", + "type": "string", + "const": "plan" + }, + { + "description": "Visual confirmation via browser", + "type": "string", + "const": "visual" + }, + { + "description": "Git interface PR review workflow", + "type": "string", + "const": "pr" + } + ] + }, + "VisualReviewConfig": { + "description": "Configuration for visual review steps", + "type": "object", + "properties": { + "url": { + "description": "URL to open for visual check (supports handlebars templates)", + "type": "string" + }, + "startup_command": { + "description": "Optional startup command (e.g., dev server) to run before opening browser", + "type": [ + "string", + "null" + ], + "default": null + }, + "startup_timeout_secs": { + "description": "Timeout in seconds for server startup (default: 30)", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0, + "default": null + } + }, + "required": [ + "url" + ] + }, + "OnReject": { + "description": "Action to take when a step is rejected", + "type": "object", + "properties": { + "goto_step": { + "description": "Step name to return to on rejection", + "type": "string" + }, + "prompt": { + "description": "Prompt to use when restarting after rejection", + "type": "string" + } + }, + "required": [ + "goto_step", + "prompt" + ] + }, + "StepPermissions": { + "description": "Complete permission set for a step (as defined in issuetype schema)", + "type": "object", + "properties": { + "tools": { + "description": "Tool-level allow/deny lists", + "$ref": "#/$defs/ToolPermissions" + }, + "directories": { + "description": "Directory-level allow/deny lists", + "$ref": "#/$defs/DirectoryPermissions" + }, + "mcp_servers": { + "description": "MCP server enable/disable configuration", + "$ref": "#/$defs/McpServerPermissions" + }, + "custom_flags": { + "description": "Per-provider custom configuration flags", + "$ref": "#/$defs/CustomFlags" + } + } + }, + "ToolPermissions": { + "description": "Tool-level permissions (allow/deny lists)", + "type": "object", + "properties": { + "allow": { + "description": "Tools/patterns to allow", + "type": "array", + "items": { + "$ref": "#/$defs/ToolPattern" + } + }, + "deny": { + "description": "Tools/patterns to deny", + "type": "array", + "items": { + "$ref": "#/$defs/ToolPattern" + } + } + } + }, + "ToolPattern": { + "description": "Provider-agnostic tool pattern", + "type": "object", + "properties": { + "tool": { + "description": "Tool name: Read, Write, Edit, Bash, Glob, Grep, `WebFetch`, etc.", + "type": "string" + }, + "pattern": { + "description": "Optional pattern for tool arguments (e.g., \"cargo test:*\" for Bash)", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "tool" + ] + }, + "DirectoryPermissions": { + "description": "Directory-level permissions", + "type": "object", + "properties": { + "allow": { + "description": "Additional directories to allow access to (glob patterns)", + "type": "array", + "items": { + "type": "string" + } + }, + "deny": { + "description": "Directories to deny access to (glob patterns)", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "McpServerPermissions": { + "description": "MCP server permissions (server-level enable/disable only)", + "type": "object", + "properties": { + "enable": { + "description": "MCP servers to enable for this step", + "type": "array", + "items": { + "type": "string" + } + }, + "disable": { + "description": "MCP servers to disable for this step", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "CustomFlags": { + "description": "Per-provider custom configuration flags", + "type": "object", + "properties": { + "claude": { + "description": "Claude-specific configuration flags", + "type": "object", + "additionalProperties": true + }, + "gemini": { + "description": "Gemini-specific configuration flags", + "type": "object", + "additionalProperties": true + }, + "codex": { + "description": "Codex-specific configuration flags", + "type": "object", + "additionalProperties": true + } + } + }, + "ProviderCliArgs": { + "description": "Arbitrary CLI arguments per provider", + "type": "object", + "properties": { + "claude": { + "description": "CLI arguments for Claude", + "type": "array", + "items": { + "type": "string" + } + }, + "gemini": { + "description": "CLI arguments for Gemini", + "type": "array", + "items": { + "type": "string" + } + }, + "codex": { + "description": "CLI arguments for Codex", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionMode": { + "description": "Permission mode for LLM interaction", + "oneOf": [ + { + "description": "Default permission mode - standard interactive behavior", + "type": "string", + "const": "default" + }, + { + "description": "Plan mode - read-only exploration before implementation", + "type": "string", + "const": "plan" + }, + { + "description": "Accept edits mode - auto-approve file edits", + "type": "string", + "const": "acceptEdits" + }, + { + "description": "Delegate mode - task delegation with DAG management", + "type": "string", + "const": "delegate" + } + ] + }, + "ClassifierConfig": { + "description": "Configuration for classifier steps that return structured typed output", + "type": "object", + "properties": { + "output_type": { + "description": "What type of answer the classifier returns", + "$ref": "#/$defs/ClassifierOutputType" + }, + "options": { + "description": "For enum type: the allowed options", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "default": null + }, + "max_length": { + "description": "For `short_string`: max character length (default 255)", + "type": [ + "integer", + "null" + ], + "format": "uint", + "minimum": 0, + "default": null + }, + "agent": { + "description": "Agent/delegator to use (overrides issuetype default)", + "type": [ + "string", + "null" + ], + "default": null + } + }, + "required": [ + "output_type" + ] + }, + "ClassifierOutputType": { + "description": "Output types for classifier steps", + "oneOf": [ + { + "description": "true/false answer", + "type": "string", + "const": "boolean" + }, + { + "description": "Numeric answer (integer or float)", + "type": "string", + "const": "number" + }, + { + "description": "Short string < 255 chars", + "type": "string", + "const": "short_string" + }, + { + "description": "Longer arbitrary-length text", + "type": "string", + "const": "big_text" + }, + { + "description": "One of a fixed set of options", + "type": "string", + "const": "enum" + } + ] + }, + "RagConfig": { + "description": "Configuration for RAG (retrieval-augmented generation) steps", + "type": "object", + "properties": { + "sources": { + "description": "Context sources to retrieve before running the prompt", + "type": "array", + "items": { + "$ref": "#/$defs/RagSource" + } + }, + "max_context_tokens": { + "description": "Maximum tokens of context to inject (default: 50000)", + "type": [ + "integer", + "null" + ], + "format": "uint", + "minimum": 0, + "default": null + }, + "agent": { + "description": "Agent/delegator to use", + "type": [ + "string", + "null" + ], + "default": null + }, + "allowed_tools": { + "description": "Tools allowed for the agent", + "type": "array", + "items": { + "type": "string" + }, + "default": [] + } + }, + "required": [ + "sources" + ] + }, + "RagSource": { + "description": "A source of context for RAG steps", + "oneOf": [ + { + "description": "Match files by glob pattern", + "type": "object", + "properties": { + "pattern": { + "description": "Glob pattern relative to project root", + "type": "string" + }, + "type": { + "type": "string", + "const": "glob" + } + }, + "required": [ + "type", + "pattern" + ] + }, + { + "description": "Single file path", + "type": "object", + "properties": { + "path": { + "description": "File path relative to project root", + "type": "string" + }, + "type": { + "type": "string", + "const": "file" + } + }, + "required": [ + "type", + "path" + ] + }, + { + "description": "Retrieve via MCP server tool", + "type": "object", + "properties": { + "server": { + "description": "MCP server name", + "type": "string" + }, + "tool": { + "description": "Tool name on the MCP server", + "type": "string" + }, + "query": { + "description": "Optional query template (Handlebars)", + "type": [ + "string", + "null" + ], + "default": null + }, + "type": { + "type": "string", + "const": "mcp" + } + }, + "required": [ + "type", + "server", + "tool" + ] + } + ] + }, + "DelegatorStepConfig": { + "description": "Configuration for delegator steps that run with a specific model+flavor", + "type": "object", + "properties": { + "delegator": { + "description": "Named delegator reference (from config.delegators)", + "type": "string" + }, + "prompt_flavor": { + "description": "Additional prompt flavor text prepended to the step prompt", + "type": [ + "string", + "null" + ], + "default": null + }, + "allowed_tools": { + "description": "Tools allowed", + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "permissions": { + "description": "Permissions", + "anyOf": [ + { + "$ref": "#/$defs/StepPermissions" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "delegator" + ] + }, + "McpStepConfig": { + "description": "Configuration for MCP steps that require specific MCP tools", + "type": "object", + "properties": { + "required_tools": { + "description": "MCP tools that MUST be available (step fails if missing)", + "type": "array", + "items": { + "$ref": "#/$defs/McpToolRef" + } + }, + "optional_tools": { + "description": "MCP tools that SHOULD be available (warning if missing)", + "type": "array", + "items": { + "$ref": "#/$defs/McpToolRef" + }, + "default": [] + }, + "agent": { + "description": "Agent/delegator to use", + "type": [ + "string", + "null" + ], + "default": null + }, + "allowed_tools": { + "description": "Tools allowed (in addition to MCP tools)", + "type": "array", + "items": { + "type": "string" + }, + "default": [] + } + }, + "required": [ + "required_tools" + ] + }, + "McpToolRef": { + "description": "Reference to a specific MCP server tool", + "type": "object", + "properties": { + "server": { + "description": "MCP server name", + "type": "string" + }, + "tool": { + "description": "Specific tool name (None = all tools from this server)", + "type": [ + "string", + "null" + ], + "default": null + } + }, + "required": [ + "server" + ] + }, + "MultiModelConfig": { + "description": "Configuration for multi-model delegation steps (fan-out + vote)", + "type": "object", + "properties": { + "delegators": { + "description": "Named delegator references (from config.delegators), minimum 2", + "type": "array", + "items": { + "type": "string" + } + }, + "voting_strategy": { + "description": "How to aggregate/select the final answer", + "$ref": "#/$defs/VotingStrategy" + }, + "share_answers": { + "description": "Whether to share all answers with all models in the voting round", + "type": "boolean", + "default": true + }, + "voting_prompt": { + "description": "Prompt for the voting round (Handlebars, receives {{ answers }} array)", + "type": [ + "string", + "null" + ], + "default": null + }, + "voting_mode": { + "description": "How the voting round executes", + "$ref": "#/$defs/VotingMode", + "default": "single_judge" + } + }, + "required": [ + "delegators", + "voting_strategy" + ] + }, + "VotingStrategy": { + "description": "Voting strategy for multi-model steps", + "oneOf": [ + { + "description": "Simple majority vote", + "type": "string", + "const": "majority" + }, + { + "description": "Ranked choice voting", + "type": "string", + "const": "ranked" + }, + { + "description": "Unanimous required (falls back to longest answer if no consensus)", + "type": "string", + "const": "unanimous" + } + ] + }, + "VotingMode": { + "description": "How the voting round is executed in multi-model steps", + "oneOf": [ + { + "description": "One agent reviews all answers and picks winner (uses 1 slot)", + "type": "string", + "const": "single_judge" + }, + { + "description": "All original delegators re-run with shared answers, each votes (uses N slots)", + "type": "string", + "const": "multi_voter" + } + ] + }, + "MultiPromptConfig": { + "description": "Configuration for multi-prompt interrogation steps (N variations, select best)", + "type": "object", + "properties": { + "prompt_variations": { + "description": "Prompt variations (Handlebars templates), minimum 2", + "type": "array", + "items": { + "type": "string" + } + }, + "selection_strategy": { + "description": "How to select the best result", + "$ref": "#/$defs/SelectionStrategy" + }, + "agent": { + "description": "Agent/delegator to use for all variations", + "type": [ + "string", + "null" + ], + "default": null + }, + "selection_prompt": { + "description": "Prompt for the selection/review round", + "type": [ + "string", + "null" + ], + "default": null + } + }, + "required": [ + "prompt_variations", + "selection_strategy" + ] + }, + "SelectionStrategy": { + "description": "Selection strategy for multi-prompt steps", + "oneOf": [ + { + "description": "Model reviews all outputs and picks the best", + "type": "string", + "const": "model_choice" + }, + { + "description": "Model scores each and highest wins", + "type": "string", + "const": "scored" + } + ] + }, + "MatrixedConfig": { + "description": "Configuration for matrixed work output steps (N x M delegators x prompts)", + "type": "object", + "properties": { + "delegators": { + "description": "Named delegator references (N), minimum 2", + "type": "array", + "items": { + "type": "string" + } + }, + "prompt_variations": { + "description": "Prompt variations (M) — Handlebars templates, minimum 2", + "type": "array", + "items": { + "type": "string" + } + }, + "output_format": { + "description": "How to organize/present the N x M output", + "$ref": "#/$defs/MatrixedOutputFormat" + }, + "aggregation_prompt": { + "description": "Optional aggregation prompt (receives the full matrix of results)", + "type": [ + "string", + "null" + ], + "default": null + } + }, + "required": [ + "delegators", + "prompt_variations", + "output_format" + ] + }, + "MatrixedOutputFormat": { + "description": "Output format for matrixed steps", + "oneOf": [ + { + "description": "Each cell's output in `temp_dir/{delegator}/{prompt_index}/`", + "type": "string", + "const": "directory" + }, + { + "description": "Structured N x M JSON matrix in step output artifact", + "type": "string", + "const": "structured" + } + ] + }, + "PipelineConfig": { + "description": "Configuration for pipeline steps: iterate a list of items through ordered\nstages with no barrier (each item flows through all stages independently).\n\nThe step graph stays linear — a pipeline step still has exactly one\n`next_step`. The fan-out (N items x M stages) lives entirely inside this one\nstep; iteration is an intra-step concern, never a step-to-step edge.", + "type": "object", + "properties": { + "item_source": { + "description": "Where the iterated items come from.", + "$ref": "#/$defs/ItemSource" + }, + "stages": { + "description": "Ordered mini-steps each item flows through. Must be non-empty.", + "type": "array", + "items": { + "$ref": "#/$defs/PipelineStage" + } + } + }, + "required": [ + "item_source", + "stages" + ] + }, + "ItemSource": { + "description": "Where a pipeline's iterated items come from. The variant determines *when*\nthe list resolves: export-time (a literal array → static fan-out width in\nthe compiled graph) vs runtime (an identifier → symbolic width).", + "oneOf": [ + { + "description": "The configured/relevant projects (`config.discover_projects()`),\nresolved to a literal array at export time. The \"plan work across many\nprojects\" mechanism.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "projects" + } + }, + "required": [ + "type" + ] + }, + { + "description": "An array produced by a prior step. Emits that step's result identifier\n(`r_`) — a runtime value, so the graph width is symbolic.", + "type": "object", + "properties": { + "step": { + "description": "Name of the prior step whose (array) output is iterated.", + "type": "string" + }, + "type": { + "type": "string", + "const": "from_step" + } + }, + "required": [ + "type", + "step" + ] + }, + { + "description": "A glob pattern, expanded to a literal array at export time against the\nproject root (`projects_path()/`).", + "type": "object", + "properties": { + "pattern": { + "description": "Glob pattern, relative to the project root.", + "type": "string" + }, + "type": { + "type": "string", + "const": "glob" + } + }, + "required": [ + "type", + "pattern" + ] + }, + { + "description": "A literal, author-provided list, emitted verbatim as a literal array.", + "type": "object", + "properties": { + "items": { + "description": "The items to iterate.", + "type": "array", + "items": { + "type": "string" + } + }, + "type": { + "type": "string", + "const": "static" + } + }, + "required": [ + "type", + "items" + ] + }, + { + "description": "A ticket field value split into a list. Resolution is deferred — there\nis no list `FieldType` and ticket field values are not captured at\nexport time yet — so this currently emits a symbolic placeholder.", + "type": "object", + "properties": { + "name": { + "description": "Name of the ticket field to read.", + "type": "string" + }, + "type": { + "type": "string", + "const": "field" + } + }, + "required": [ + "type", + "name" + ] + } + ] + }, + "PipelineStage": { + "description": "A single stage in a pipeline — deliberately flat (not a recursive\n`StepSchema`): \"prompt + optional agent/model/schema\" only. It has no\n`next_step`/`review_type`/`on_reject`, so a stage cannot reopen the\nstep-graph linearity question.", + "type": "object", + "properties": { + "prompt": { + "description": "Handlebars prompt. The per-item value is appended as a JS binding at\nexport time (see `workflow_gen::export`), not via a Handlebars variable.", + "type": "string" + }, + "agent": { + "description": "Optional agent/delegator name (falls back to the step/issuetype agent).", + "type": [ + "string", + "null" + ], + "default": null + }, + "model": { + "description": "Optional model pin (emitted as `{ model: … }`).", + "type": [ + "string", + "null" + ], + "default": null + }, + "jsonSchema": { + "description": "Optional structured-output JSON schema (emitted as `{ schema: … }`).", + "default": null + }, + "label": { + "description": "Optional display label override (defaults to `:`).", + "type": [ + "string", + "null" + ], + "default": null + } + }, + "required": [ + "prompt" + ] + } + }, + "$id": "https://operator.untra.io/schemas/issuetype_schema.json", + "$comment": "AUTO-GENERATED FROM src/templates/schema.rs - DO NOT EDIT. Regenerate with: cargo run -- docs --only issuetype-json-schema" +} \ No newline at end of file diff --git a/vscode-extension/src/sections/kanban-section.ts b/vscode-extension/src/sections/kanban-section.ts index e76af725..93b78088 100644 --- a/vscode-extension/src/sections/kanban-section.ts +++ b/vscode-extension/src/sections/kanban-section.ts @@ -113,6 +113,24 @@ export class KanbanSection implements StatusSection { }); } } + + // Parse OpenSpec roots from config.toml (experimental, pull-only; + // no per-project sub-tables — the instance itself is the source) + const openspecSection = kanbanSection.openspec as Record | undefined; + if (openspecSection) { + for (const [instance, wsConfig] of Object.entries(openspecSection)) { + const ws = wsConfig as Record; + if (ws.enabled === false) { continue; } + providers.push({ + provider: 'openspec', + key: instance, + enabled: ws.enabled !== false, + displayName: (ws.root_path as string) || instance, + url: 'https://operator.untra.io/getting-started/kanban/openspec/', + projects: [], + }); + } + } } // Fall back to env-var-based detection if config.toml has no kanban section @@ -169,11 +187,13 @@ export class KanbanSection implements StatusSection { const providerLabel = prov.provider === 'jira' ? 'Jira' : prov.provider === 'linear' ? 'Linear' - : 'GitHub Projects'; + : prov.provider === 'openspec' ? 'OpenSpec' + : 'GitHub Projects'; const providerIcon = prov.provider === 'jira' ? 'operator-atlassian' : prov.provider === 'linear' ? 'operator-linear' - : 'github'; + : prov.provider === 'openspec' ? 'checklist' + : 'github'; items.push(new StatusItem({ label: providerLabel, description: prov.displayName, diff --git a/vscode-extension/src/sections/types.ts b/vscode-extension/src/sections/types.ts index a81fdfad..b7f9ee21 100644 --- a/vscode-extension/src/sections/types.ts +++ b/vscode-extension/src/sections/types.ts @@ -77,7 +77,7 @@ export interface ConfigState { /** Config-driven state for a single kanban provider */ export interface KanbanProviderState { - provider: 'jira' | 'linear' | 'github'; + provider: 'jira' | 'linear' | 'github' | 'openspec'; key: string; enabled: boolean; displayName: string; diff --git a/vscode-extension/src/walkthrough.ts b/vscode-extension/src/walkthrough.ts index 4fc6eb1a..898b65f0 100644 --- a/vscode-extension/src/walkthrough.ts +++ b/vscode-extension/src/walkthrough.ts @@ -16,7 +16,7 @@ import { promisify } from 'util'; const execAsync = promisify(exec); /** Kanban provider types */ -export type KanbanProviderType = 'jira' | 'linear' | 'github'; +export type KanbanProviderType = 'jira' | 'linear' | 'github' | 'openspec'; /** Detected kanban workspace with connection details */ export interface KanbanWorkspace { diff --git a/vscode-extension/webview-ui/types/defaults.ts b/vscode-extension/webview-ui/types/defaults.ts index 01933596..8ecd4032 100644 --- a/vscode-extension/webview-ui/types/defaults.ts +++ b/vscode-extension/webview-ui/types/defaults.ts @@ -119,6 +119,7 @@ const DEFAULT_CONFIG: Config = { jira: {}, linear: {}, github: {}, + openspec: {}, }, version_check: { enabled: true, @@ -127,6 +128,7 @@ const DEFAULT_CONFIG: Config = { }, delegators: [], model_servers: [], + hosts: [], relay: { auto_inject_mcp: false }, mcp: { http_enabled: true,