From e712d11d8ee012ce7f97024f965fbbd8f9b01900 Mon Sep 17 00:00:00 2001 From: untra Date: Tue, 16 Jun 2026 10:38:49 -0600 Subject: [PATCH 01/11] zed, agnt versions must rev --- .githooks/pre-push | 19 +++ .github/workflows/build.yaml | 28 +++- .github/workflows/docs.yml | 2 + CLAUDE.md | 32 +++- Makefile | 33 +++++ README.md | 64 +------- agnt-plugin/manifest.json | 2 +- agnt-plugin/package.json | 2 +- backstage-server/package.json | 2 +- bindings/CollectionResponse.ts | 15 +- bindings/TemplatesConfig.ts | 16 +- bindings/WorkflowHintsDto.ts | 6 + bump-version.sh | 4 + codecov.yml | 12 +- docs/schemas/openapi.json | 2 +- src/main.rs | 2 +- src/mcp/descriptor.rs | 2 +- src/mcp/mod.rs | 27 ++++ src/mcp/transport.rs | 2 +- src/rest/dto/agents.rs | 220 ++++++++++++++++++++++++++++ src/rest/dto/configuration.rs | 183 +++++++++++++++++++++++ src/rest/dto/issue_types.rs | 161 ++++++++++++++++++++ src/rest/dto/kanban.rs | 177 ++++++++++++++++++++++ src/ui/setup/steps/hosted.rs | 147 +++++++++++++++++++ src/ui/setup/steps/mod.rs | 2 + src/workflow_gen/export.rs | 258 +++++++++++++++++++++++++++++++++ tests/version_parity.rs | 108 ++++++++++++++ zed-extension/Cargo.lock | 2 +- zed-extension/Cargo.toml | 2 +- zed-extension/extension.toml | 2 +- 30 files changed, 1440 insertions(+), 94 deletions(-) create mode 100755 .githooks/pre-push create mode 100644 Makefile create mode 100644 bindings/WorkflowHintsDto.ts create mode 100644 src/ui/setup/steps/hosted.rs create mode 100644 tests/version_parity.rs diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 00000000..7ab5df45 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Committed pre-push hook. Runs the CI-parity gate (`make check`) before any +# push so clippy/fmt/test failures are caught locally instead of on CI. +# +# Enable once per clone: make install-hooks (sets core.hooksPath=.githooks) +# Bypass in an emergency: git push --no-verify +set -euo pipefail + +# Resolve the repo root so the hook works regardless of the cwd at push time. +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" + +echo "pre-push: running 'make check' (fmt + clippy + test)…" +if ! make check; then + echo + echo "pre-push: 'make check' failed — push aborted." >&2 + echo "Fix the issues above, or bypass with 'git push --no-verify' (not recommended)." >&2 + exit 1 +fi diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 09864d7a..1a8c212c 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -466,17 +466,37 @@ jobs: - name: Update package.json versions run: | - jq --arg v "${{ needs.version.outputs.version }}" '.version = $v' vscode-extension/package.json > tmp.json && mv tmp.json vscode-extension/package.json + for f in vscode-extension/package.json \ + backstage-server/package.json \ + agnt-plugin/package.json \ + agnt-plugin/manifest.json; do + jq --arg v "${{ needs.version.outputs.version }}" '.version = $v' "$f" > tmp.json && mv tmp.json "$f" + done - name: Update opr8r Cargo.toml version run: | sed -i 's/^version = ".*"/version = "${{ needs.version.outputs.version }}"/' opr8r/Cargo.toml cd opr8r && cargo update --workspace + - name: Update zed-extension versions + run: | + sed -i 's/^version = ".*"/version = "${{ needs.version.outputs.version }}"/' zed-extension/Cargo.toml + sed -i 's/^version = ".*"/version = "${{ needs.version.outputs.version }}"/' zed-extension/extension.toml + cd zed-extension && cargo update -p operator-zed + - name: Update TypeScript VERSION constant run: | sed -i "s/const VERSION = '[^']*'/const VERSION = '${{ needs.version.outputs.version }}'/" vscode-extension/src/webhook-server.ts + # openapi.json's version is code-derived (env!("CARGO_PKG_VERSION")); the + # already-built linux binary embeds the new version, so regenerate the + # committed spec from it instead of recompiling. + - name: Regenerate OpenAPI spec + run: | + BIN=$(find artifacts -type f -name 'operator-linux-x86_64' | head -1) + chmod +x "$BIN" + "$BIN" docs --only openapi + - name: Commit version bump run: | git config user.name "github-actions[bot]" @@ -484,7 +504,11 @@ jobs: git add VERSION Cargo.toml Cargo.lock docs/_config.yml \ vscode-extension/package.json \ vscode-extension/src/webhook-server.ts \ - opr8r/Cargo.toml opr8r/Cargo.lock + opr8r/Cargo.toml opr8r/Cargo.lock \ + zed-extension/Cargo.toml zed-extension/extension.toml zed-extension/Cargo.lock \ + backstage-server/package.json \ + agnt-plugin/package.json agnt-plugin/manifest.json \ + docs/schemas/openapi.json git commit -m "chore: bump version to v${{ needs.version.outputs.version }} [skip ci]" git push diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 8019a072..3d5ed750 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -9,6 +9,8 @@ on: - 'src/docs_gen/**' - 'src/taxonomy/taxonomy.toml' - 'src/templates/*.json' + - 'src/collections/**' + - 'src/schemas/**' - '.github/workflows/docs.yml' workflow_dispatch: diff --git a/CLAUDE.md b/CLAUDE.md index 9b59b8eb..04f85a66 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,12 +20,28 @@ Aim for functional software development with a focus on stateless, single respon ### Mandatory Before Committing -All changes MUST pass these checks before committing: +All changes MUST pass these checks before committing. Run them with `make check`, +which mirrors the CI `lint-test` job exactly (so a clean local run means a clean +CI run): ```bash -cargo fmt # Format code -cargo clippy -- -D warnings # Lint (warnings are errors) -cargo test # Run all tests +make check +# equivalent to the exact CI commands: +cargo fmt --all -- --check # Format check +cargo clippy --locked --all-targets --all-features -- -D warnings # Lint (warnings are errors) +cargo test --locked # Run all tests +``` + +> The `--locked --all-targets --all-features` flags matter: plain +> `cargo clippy` misses test-target and feature-gated lints (e.g. a dependency +> deprecation that only surfaces under `--all-targets`), which is how a clippy +> failure can pass locally yet break CI. Always use the full command above. + +Install the pre-push hook once per clone so this gate runs automatically before +every push: + +```bash +make install-hooks # sets core.hooksPath=.githooks ``` If any of these fail, fix the issues before proceeding. Do NOT use `#[allow(...)]` attributes to silence warnings unless there's a documented reason (e.g., code used only in tests). @@ -65,7 +81,7 @@ cargo test test_new_feature -- --nocapture cargo test # 5. Run full validation before committing -cargo fmt && cargo clippy -- -D warnings && cargo test +make check ``` ### Test Organization @@ -77,8 +93,10 @@ cargo fmt && cargo clippy -- -D warnings && cargo test ## Quick Reference ```bash +make check # Full CI-parity gate (fmt + clippy + test) +make install-hooks # Install the pre-push hook (once per clone) cargo fmt # Format code -cargo clippy -- -D warnings # Lint (warnings as errors) +cargo clippy --locked --all-targets --all-features -- -D warnings # Lint (CI parity) cargo test # Run all tests cargo test # Run specific test cargo run # Run TUI @@ -192,7 +210,7 @@ On startup, operator scans the configured projects directory for subdirectories ### Completing Work -1. Run full validation: `cargo fmt && cargo clippy -- -D warnings && cargo test` +1. Run full validation: `make check` (CI-parity fmt + clippy + test) 2. Ensure all tests pass and no clippy warnings 3. Commit with message: `{type}({project}): {summary}\n\nTicket: {ID}\n` diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..5437bd9f --- /dev/null +++ b/Makefile @@ -0,0 +1,33 @@ +# Operator developer tasks. +# +# `make check` mirrors the CI `lint-test` job exactly so a clean local run means +# a clean CI run. `make install-hooks` wires the committed pre-push hook so the +# same gate runs automatically before every push. + +.PHONY: check fmt clippy test build run install-hooks + +# Full CI-parity gate. Keep these commands byte-identical to +# .github/workflows/build.yaml so local and CI never disagree. +check: fmt clippy test + +fmt: + cargo fmt --all -- --check + +clippy: + cargo clippy --locked --all-targets --all-features -- -D warnings + +test: + cargo test --locked + +# Optimized release binary at target/release/operator. +build: + cargo build --release + +# Run the TUI from source (development). +run: + cargo run + +# One-time per clone: route git hooks at the committed .githooks/ directory. +install-hooks: + git config core.hooksPath .githooks + @echo "pre-push hook installed (runs 'make check')" diff --git a/README.md b/README.md index 9d7c92b8..ec537c3f 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ * **LLM Tool** [![Claude](https://img.shields.io/badge/Claude-D97757?logo=claude&logoColor=white)](https://operator.untra.io/getting-started/agents/claude/) [![Codex](https://img.shields.io/badge/Codex-000000?logo=openai&logoColor=white)](https://operator.untra.io/getting-started/agents/codex/) [![Gemini CLI](https://img.shields.io/badge/Gemini_CLI-8E75B2?logo=googlegemini&logoColor=white)](https://operator.untra.io/getting-started/agents/gemini-cli/) -* **Model Provider** [![Anthropic](https://img.shields.io/badge/Anthropic-D97757?logo=anthropic&logoColor=white)](https://operator.untra.io/getting-started/model-servers/anthropic/) [![OpenAI](https://img.shields.io/badge/OpenAI-000000?logo=openai&logoColor=white)](https://operator.untra.io/getting-started/model-servers/openai/) [![Google](https://img.shields.io/badge/Google-4285F4?logo=google&logoColor=white)](https://operator.untra.io/getting-started/model-servers/google/) [![OpenRouter](https://img.shields.io/badge/OpenRouter-6566F1?logo=openrouter&logoColor=white)](https://operator.untra.io/getting-started/model-servers/openrouter/) [![Ollama](https://img.shields.io/badge/Ollama-000000?logo=ollama&logoColor=white)](https://operator.untra.io/getting-started/model-servers/ollama/) +* **Model Provider** [![Anthropic](https://img.shields.io/badge/Anthropic-D97757?logo=anthropic&logoColor=white)](https://operator.untra.io/getting-started/model-servers/anthropic/) [![OpenAI](https://img.shields.io/badge/OpenAI-000000?logo=openai&logoColor=white)](https://operator.untra.io/getting-started/model-servers/openai/) [![Google](https://img.shields.io/badge/Google-4285F4?logo=google&logoColor=white)](https://operator.untra.io/getting-started/model-servers/google/) [![OpenRouter](https://img.shields.io/badge/OpenRouter-94A3B8?logo=openrouter&logoColor=white)](https://operator.untra.io/getting-started/model-servers/openrouter/) [![Ollama](https://img.shields.io/badge/Ollama-000000?logo=ollama&logoColor=white)](https://operator.untra.io/getting-started/model-servers/ollama/) * **Git Version Control** [![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://operator.untra.io/getting-started/git/github/) [![GitLab](https://img.shields.io/badge/GitLab-FC6D26?logo=gitlab&logoColor=white)](https://operator.untra.io/getting-started/git/gitlab/) @@ -98,7 +98,7 @@ Or build from source: ```bash git clone https://github.com/untra/operator.git cd operator -cargo build --release +make build # cargo build --release sudo cp target/release/operator /usr/local/bin/ ``` @@ -296,63 +296,3 @@ operator launch --llm-tool codex --model qwen2.5-coder --model-server ollama-loc Current release ships the infrastructure — ollama detection and automatic env-var injection on spawn land in the next release. See `docs/getting-started/model-servers/` for the full walkthrough. -## Development - -```bash -# Run in development -cargo run - -# Run tests -cargo test - -# Build release -cargo build --release -``` - -## Documentation - -Reference documentation is auto-generated from source-of-truth files to minimize maintenance. - -### Available References - -| Generator | Source | Output | -|-----------|--------|--------| -| taxonomy | `src/taxonomy/taxonomy.toml` | `docs/taxonomy/index.md` | -| issuetype-schema | `src/schemas/issuetype_schema.json` | `docs/schemas/issuetype.md` | -| metadata-schema | `src/schemas/ticket_metadata.schema.json` | `docs/schemas/metadata.md` | -| shortcuts | `src/ui/keybindings.rs` | `docs/shortcuts/index.md` | -| cli | `src/main.rs`, `src/env_vars.rs` | `docs/cli/index.md` | -| config | `src/config.rs` | `docs/configuration/index.md` | -| OpenAPI | `src/rest/` (utoipa annotations) | `docs/schemas/openapi.json` | -| llm-tools | `src/llm/tools/tool_config.schema.json` | `docs/llm-tools/index.md` | -| startup | `src/startup/mod.rs` | `docs/startup/index.md` | -| config-schema | `docs/schemas/config.json` | `docs/schemas/config.md` | -| state-schema | `docs/schemas/state.json` | `docs/schemas/state.md` | -| schema-index | `docs/schemas/` | `docs/schemas/index.md` | -| jira-api | `docs/schemas/jira-api.json` | `docs/getting-started/kanban/jira-api.md` | - -### Viewing Documentation - -```bash -# Serve docs locally with Jekyll -cd docs && bundle install && bundle exec jekyll serve -# Visit http://localhost:4000 - -# View OpenAPI spec with Swagger UI -# After starting Jekyll, visit http://localhost:4000/schemas/api/ -``` - -### Regenerating Documentation - -```bash -# Regenerate all auto-generated docs -cargo run -- docs - -# Regenerate specific docs -cargo run -- docs --only openapi -cargo run -- docs --only config - -# Available generators: taxonomy, issuetype-schema, metadata-schema, shortcuts, -# cli, config, OpenAPI, llm-tools, startup, config-schema, state-schema, -# schema-index, jira-api -``` diff --git a/agnt-plugin/manifest.json b/agnt-plugin/manifest.json index e9160815..725f0f1b 100644 --- a/agnt-plugin/manifest.json +++ b/agnt-plugin/manifest.json @@ -1,6 +1,6 @@ { "name": "operator-plugin", - "version": "0.2.1", + "version": "0.2.2", "description": "Orchestrate Operator! ticket-driven coding agents from AGNT workflows", "author": "untra", "displayName": "Operator!", diff --git a/agnt-plugin/package.json b/agnt-plugin/package.json index a3c5822e..be64ca2d 100644 --- a/agnt-plugin/package.json +++ b/agnt-plugin/package.json @@ -1,6 +1,6 @@ { "name": "operator-plugin", - "version": "0.2.1", + "version": "0.2.2", "description": "Orchestrate Operator! ticket-driven coding agents from AGNT workflows", "author": "untra", "license": "MIT", diff --git a/backstage-server/package.json b/backstage-server/package.json index ad71d626..4f6b1e88 100644 --- a/backstage-server/package.json +++ b/backstage-server/package.json @@ -1,6 +1,6 @@ { "name": "operator-backstage", - "version": "0.2.0", + "version": "0.2.2", "author": { "name": "Samuel Volin", "email": "untra.sam@gmail.com", diff --git a/bindings/CollectionResponse.ts b/bindings/CollectionResponse.ts index 7ed56950..6c4ca395 100644 --- a/bindings/CollectionResponse.ts +++ b/bindings/CollectionResponse.ts @@ -1,6 +1,19 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { WorkflowHintsDto } from "./WorkflowHintsDto"; /** * Response for a collection */ -export type CollectionResponse = { name: string, description: string, types: Array, is_active: boolean, }; +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, +/** + * Descriptive workflow hints (present for hosted collections). + */ +workflow_hints?: WorkflowHintsDto | null, }; diff --git a/bindings/TemplatesConfig.ts b/bindings/TemplatesConfig.ts index 54e75abe..3bec21c2 100644 --- a/bindings/TemplatesConfig.ts +++ b/bindings/TemplatesConfig.ts @@ -16,4 +16,18 @@ collection: Array, * Active collection name (overrides preset if set) * Can be a builtin preset name or a user-defined collection */ -active_collection: string | null, }; +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, }; diff --git a/bindings/WorkflowHintsDto.ts b/bindings/WorkflowHintsDto.ts new file mode 100644 index 00000000..d566cc21 --- /dev/null +++ b/bindings/WorkflowHintsDto.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Descriptive workflow hints for a collection (v1: metadata only). + */ +export type WorkflowHintsDto = { loop_kind: string | null, memory_surfaces: Array, review_gates: Array, external_tools: Array, stop_conditions: Array, runner_semantics: string, }; diff --git a/bump-version.sh b/bump-version.sh index 04de56fa..cea051ea 100755 --- a/bump-version.sh +++ b/bump-version.sh @@ -30,6 +30,8 @@ TEXT_FILES=( "VERSION" "Cargo.toml" "opr8r/Cargo.toml" + "zed-extension/Cargo.toml" + "zed-extension/extension.toml" "vscode-extension/src/webhook-server.ts" "docs/_config.yml" ) @@ -38,6 +40,8 @@ TEXT_FILES=( JSON_FILES=( "vscode-extension/package.json" "backstage-server/package.json" + "agnt-plugin/package.json" + "agnt-plugin/manifest.json" ) for f in "${TEXT_FILES[@]}"; do diff --git a/codecov.yml b/codecov.yml index c0a38140..c0799ef8 100644 --- a/codecov.yml +++ b/codecov.yml @@ -12,11 +12,6 @@ coverage: threshold: 2% flags: - rust - typescript: - target: auto - threshold: 2% - flags: - - typescript vscode-extension: target: auto threshold: 2% @@ -36,11 +31,6 @@ flags: paths: - src/ carryforward: true - typescript: - paths: - - backstage-server/packages/ - - backstage-server/src/ - carryforward: true vscode-extension: paths: - vscode-extension/src/ @@ -57,5 +47,5 @@ comment: ignore: - "tests/**" - - "backstage-server/e2e/**" + - "backstage-server/**" - "vscode-extension/test/**" diff --git a/docs/schemas/openapi.json b/docs/schemas/openapi.json index aff5b61a..86c5fd82 100644 --- a/docs/schemas/openapi.json +++ b/docs/schemas/openapi.json @@ -10,7 +10,7 @@ "license": { "name": "MIT" }, - "version": "0.2.1" + "version": "0.2.2" }, "paths": { "/api/v1/agents/active": { diff --git a/src/main.rs b/src/main.rs index 9a01bd48..66f3c587 100644 --- a/src/main.rs +++ b/src/main.rs @@ -112,7 +112,7 @@ fn print_tmux_error(err: &TmuxError) { #[derive(Parser)] #[command(name = "operator")] -#[command(about = "Multi-agent orchestration dashboard for gbqr.us")] +#[command(about = "Multi-agent orchestration dashboard for kanban shaped software development")] #[command(version)] pub struct Cli { #[command(subcommand)] diff --git a/src/mcp/descriptor.rs b/src/mcp/descriptor.rs index f881282d..e56449e1 100644 --- a/src/mcp/descriptor.rs +++ b/src/mcp/descriptor.rs @@ -5,9 +5,9 @@ //! vscode-extension) and, optionally, the stdio entrypoint command so //! clients can spawn `operator mcp` as a subprocess instead. +use super::Host; use axum::extract::State; use axum::Json; -use axum_extra::extract::Host; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use ts_rs::TS; diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 342f2015..b192deab 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -12,3 +12,30 @@ pub mod stdio; pub mod tickets; pub mod tools; pub mod transport; + +use axum::extract::FromRequestParts; +use axum::http::request::Parts; + +/// Host (`host:port`) extracted from the request, for building the absolute +/// MCP URLs advertised to clients. +/// +/// Replaces the deprecated `axum_extra::extract::Host` extractor (axum +/// [#3442](https://github.com/tokio-rs/axum/issues/3442)). Reads only the +/// standard `Host` header, falling back to the URI authority for HTTP/2; it +/// deliberately does *not* trust `X-Forwarded-Host`. +pub struct Host(pub String); + +impl FromRequestParts for Host { + type Rejection = std::convert::Infallible; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + let host = parts + .headers + .get(axum::http::header::HOST) + .and_then(|v| v.to_str().ok()) + .map(str::to_string) + .or_else(|| parts.uri.authority().map(ToString::to_string)) + .unwrap_or_default(); + Ok(Host(host)) + } +} diff --git a/src/mcp/transport.rs b/src/mcp/transport.rs index d4fe9882..03dea8e3 100644 --- a/src/mcp/transport.rs +++ b/src/mcp/transport.rs @@ -8,11 +8,11 @@ use std::convert::Infallible; use std::time::Duration; +use super::Host; use axum::extract::{Query, State}; use axum::response::sse::{Event, Sse}; use axum::response::IntoResponse; use axum::Json; -use axum_extra::extract::Host; use serde::Deserialize; use serde_json::json; use tokio::sync::mpsc; diff --git a/src/rest/dto/agents.rs b/src/rest/dto/agents.rs index 3f961de2..01659c60 100644 --- a/src/rest/dto/agents.rs +++ b/src/rest/dto/agents.rs @@ -551,3 +551,223 @@ pub struct UpdateTicketStatusResponse { /// Human-readable message pub message: String, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_health_response_roundtrip_preserves_fields() { + let resp = HealthResponse { + status: "ok".to_string(), + version: "0.2.2".to_string(), + directory_name: "acme".to_string(), + directory_id: "abc123".to_string(), + }; + let json = serde_json::to_string(&resp).unwrap(); + let parsed: HealthResponse = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.status, "ok"); + assert_eq!(parsed.version, "0.2.2"); + assert_eq!(parsed.directory_name, "acme"); + assert_eq!(parsed.directory_id, "abc123"); + } + + #[test] + fn test_kanban_ticket_card_step_display_name_absent_when_none() { + let card = KanbanTicketCard { + id: "FEAT-1".to_string(), + summary: "Add thing".to_string(), + ticket_type: "FEAT".to_string(), + project: "gamesvc".to_string(), + status: "queued".to_string(), + step: "execute".to_string(), + step_display_name: None, + priority: "P2-medium".to_string(), + timestamp: "20260616-1200".to_string(), + }; + let json = serde_json::to_string(&card).unwrap(); + assert!(!json.contains("step_display_name")); + } + + #[test] + fn test_kanban_ticket_card_step_display_name_present_when_set() { + let card = KanbanTicketCard { + id: "FEAT-1".to_string(), + summary: "Add thing".to_string(), + ticket_type: "FEAT".to_string(), + project: "gamesvc".to_string(), + status: "queued".to_string(), + step: "execute".to_string(), + step_display_name: Some("Execute".to_string()), + priority: "P2-medium".to_string(), + timestamp: "20260616-1200".to_string(), + }; + let json = serde_json::to_string(&card).unwrap(); + assert!(json.contains("\"step_display_name\":\"Execute\"")); + } + + #[test] + fn test_queue_status_response_nests_by_type_counts() { + let resp = QueueStatusResponse { + queued: 3, + in_progress: 1, + awaiting: 2, + completed: 7, + by_type: QueueByType { + inv: 1, + fix: 1, + feat: 1, + spike: 0, + }, + }; + let json = serde_json::to_string(&resp).unwrap(); + assert!(json.contains("\"by_type\":{")); + let parsed: QueueStatusResponse = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.by_type.inv, 1); + assert_eq!(parsed.by_type.spike, 0); + assert_eq!(parsed.completed, 7); + } + + #[test] + fn test_active_agent_response_session_fields_absent_when_none() { + let agent = ActiveAgentResponse { + id: "op-1".to_string(), + ticket_id: "FEAT-1".to_string(), + ticket_type: "FEAT".to_string(), + project: "gamesvc".to_string(), + status: "running".to_string(), + mode: "autonomous".to_string(), + started_at: "2026-06-16T12:00:00Z".to_string(), + current_step: None, + session_wrapper: None, + session_window_ref: None, + session_context_ref: None, + session_pane_ref: None, + }; + let json = serde_json::to_string(&agent).unwrap(); + assert!(!json.contains("current_step")); + assert!(!json.contains("session_wrapper")); + assert!(!json.contains("session_window_ref")); + assert!(!json.contains("session_context_ref")); + assert!(!json.contains("session_pane_ref")); + } + + #[test] + fn test_agent_detail_response_optional_fields_absent_when_none() { + let detail = AgentDetailResponse { + id: "uuid-1".to_string(), + ticket_id: "FEAT-1".to_string(), + ticket_type: "FEAT".to_string(), + project: "gamesvc".to_string(), + status: "running".to_string(), + started_at: "2026-06-16T12:00:00Z".to_string(), + last_activity: "2026-06-16T12:05:00Z".to_string(), + current_step: None, + llm_tool: None, + llm_model: None, + launch_mode: None, + pr_url: None, + pr_status: None, + session_wrapper: None, + review_state: None, + completed_steps: vec![], + worktree_path: None, + paired: false, + }; + let json = serde_json::to_string(&detail).unwrap(); + // skip_serializing_if optionals are omitted... + assert!(!json.contains("pr_url")); + assert!(!json.contains("worktree_path")); + // ...but non-optional fields (incl. empty Vec) remain in the shape. + assert!(json.contains("\"completed_steps\":[]")); + assert!(json.contains("\"paired\":false")); + } + + #[test] + fn test_launch_ticket_request_minimal_json_applies_defaults() { + // Every field is #[serde(default)]; an empty object must parse. + let req: LaunchTicketRequest = serde_json::from_str("{}").unwrap(); + assert!(req.delegator.is_none()); + assert!(req.provider.is_none()); + assert!(req.model.is_none()); + assert!(req.model_server.is_none()); + assert!(!req.yolo_mode); + assert!(req.wrapper.is_none()); + assert!(req.retry_reason.is_none()); + assert!(req.resume_session_id.is_none()); + } + + #[test] + fn test_step_complete_request_output_valid_defaults_true_when_absent() { + // default_true(): output_valid should be true when the JSON omits it. + let json = r#"{ "exit_code": 0, "duration_secs": 10 }"#; + let req: StepCompleteRequest = serde_json::from_str(json).unwrap(); + assert!(req.output_valid); + assert!(req.output.is_none()); + } + + #[test] + fn test_step_complete_response_circuit_state_defaults_closed_when_absent() { + // default_circuit_closed(): circuit_state should be "closed" when omitted, + // and the other #[serde(default)] fields fall back to their zero values. + let json = r#"{ "status": "completed", "auto_proceed": true }"#; + let resp: StepCompleteResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.circuit_state, "closed"); + assert!(!resp.output_valid); + assert!(!resp.should_iterate); + assert_eq!(resp.iteration_count, 0); + assert_eq!(resp.cumulative_files_modified, 0); + assert_eq!(resp.cumulative_errors, 0); + } + + #[test] + fn test_ticket_detail_response_roundtrip_preserves_maps() { + let mut sessions = std::collections::HashMap::new(); + sessions.insert("execute".to_string(), "uuid-1".to_string()); + let mut step_delegators = std::collections::HashMap::new(); + step_delegators.insert("execute".to_string(), "claude-opus".to_string()); + + let detail = TicketDetailResponse { + id: "FEAT-1".to_string(), + summary: "Add thing".to_string(), + ticket_type: "FEAT".to_string(), + project: "gamesvc".to_string(), + status: "running".to_string(), + step: "execute".to_string(), + step_display_name: None, + priority: "P2-medium".to_string(), + timestamp: "20260616-1200".to_string(), + content: "# Ticket".to_string(), + filename: "feat-1.md".to_string(), + filepath: "/tmp/feat-1.md".to_string(), + sessions, + step_delegators, + worktree_path: None, + branch: None, + external_id: None, + external_url: None, + external_provider: None, + }; + let json = serde_json::to_string(&detail).unwrap(); + let parsed: TicketDetailResponse = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.sessions.get("execute").unwrap(), "uuid-1"); + assert_eq!( + parsed.step_delegators.get("execute").unwrap(), + "claude-opus" + ); + assert!(parsed.external_provider.is_none()); + } + + #[test] + fn test_next_step_info_prompt_absent_when_none() { + let info = NextStepInfo { + name: "review".to_string(), + display_name: "Review".to_string(), + review_type: "pr".to_string(), + prompt: None, + }; + let json = serde_json::to_string(&info).unwrap(); + assert!(json.contains("\"review_type\":\"pr\"")); + assert!(!json.contains("prompt")); + } +} diff --git a/src/rest/dto/configuration.rs b/src/rest/dto/configuration.rs index f14fb459..4967b064 100644 --- a/src/rest/dto/configuration.rs +++ b/src/rest/dto/configuration.rs @@ -403,3 +403,186 @@ pub struct DefaultLlmResponse { /// Default model alias (empty string if not set) pub model: String, } + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + #[test] + fn test_project_summary_skips_optional_kind_fields_when_none() { + let summary = ProjectSummary { + project_name: "gamesvc".to_string(), + project_path: "/repos/gamesvc".to_string(), + exists: true, + has_catalog_info: false, + has_project_context: false, + kind: None, + kind_confidence: None, + kind_tier: None, + languages: vec!["Rust".to_string()], + frameworks: vec![], + databases: vec![], + has_docker: None, + has_tests: None, + ports: vec![6400, 6401], + env_var_count: 0, + entry_point_count: 1, + commands: vec!["test".to_string()], + }; + let json = serde_json::to_string(&summary).unwrap(); + assert!(!json.contains("kind_confidence")); + assert!(!json.contains("has_docker")); + // Non-optional Vec fields stay present even when empty. + assert!(json.contains("\"frameworks\":[]")); + assert!(json.contains("\"ports\":[6400,6401]")); + + let parsed: ProjectSummary = serde_json::from_str(&json).unwrap(); + assert!(parsed.kind.is_none()); + assert_eq!(parsed.languages, vec!["Rust".to_string()]); + } + + #[test] + fn test_delegator_response_roundtrip() { + let resp = DelegatorResponse { + name: "claude-opus".to_string(), + llm_tool: "claude".to_string(), + model: "opus".to_string(), + display_name: Some("Claude Opus".to_string()), + model_properties: HashMap::new(), + model_server: None, + launch_config: None, + remote_agent: None, + }; + let json = serde_json::to_string(&resp).unwrap(); + // None optionals are skipped. + assert!(!json.contains("model_server")); + assert!(!json.contains("launch_config")); + assert!(!json.contains("remote_agent")); + let parsed: DelegatorResponse = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.name, "claude-opus"); + assert_eq!(parsed.display_name, Some("Claude Opus".to_string())); + } + + #[test] + fn test_delegator_launch_config_dto_defaults_when_absent() { + // yolo defaults to false; tri-state overrides default to None. + let dto: DelegatorLaunchConfigDto = serde_json::from_str("{}").unwrap(); + assert!(!dto.yolo); + assert!(dto.permission_mode.is_none()); + assert!(dto.flags.is_empty()); + assert!(dto.use_worktrees.is_none()); + assert!(dto.operator_relay.is_none()); + } + + #[test] + fn test_model_server_response_carries_api_key_env_name_not_raw_secret() { + // Secret-by-reference: the DTO references the API key only by env-var NAME + // (`api_key_env`); it has no field that could carry the raw secret value. + let resp = ModelServerResponse { + name: "ollama-local".to_string(), + kind: "ollama".to_string(), + base_url: Some("http://localhost:11434".to_string()), + api_key_env: Some("OLLAMA_API_KEY".to_string()), + extra_env: HashMap::new(), + display_name: None, + user_declared: true, + }; + let json = serde_json::to_string(&resp).unwrap(); + // The env-var NAME reference is present... + assert!(json.contains("\"api_key_env\":\"OLLAMA_API_KEY\"")); + // ...but there is no raw `api_key` secret field on the wire. + assert!(!json.contains("\"api_key\":")); + } + + #[test] + fn test_model_server_response_skips_none_optionals() { + let resp = ModelServerResponse { + name: "anthropic".to_string(), + kind: "anthropic-api".to_string(), + base_url: None, + api_key_env: None, + extra_env: HashMap::new(), + display_name: None, + user_declared: false, + }; + let json = serde_json::to_string(&resp).unwrap(); + assert!(!json.contains("base_url")); + assert!(!json.contains("api_key_env")); + assert!(!json.contains("display_name")); + // extra_env has no skip attribute, so it is always present. + assert!(json.contains("\"extra_env\":{}")); + } + + #[test] + fn test_create_model_server_request_extra_env_defaults_empty() { + let json = r#"{ "name": "ollama-local", "kind": "ollama" }"#; + let req: CreateModelServerRequest = serde_json::from_str(json).unwrap(); + assert!(req.extra_env.is_empty()); + assert!(req.base_url.is_none()); + assert!(req.api_key_env.is_none()); + } + + #[test] + fn test_update_model_server_request_extra_env_defaults_empty() { + let json = r#"{ "kind": "ollama" }"#; + let req: UpdateModelServerRequest = serde_json::from_str(json).unwrap(); + assert!(req.extra_env.is_empty()); + assert!(req.display_name.is_none()); + } + + #[test] + fn test_model_server_kind_entry_skips_none_brand_and_defaults() { + let entry = ModelServerKindEntry { + slug: "ollama".to_string(), + display_name: "Ollama".to_string(), + description: "Local models".to_string(), + setup_url: "https://ollama.com".to_string(), + icon: "server".to_string(), + is_builtin: true, + category: "first-party".to_string(), + category_label: "First-party".to_string(), + brand_icon: None, + default_base_url: Some("http://localhost:11434".to_string()), + default_api_key_env: None, + connectable: true, + }; + let json = serde_json::to_string(&entry).unwrap(); + // `category` field name is kept for wire/TS stability. + assert!(json.contains("\"category\":\"first-party\"")); + assert!(!json.contains("brand_icon")); + assert!(!json.contains("default_api_key_env")); + let parsed: ModelServerKindEntry = serde_json::from_str(&json).unwrap(); + assert!(parsed.connectable); + assert!(parsed.brand_icon.is_none()); + } + + #[test] + fn test_model_server_models_response_error_absent_when_reachable() { + let resp = ModelServerModelsResponse { + server: "ollama-local".to_string(), + reachable: true, + models: vec![ModelEntry { + id: "llama3".to_string(), + display_name: None, + }], + error: None, + }; + let json = serde_json::to_string(&resp).unwrap(); + assert!(!json.contains("error")); + assert!(!json.contains("display_name")); + assert!(json.contains("\"reachable\":true")); + } + + #[test] + fn test_default_llm_response_roundtrip_with_empty_strings() { + let resp = DefaultLlmResponse { + tool: String::new(), + model: String::new(), + }; + let json = serde_json::to_string(&resp).unwrap(); + assert_eq!(json, r#"{"tool":"","model":""}"#); + let parsed: DefaultLlmResponse = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.tool, ""); + } +} diff --git a/src/rest/dto/issue_types.rs b/src/rest/dto/issue_types.rs index 8074c92d..49166775 100644 --- a/src/rest/dto/issue_types.rs +++ b/src/rest/dto/issue_types.rs @@ -457,3 +457,164 @@ impl CollectionResponse { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_issue_type_summary_serializes_step_count_as_camel_case() { + // IssueTypeSummary is the only DTO here using rename_all = "camelCase"; + // snake_case `step_count` must appear on the wire as `stepCount`. + let summary = IssueTypeSummary { + key: "FEAT".to_string(), + name: "Feature".to_string(), + description: "A feature".to_string(), + mode: "autonomous".to_string(), + glyph: "F".to_string(), + color: Some("cyan".to_string()), + source: "user".to_string(), + step_count: 3, + }; + let json = serde_json::to_string(&summary).unwrap(); + assert!(json.contains("\"stepCount\":3")); + assert!(!json.contains("step_count")); + } + + #[test] + fn test_issue_type_summary_color_absent_when_none() { + let summary = IssueTypeSummary { + key: "FEAT".to_string(), + name: "Feature".to_string(), + description: "A feature".to_string(), + mode: "autonomous".to_string(), + glyph: "F".to_string(), + color: None, + source: "user".to_string(), + step_count: 0, + }; + let json = serde_json::to_string(&summary).unwrap(); + assert!(!json.contains("color")); + } + + #[test] + fn test_issue_type_summary_deserializes_from_camel_case() { + let json = r#"{ + "key": "FIX", + "name": "Fix", + "description": "A fix", + "mode": "autonomous", + "glyph": "X", + "source": "user", + "stepCount": 5 + }"#; + let summary: IssueTypeSummary = serde_json::from_str(json).unwrap(); + assert_eq!(summary.step_count, 5); + assert!(summary.color.is_none()); + } + + #[test] + fn test_create_issue_type_request_applies_defaults_when_absent() { + // mode -> default_mode(), project_required -> default_true(), fields -> empty. + let json = r#"{ + "key": "feat", + "name": "Feature", + "description": "A feature", + "glyph": "F", + "steps": [] + }"#; + let req: CreateIssueTypeRequest = serde_json::from_str(json).unwrap(); + assert_eq!(req.mode, "autonomous"); + assert!(req.project_required); + assert!(req.fields.is_empty()); + assert!(req.color.is_none()); + } + + #[test] + fn test_create_field_request_applies_typed_defaults_when_absent() { + // field_type -> default_string_type(), user_editable -> default_true(), + // required defaults to false, options to empty. + let json = r#"{ "name": "title", "description": "Title field" }"#; + let req: CreateFieldRequest = serde_json::from_str(json).unwrap(); + assert_eq!(req.field_type, "string"); + assert!(req.user_editable); + assert!(!req.required); + assert!(req.options.is_empty()); + } + + #[test] + fn test_create_step_request_applies_defaults_when_absent() { + // allowed_tools -> ["*"], review_type -> "none", permission_mode -> "default". + let json = r#"{ "name": "execute", "prompt": "Do the thing" }"#; + let req: CreateStepRequest = serde_json::from_str(json).unwrap(); + assert_eq!(req.allowed_tools, vec!["*".to_string()]); + assert_eq!(req.review_type, "none"); + assert_eq!(req.permission_mode, "default"); + assert!(req.next_step.is_none()); + assert!(req.outputs.is_empty()); + } + + #[test] + fn test_field_response_skips_empty_options_and_none_default() { + let resp = FieldResponse { + name: "title".to_string(), + description: "Title".to_string(), + field_type: "string".to_string(), + required: true, + default: None, + options: vec![], + placeholder: None, + max_length: None, + user_editable: true, + }; + let json = serde_json::to_string(&resp).unwrap(); + // skip_serializing_if = "Vec::is_empty" and "Option::is_none" + assert!(!json.contains("options")); + assert!(!json.contains("default")); + assert!(!json.contains("placeholder")); + assert!(json.contains("\"required\":true")); + } + + #[test] + fn test_field_response_includes_options_when_present() { + let resp = FieldResponse { + name: "priority".to_string(), + description: "Priority".to_string(), + field_type: "enum".to_string(), + required: false, + default: Some("P2".to_string()), + options: vec!["P0".to_string(), "P2".to_string()], + placeholder: None, + max_length: None, + user_editable: true, + }; + let json = serde_json::to_string(&resp).unwrap(); + assert!(json.contains("\"options\":[\"P0\",\"P2\"]")); + assert!(json.contains("\"default\":\"P2\"")); + } + + #[test] + fn test_update_issue_type_request_all_fields_optional() { + // Every field is #[serde(default)] Option/None; empty object parses to all-None. + let req: UpdateIssueTypeRequest = serde_json::from_str("{}").unwrap(); + assert!(req.name.is_none()); + assert!(req.mode.is_none()); + assert!(req.project_required.is_none()); + assert!(req.fields.is_none()); + assert!(req.steps.is_none()); + } + + #[test] + fn test_collection_response_roundtrip() { + let resp = CollectionResponse { + name: "default".to_string(), + description: "Default collection".to_string(), + types: vec!["FEAT".to_string(), "FIX".to_string()], + is_active: true, + }; + let json = serde_json::to_string(&resp).unwrap(); + let parsed: CollectionResponse = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.types, vec!["FEAT".to_string(), "FIX".to_string()]); + assert!(parsed.is_active); + } +} diff --git a/src/rest/dto/kanban.rs b/src/rest/dto/kanban.rs index 401b3492..d83b4814 100644 --- a/src/rest/dto/kanban.rs +++ b/src/rest/dto/kanban.rs @@ -378,3 +378,180 @@ pub struct SetKanbanSessionEnvResponse { /// into `~/.zshrc` / `~/.bashrc`. pub shell_export_block: String, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_kanban_provider_kind_serializes_lowercase_slugs() { + // rename_all = "lowercase": these slugs must match the catalog slugs + // documented on KanbanProviderCatalogEntry ("jira" | "linear" | "github"). + assert_eq!( + serde_json::to_string(&KanbanProviderKind::Jira).unwrap(), + "\"jira\"" + ); + assert_eq!( + serde_json::to_string(&KanbanProviderKind::Linear).unwrap(), + "\"linear\"" + ); + assert_eq!( + serde_json::to_string(&KanbanProviderKind::Github).unwrap(), + "\"github\"" + ); + } + + #[test] + fn test_kanban_provider_kind_deserializes_from_lowercase_slugs() { + let jira: KanbanProviderKind = serde_json::from_str("\"jira\"").unwrap(); + assert_eq!(jira, KanbanProviderKind::Jira); + let github: KanbanProviderKind = serde_json::from_str("\"github\"").unwrap(); + assert_eq!(github, KanbanProviderKind::Github); + } + + #[test] + fn test_kanban_provider_kind_rejects_titlecase() { + // Wire format is strictly lowercase; the Rust variant spelling must not parse. + assert!(serde_json::from_str::("\"Jira\"").is_err()); + } + + #[test] + fn test_validate_request_serializes_only_targeted_provider_body() { + // provider-tagged request: only the matching sub-body should appear; the + // other two (None) are skipped via skip_serializing_if. + let req = ValidateKanbanCredentialsRequest { + provider: KanbanProviderKind::Jira, + jira: Some(JiraCredentials { + domain: "acme.atlassian.net".to_string(), + email: "a@b.com".to_string(), + api_token: "secret-token".to_string(), + }), + linear: None, + github: None, + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("\"provider\":\"jira\"")); + assert!(json.contains("\"jira\":{")); + assert!(!json.contains("\"linear\":")); + assert!(!json.contains("\"github\":")); + } + + #[test] + fn test_validate_request_deserializes_with_missing_provider_bodies() { + // The three credential slots default to None when absent. + let json = r#"{ "provider": "linear", "linear": { "api_key": "lin_api_x" } }"#; + let req: ValidateKanbanCredentialsRequest = serde_json::from_str(json).unwrap(); + assert_eq!(req.provider, KanbanProviderKind::Linear); + assert!(req.jira.is_none()); + assert!(req.github.is_none()); + assert_eq!(req.linear.unwrap().api_key, "lin_api_x"); + } + + #[test] + fn test_validate_response_skips_none_detail_blocks() { + let resp = ValidateKanbanCredentialsResponse { + valid: false, + error: Some("invalid token".to_string()), + jira: None, + linear: None, + github: None, + }; + let json = serde_json::to_string(&resp).unwrap(); + assert!(json.contains("\"valid\":false")); + assert!(json.contains("\"error\":\"invalid token\"")); + assert!(!json.contains("\"jira\":")); + assert!(!json.contains("\"linear\":")); + assert!(!json.contains("\"github\":")); + } + + #[test] + fn test_write_kanban_config_body_carries_env_name_not_secret() { + // The config-write path stores only the env-var NAME (`api_key_env`) — it + // must never carry the raw secret. (Contrast with the *SessionEnv bodies + // below, which deliberately DO carry the secret to set server env.) + let body = WriteJiraConfigBody { + domain: "acme.atlassian.net".to_string(), + email: "a@b.com".to_string(), + api_key_env: "OPERATOR_JIRA_TOKEN".to_string(), + project_key: "PROJ".to_string(), + sync_user_id: "acct-1".to_string(), + }; + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains("\"api_key_env\":\"OPERATOR_JIRA_TOKEN\"")); + // No raw secret field channels on the config-write body. + assert!(!json.contains("api_token")); + assert!(!json.contains("\"token\":")); + } + + #[test] + fn test_jira_session_env_intentionally_carries_secret() { + // Counterpart to the config-write body: session-env DOES transport the + // secret (`api_token`) so the server can set it in its process env. + let env = JiraSessionEnv { + domain: "acme.atlassian.net".to_string(), + email: "a@b.com".to_string(), + api_token: "real-secret".to_string(), + api_key_env: "OPERATOR_JIRA_TOKEN".to_string(), + }; + let json = serde_json::to_string(&env).unwrap(); + assert!(json.contains("\"api_token\":\"real-secret\"")); + assert!(json.contains("\"api_key_env\":\"OPERATOR_JIRA_TOKEN\"")); + let parsed: JiraSessionEnv = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.api_token, "real-secret"); + } + + #[test] + fn test_provider_catalog_entry_roundtrip() { + let entry = KanbanProviderCatalogEntry { + slug: "github".to_string(), + display_name: "GitHub Projects".to_string(), + description: "Sync from a GitHub Project v2".to_string(), + setup_url: "https://github.com/settings/tokens".to_string(), + icon: "github".to_string(), + configured: true, + }; + let json = serde_json::to_string(&entry).unwrap(); + let parsed: KanbanProviderCatalogEntry = serde_json::from_str(&json).unwrap(); + // slug aligns with KanbanProviderKind::Github's lowercase wire form. + assert_eq!(parsed.slug, "github"); + assert!(parsed.configured); + } + + #[test] + fn test_kanban_issue_type_response_skips_none_optionals() { + let resp = KanbanIssueTypeResponse { + id: "10001".to_string(), + name: "Bug".to_string(), + description: None, + icon_url: None, + provider: "jira".to_string(), + project: "PROJ".to_string(), + source_kind: "issuetype".to_string(), + synced_at: "2026-06-16T12:00:00Z".to_string(), + }; + let json = serde_json::to_string(&resp).unwrap(); + assert!(!json.contains("description")); + assert!(!json.contains("icon_url")); + assert!(json.contains("\"source_kind\":\"issuetype\"")); + } + + #[test] + fn test_write_kanban_config_request_targets_single_provider() { + let req = WriteKanbanConfigRequest { + provider: KanbanProviderKind::Github, + jira: None, + linear: None, + github: Some(WriteGithubConfigBody { + owner: "acme".to_string(), + api_key_env: "OPERATOR_GITHUB_TOKEN".to_string(), + project_key: "PVT_kwDOABcdefg".to_string(), + sync_user_id: "123".to_string(), + }), + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("\"provider\":\"github\"")); + assert!(json.contains("\"github\":{")); + assert!(!json.contains("\"jira\":")); + assert!(!json.contains("\"linear\":")); + } +} diff --git a/src/ui/setup/steps/hosted.rs b/src/ui/setup/steps/hosted.rs new file mode 100644 index 00000000..bf2713c5 --- /dev/null +++ b/src/ui/setup/steps/hosted.rs @@ -0,0 +1,147 @@ +//! Hosted collection picker step rendering + +use crate::collections::fetch::CollectionOrigin; +use crate::ui::dialogs::centered_rect; +use crate::ui::setup::SetupScreen; +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem, Paragraph}, + Frame, +}; + +impl SetupScreen { + pub(crate) fn render_hosted_collection_step(&mut self, frame: &mut Frame) { + let area = centered_rect(70, 70, frame.area()); + frame.render_widget(Clear, area); + + let block = Block::default() + .title(" Hosted Collections ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + + let inner = block.inner(area); + frame.render_widget(block, area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(2), // Title + Constraint::Length(2), // Instructions + Constraint::Min(6), // List + Constraint::Length(4), // Details of highlighted + Constraint::Length(2), // Footer + ]) + .split(inner); + + let title = Paragraph::new(Line::from(vec![Span::styled( + "Choose a Collection", + Style::default() + .fg(Color::LightRed) + .add_modifier(Modifier::BOLD), + )])) + .alignment(Alignment::Center); + frame.render_widget(title, chunks[0]); + + // Loading / empty states. + if !self.hosted_loaded { + let msg = Paragraph::new("Fetching collections…") + .alignment(Alignment::Center) + .style(Style::default().fg(Color::Gray)); + frame.render_widget(msg, chunks[2]); + return; + } + if self.hosted_resolved.is_empty() { + let msg = Paragraph::new("No collections available.") + .alignment(Alignment::Center) + .style(Style::default().fg(Color::DarkGray)); + frame.render_widget(msg, chunks[2]); + return; + } + + let instructions = + Paragraph::new(vec![Line::from("Use arrows to navigate, Enter to select")]) + .alignment(Alignment::Center) + .style(Style::default().fg(Color::Gray)); + frame.render_widget(instructions, chunks[1]); + + // Collection list: name + version + verification status. + let items: Vec = self + .hosted_resolved + .iter() + .map(|r| { + let (badge, badge_style) = match r.origin { + CollectionOrigin::Hosted => ("✓ verified", Style::default().fg(Color::Green)), + CollectionOrigin::Embedded => { + ("ⓘ built-in", Style::default().fg(Color::Yellow)) + } + }; + ListItem::new(vec![ + Line::from(vec![ + Span::styled( + r.manifest.name.clone(), + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + format!("v{}", r.manifest.version), + Style::default().fg(Color::DarkGray), + ), + Span::raw(" "), + Span::styled(badge, badge_style), + ]), + Line::from(vec![ + Span::raw(" "), + Span::styled( + r.manifest.description.clone(), + Style::default().fg(Color::DarkGray), + ), + ]), + ]) + }) + .collect(); + + let list = List::new(items) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) + .highlight_symbol("> "); + frame.render_stateful_widget(list, chunks[2], &mut self.hosted_state); + + // Details for the highlighted collection: issue types + workflow hints. + if let Some(r) = self.highlighted_hosted() { + let mut lines = vec![Line::from(vec![ + Span::styled("Types: ", Style::default().fg(Color::Cyan)), + Span::raw(r.manifest.type_keys().join(", ")), + ])]; + if let Some(hints) = &r.manifest.workflow_hints { + let mut hint_spans = vec![Span::styled("Loop: ", Style::default().fg(Color::Cyan))]; + hint_spans.push(Span::raw( + hints.loop_kind.clone().unwrap_or_else(|| "—".to_string()), + )); + if !hints.review_gates.is_empty() { + hint_spans.push(Span::styled(" Gates: ", Style::default().fg(Color::Cyan))); + hint_spans.push(Span::raw(hints.review_gates.join(", "))); + } + lines.push(Line::from(hint_spans)); + } + if let Some(note) = &r.note { + lines.push(Line::from(vec![Span::styled( + format!("⚠ {note}"), + Style::default().fg(Color::Yellow), + )])); + } + let details = Paragraph::new(lines).style(Style::default().fg(Color::Gray)); + frame.render_widget(details, chunks[3]); + } + + let footer = Paragraph::new(Line::from(vec![ + Span::styled("Enter", Style::default().fg(Color::Yellow)), + Span::raw(" select "), + Span::styled("Esc", Style::default().fg(Color::Yellow)), + Span::raw(" back"), + ])) + .alignment(Alignment::Center); + frame.render_widget(footer, chunks[4]); + } +} diff --git a/src/ui/setup/steps/mod.rs b/src/ui/setup/steps/mod.rs index 4f14a3d1..e6e67d66 100644 --- a/src/ui/setup/steps/mod.rs +++ b/src/ui/setup/steps/mod.rs @@ -3,6 +3,7 @@ mod acceptance; mod collection; mod confirm; +mod hosted; mod kanban; mod startup; mod task_fields; @@ -12,6 +13,7 @@ mod wrapper; pub use acceptance::*; pub use collection::*; pub use confirm::*; +pub use hosted::*; pub use kanban::*; pub use startup::*; pub use task_fields::*; diff --git a/src/workflow_gen/export.rs b/src/workflow_gen/export.rs index 2f61f9ec..aee9a1c0 100644 --- a/src/workflow_gen/export.rs +++ b/src/workflow_gen/export.rs @@ -613,3 +613,261 @@ fn describe_rag_source(src: &RagSource) -> String { RagSource::Mcp { server, tool, .. } => format!("mcp:{server}/{tool}"), } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Minimal issuetype wrapping `steps_json`, used to materialize real + /// `StepSchema` values (serde defaults fill the rest) for the private + /// helpers that take a `&StepSchema`. + fn issuetype_with_steps(steps_json: &str) -> IssueType { + let json = format!( + r#"{{ + "key": "FEAT", + "name": "Feature", + "description": "A new feature", + "mode": "autonomous", + "glyph": "*", + "fields": [], + "steps": {steps_json} + }}"# + ); + IssueType::from_json(&json).expect("valid issuetype json") + } + + fn ticket(id: &str, project: &str, summary: &str) -> Ticket { + Ticket { + filename: "20241221-1430-FEAT-proj-test.md".to_string(), + filepath: "/test/path".to_string(), + timestamp: "20241221-1430".to_string(), + ticket_type: "FEAT".to_string(), + project: project.to_string(), + id: id.to_string(), + summary: summary.to_string(), + priority: "P2-medium".to_string(), + status: "queued".to_string(), + step: String::new(), + content: "body".to_string(), + sessions: std::collections::HashMap::new(), + step_delegators: std::collections::HashMap::new(), + llm_task: crate::queue::LlmTask::default(), + worktree_path: None, + branch: None, + external_id: None, + external_url: None, + external_provider: None, + } + } + + // ── agent_call ────────────────────────────────────────────────────── + + #[test] + fn test_agent_call_without_model_emits_single_await_line() { + let out = agent_call("r_plan", "Do the thing", "plan", None); + assert_eq!( + out, + "const r_plan = await agent(\"Do the thing\", { label: \"plan\" });\n" + ); + assert!(!out.contains("model:"), "no model opt expected:\n{out}"); + } + + #[test] + fn test_agent_call_with_model_includes_model_opt() { + let out = agent_call("r_build", "Build it", "build", Some("opus")); + assert!( + out.contains("label: \"build\", model: \"opus\""), + "model opt missing:\n{out}" + ); + } + + #[test] + fn test_agent_call_escapes_prompt_quotes() { + let out = agent_call("r_x", "say \"hi\"", "x", None); + assert!( + out.contains(r#"agent("say \"hi\"","#), + "embedded quotes not escaped:\n{out}" + ); + } + + // ── items_literal ─────────────────────────────────────────────────── + + #[test] + fn test_items_literal_quotes_and_joins() { + let items = vec!["a".to_string(), "b".to_string()]; + assert_eq!(items_literal(&items), r#"["a", "b"]"#); + } + + #[test] + fn test_items_literal_empty_is_empty_array() { + assert_eq!(items_literal(&[]), "[]"); + } + + #[test] + fn test_items_literal_escapes_embedded_quotes() { + let items = vec![r#"a"b"#.to_string()]; + assert_eq!(items_literal(&items), r#"["a\"b"]"#); + } + + // ── describe_rag_source ───────────────────────────────────────────── + + #[test] + fn test_describe_rag_source_glob_prefixes_pattern() { + let src = RagSource::Glob { + pattern: "docs/*.md".to_string(), + }; + assert_eq!(describe_rag_source(&src), "glob:docs/*.md"); + } + + #[test] + fn test_describe_rag_source_file_prefixes_path() { + let src = RagSource::File { + path: "README.md".to_string(), + }; + assert_eq!(describe_rag_source(&src), "file:README.md"); + } + + #[test] + fn test_describe_rag_source_mcp_joins_server_and_tool() { + let src = RagSource::Mcp { + server: "srv".to_string(), + tool: "search".to_string(), + query: None, + }; + assert_eq!(describe_rag_source(&src), "mcp:srv/search"); + } + + // ── meta_block ────────────────────────────────────────────────────── + + #[test] + fn test_meta_block_emits_export_const_meta_with_name_and_phases() { + let it = issuetype_with_steps( + r#"[{"name":"plan","display_name":"Planning","outputs":["plan"],"prompt":"p"}, + {"name":"build","display_name":"Building","outputs":["code"],"prompt":"b"}]"#, + ); + let tk = ticket("FEAT-1234", "gamesvc", "Add pagination"); + let steps: Vec<&StepSchema> = it.steps.iter().collect(); + let out = meta_block(&tk, &it, &steps); + + assert!( + out.contains("export const meta = {"), + "meta header missing:\n{out}" + ); + assert!( + out.contains(r#"name: "FEAT-1234 — Add pagination""#), + "name should combine ticket id + summary:\n{out}" + ); + assert!(out.contains("phases: ["), "phases array missing:\n{out}"); + // Each step's display_name becomes a phase title. + assert!( + out.contains(r#"{ title: "Planning" }"#) && out.contains(r#"{ title: "Building" }"#), + "phase titles missing:\n{out}" + ); + } + + // ── judge_loop ────────────────────────────────────────────────────── + + #[test] + fn test_judge_loop_emits_bounded_do_while_with_gap_marker() { + let it = issuetype_with_steps( + r#"[{"name":"plan","outputs":["plan"],"prompt":"plan it","review_type":"plan"}]"#, + ); + let step = &it.steps[0]; + let out = judge_loop(step, "r_plan", "plan it", None); + + assert!(out.contains("do {"), "do-block missing:\n{out}"); + assert!(out.contains("} while ("), "while condition missing:\n{out}"); + assert!(out.contains("REVISE"), "REVISE sentinel missing:\n{out}"); + // Bounded retry: at most 3 attempts. + assert!( + out.contains("++r_plan_attempt < 3"), + "attempt bound missing:\n{out}" + ); + assert!( + out.contains(GAP_MARKER), + "human-gate GAP marker missing:\n{out}" + ); + } + + // ── pipeline_items_expr ───────────────────────────────────────────── + + #[test] + fn test_pipeline_items_expr_static_emits_literal_array() { + let src = ItemSource::Static { + items: vec!["alpha".to_string(), "beta".to_string()], + }; + let (preamble, expr) = pipeline_items_expr(&src, "r_x", &PipelineEnv::default()); + assert_eq!(preamble, ""); + assert_eq!(expr, r#"["alpha", "beta"]"#); + } + + #[test] + fn test_pipeline_items_expr_from_step_references_step_var() { + let src = ItemSource::FromStep { + step: "triage".to_string(), + }; + let (preamble, expr) = pipeline_items_expr(&src, "r_x", &PipelineEnv::default()); + // Symbolic: the prior step's result identifier, no literal array. + assert_eq!(expr, "r_triage"); + assert!( + preamble.contains(GAP_MARKER) && preamble.contains("triage"), + "runtime-shape GAP note missing:\n{preamble}" + ); + } + + #[test] + fn test_pipeline_items_expr_projects_sorts_and_emits_literal() { + let env = PipelineEnv { + projects: vec!["zeta".to_string(), "alpha".to_string()], + glob_root: None, + }; + let (preamble, expr) = pipeline_items_expr(&ItemSource::Projects, "r_x", &env); + assert_eq!(preamble, ""); + assert_eq!(expr, r#"["alpha", "zeta"]"#, "projects must be sorted"); + } + + #[test] + fn test_pipeline_items_expr_projects_empty_falls_back_to_placeholder() { + let (preamble, expr) = + pipeline_items_expr(&ItemSource::Projects, "r_x", &PipelineEnv::default()); + assert_eq!(expr, "r_x_items", "unresolved → symbolic placeholder ident"); + assert!( + preamble.contains(GAP_MARKER) && preamble.contains("const r_x_items = [];"), + "zero-iteration placeholder missing:\n{preamble}" + ); + } + + #[test] + fn test_pipeline_items_expr_field_emits_gap_placeholder() { + let src = ItemSource::Field { + name: "components".to_string(), + }; + let (preamble, expr) = pipeline_items_expr(&src, "r_x", &PipelineEnv::default()); + assert_eq!(expr, "r_x_items"); + assert!( + preamble.contains(GAP_MARKER) && preamble.contains("field:components"), + "field placeholder note missing:\n{preamble}" + ); + } + + // ── expand_glob (filesystem) ──────────────────────────────────────── + + #[test] + fn test_expand_glob_returns_sorted_relative_paths() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("b.md"), "b").unwrap(); + std::fs::write(dir.path().join("a.md"), "a").unwrap(); + std::fs::write(dir.path().join("skip.txt"), "no").unwrap(); + + let matches = expand_glob(dir.path(), "*.md"); + // Sorted, relative (no absolute path leaking machine state), .txt excluded. + assert_eq!(matches, vec!["a.md".to_string(), "b.md".to_string()]); + } + + #[test] + fn test_expand_glob_no_match_returns_empty() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("a.md"), "a").unwrap(); + assert!(expand_glob(dir.path(), "*.rs").is_empty()); + } +} diff --git a/tests/version_parity.rs b/tests/version_parity.rs new file mode 100644 index 00000000..c9605750 --- /dev/null +++ b/tests/version_parity.rs @@ -0,0 +1,108 @@ +//! Asserts every managed manifest carries the canonical version from `VERSION`. +//! +//! Adding a new versioned manifest is a one-line addition to `MANAGED` below. +//! Keep this list in sync with the files revved by `bump-version.sh` and the +//! `release` job in `.github/workflows/build.yaml`. + +use std::fs; +use std::path::{Path, PathBuf}; + +/// How to pull the version string out of a given file. +enum ExtractKind { + /// First `version = "..."` line (Cargo.toml, zed extension.toml). + TomlPackageVersion, + /// Top-level `"version": "..."` (package.json, manifest.json, openapi.json). + JsonDotVersion, + /// `version: ...` line (docs/_config.yml). + YamlVersion, + /// `const VERSION = '...'` (webhook-server.ts). + TsConst, +} + +/// Repo root = crate manifest dir (tests run with CWD at the crate root). +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn read(path: &Path) -> String { + fs::read_to_string(path).unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())) +} + +/// Extract the first `"": ""`-style version from a string slice +/// once positioned at the start of the value. Returns the inner string. +fn between_quotes_after(haystack: &str, marker: &str) -> Option { + let start = haystack.find(marker)? + marker.len(); + let rest = &haystack[start..]; + let q = rest.find(['"', '\''])?; + let rest = &rest[q + 1..]; + let end = rest.find(['"', '\''])?; + Some(rest[..end].to_string()) +} + +fn extract(kind: &ExtractKind, content: &str) -> Option { + match kind { + ExtractKind::TomlPackageVersion => content + .lines() + .find(|l| l.trim_start().starts_with("version =")) + .and_then(|l| between_quotes_after(l, "version")), + ExtractKind::JsonDotVersion => content + .lines() + .find(|l| l.trim_start().starts_with("\"version\"")) + .and_then(|l| between_quotes_after(l, ":")), + ExtractKind::YamlVersion => content + .lines() + .find(|l| l.trim_start().starts_with("version:")) + .map(|l| l.split(':').nth(1).unwrap_or("").trim().to_string()), + ExtractKind::TsConst => content + .lines() + .find(|l| l.contains("const VERSION")) + .and_then(|l| between_quotes_after(l, "=")), + } +} + +/// Every file whose version must match `VERSION`. One line per manifest. +const MANAGED: &[(&str, ExtractKind)] = &[ + ("Cargo.toml", ExtractKind::TomlPackageVersion), + ("opr8r/Cargo.toml", ExtractKind::TomlPackageVersion), + ("zed-extension/Cargo.toml", ExtractKind::TomlPackageVersion), + ( + "zed-extension/extension.toml", + ExtractKind::TomlPackageVersion, + ), + ("docs/_config.yml", ExtractKind::YamlVersion), + ("vscode-extension/package.json", ExtractKind::JsonDotVersion), + ( + "vscode-extension/src/webhook-server.ts", + ExtractKind::TsConst, + ), + ("backstage-server/package.json", ExtractKind::JsonDotVersion), + ("agnt-plugin/package.json", ExtractKind::JsonDotVersion), + ("agnt-plugin/manifest.json", ExtractKind::JsonDotVersion), + ("docs/schemas/openapi.json", ExtractKind::JsonDotVersion), +]; + +#[test] +fn test_all_managed_manifests_match_version_file() { + let root = repo_root(); + let expected = read(&root.join("VERSION")).trim().to_string(); + assert!(!expected.is_empty(), "VERSION file is empty"); + + let mut mismatches = Vec::new(); + for (rel, kind) in MANAGED { + let path = root.join(rel); + let content = read(&path); + match extract(kind, &content) { + Some(found) if found == expected => {} + Some(found) => { + mismatches.push(format!(" {rel}: found {found:?}, expected {expected:?}")); + } + None => mismatches.push(format!(" {rel}: no version string found")), + } + } + + assert!( + mismatches.is_empty(), + "version drift from VERSION={expected:?}:\n{}\nRun ./bump-version.sh or correct the files above; regenerate docs/schemas/openapi.json with `cargo run -- docs --only openapi`.", + mismatches.join("\n") + ); +} diff --git a/zed-extension/Cargo.lock b/zed-extension/Cargo.lock index 9740c85d..b83d87a9 100644 --- a/zed-extension/Cargo.lock +++ b/zed-extension/Cargo.lock @@ -367,7 +367,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "operator-zed" -version = "0.2.0" +version = "0.2.2" dependencies = [ "serde", "serde_json", diff --git a/zed-extension/Cargo.toml b/zed-extension/Cargo.toml index ba8b0c5d..90184c73 100644 --- a/zed-extension/Cargo.toml +++ b/zed-extension/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "operator-zed" -version = "0.2.0" +version = "0.2.2" edition = "2021" description = "Zed extension for Operator multi-agent orchestration" license = "MIT" diff --git a/zed-extension/extension.toml b/zed-extension/extension.toml index 012b8ec9..01bb0f39 100644 --- a/zed-extension/extension.toml +++ b/zed-extension/extension.toml @@ -1,7 +1,7 @@ id = "operator" name = "Operator" description = "Multi-agent orchestration for Coding Agents — MCP tools, ACP agent, and slash commands" -version = "0.2.0" +version = "0.2.2" schema_version = 1 authors = ["Samuel Volin "] repository = "https://github.com/untra/operator" From be7787055b7ad633850cde939d7f8aeaee2647fa Mon Sep 17 00:00:00 2001 From: untra Date: Tue, 16 Jun 2026 11:37:22 -0600 Subject: [PATCH 02/11] define loop structures, elves, ralph, jr, setup refinement --- docs/collections/dev_kanban/FEAT.json | 112 +++++ docs/collections/dev_kanban/FEAT.md | 15 + docs/collections/dev_kanban/FIX.json | 138 ++++++ docs/collections/dev_kanban/FIX.md | 18 + docs/collections/dev_kanban/TASK.json | 75 +++ docs/collections/dev_kanban/TASK.md | 26 + docs/collections/dev_kanban/collection.json | 62 +++ docs/collections/devops_kanban/FEAT.json | 111 +++++ docs/collections/devops_kanban/FEAT.md | 15 + docs/collections/devops_kanban/FIX.json | 138 ++++++ docs/collections/devops_kanban/FIX.md | 18 + docs/collections/devops_kanban/INV.json | 137 ++++++ docs/collections/devops_kanban/INV.md | 34 ++ docs/collections/devops_kanban/SPIKE.json | 98 ++++ docs/collections/devops_kanban/SPIKE.md | 26 + docs/collections/devops_kanban/TASK.json | 75 +++ docs/collections/devops_kanban/TASK.md | 26 + .../collections/devops_kanban/collection.json | 82 ++++ .../collections/elves_overnight/ELVBATCH.json | 149 ++++++ docs/collections/elves_overnight/ELVBATCH.md | 18 + docs/collections/elves_overnight/ELVRPT.json | 71 +++ docs/collections/elves_overnight/ELVRPT.md | 10 + .../collections/elves_overnight/ELVSTAGE.json | 96 ++++ docs/collections/elves_overnight/ELVSTAGE.md | 18 + docs/collections/elves_overnight/LANDPR.json | 109 +++++ docs/collections/elves_overnight/LANDPR.md | 11 + .../elves_overnight/collection.json | 85 ++++ docs/collections/full/ASSESS.json | 64 +++ docs/collections/full/ASSESS.md | 14 + docs/collections/full/FEAT.json | 112 +++++ docs/collections/full/FEAT.md | 15 + docs/collections/full/FIX.json | 138 ++++++ docs/collections/full/FIX.md | 18 + docs/collections/full/INIT.json | 85 ++++ docs/collections/full/INIT.md | 18 + docs/collections/full/INV.json | 137 ++++++ docs/collections/full/INV.md | 34 ++ docs/collections/full/SPIKE.json | 98 ++++ docs/collections/full/SPIKE.md | 26 + docs/collections/full/SYNC.json | 71 +++ docs/collections/full/SYNC.md | 12 + docs/collections/full/TASK.json | 75 +++ docs/collections/full/TASK.md | 26 + docs/collections/full/collection.json | 85 ++++ docs/collections/index.json | 108 +++++ docs/collections/jr_orchestration/JRFEAT.json | 104 ++++ docs/collections/jr_orchestration/JRFEAT.md | 14 + docs/collections/jr_orchestration/JRPLAN.json | 99 ++++ docs/collections/jr_orchestration/JRPLAN.md | 16 + .../jr_orchestration/JRREBASE.json | 94 ++++ docs/collections/jr_orchestration/JRREBASE.md | 14 + docs/collections/jr_orchestration/JRREV.json | 88 ++++ docs/collections/jr_orchestration/JRREV.md | 15 + docs/collections/jr_orchestration/JRTASK.json | 114 +++++ docs/collections/jr_orchestration/JRTASK.md | 19 + .../jr_orchestration/collection.json | 88 ++++ docs/collections/operator/AGENT-SETUP.json | 72 +++ docs/collections/operator/AGENT-SETUP.md | 14 + docs/collections/operator/ASSESS.json | 64 +++ docs/collections/operator/ASSESS.md | 14 + docs/collections/operator/INIT.json | 85 ++++ docs/collections/operator/INIT.md | 18 + docs/collections/operator/PROJECT-INIT.json | 54 +++ docs/collections/operator/PROJECT-INIT.md | 8 + docs/collections/operator/SYNC.json | 71 +++ docs/collections/operator/SYNC.md | 12 + docs/collections/operator/collection.json | 60 +++ docs/collections/ralph_loop/PRD.json | 105 ++++ docs/collections/ralph_loop/PRD.md | 20 + docs/collections/ralph_loop/RLOOP.json | 95 ++++ docs/collections/ralph_loop/RLOOP.md | 11 + docs/collections/ralph_loop/STORY.json | 116 +++++ docs/collections/ralph_loop/STORY.md | 16 + docs/collections/ralph_loop/collection.json | 71 +++ docs/collections/schema.json | 77 +++ docs/collections/simple/TASK.json | 75 +++ docs/collections/simple/TASK.md | 26 + docs/collections/simple/collection.json | 29 ++ docs/configuration/index.md | 6 + docs/schemas/config.json | 20 + docs/schemas/config.md | 3 + docs/schemas/index.md | 2 + docs/schemas/openapi.json | 67 +++ shared/types.ts | 32 +- src/api/providers/kanban/mod.rs | 87 ++++ src/app/keyboard.rs | 20 +- src/app/tickets.rs | 45 ++ src/bin/generate_types.rs | 4 +- src/collections/dev_kanban/collection.json | 26 + src/collections/dev_kanban/collection.toml | 5 - src/collections/devops_kanban/collection.json | 28 ++ src/collections/devops_kanban/collection.toml | 5 - src/collections/elves_overnight/ELVBATCH.json | 149 ++++++ src/collections/elves_overnight/ELVBATCH.md | 18 + src/collections/elves_overnight/ELVRPT.json | 71 +++ src/collections/elves_overnight/ELVRPT.md | 10 + src/collections/elves_overnight/ELVSTAGE.json | 96 ++++ src/collections/elves_overnight/ELVSTAGE.md | 18 + src/collections/elves_overnight/LANDPR.json | 109 +++++ src/collections/elves_overnight/LANDPR.md | 11 + .../elves_overnight/collection.json | 27 ++ src/collections/fetch.rs | 455 ++++++++++++++++++ src/collections/full/collection.json | 23 + src/collections/full/collection.toml | 5 - src/collections/jr_orchestration/JRFEAT.json | 104 ++++ src/collections/jr_orchestration/JRFEAT.md | 14 + src/collections/jr_orchestration/JRPLAN.json | 99 ++++ src/collections/jr_orchestration/JRPLAN.md | 16 + .../jr_orchestration/JRREBASE.json | 94 ++++ src/collections/jr_orchestration/JRREBASE.md | 14 + src/collections/jr_orchestration/JRREV.json | 88 ++++ src/collections/jr_orchestration/JRREV.md | 15 + src/collections/jr_orchestration/JRTASK.json | 114 +++++ src/collections/jr_orchestration/JRTASK.md | 19 + .../jr_orchestration/collection.json | 28 ++ src/collections/manifest.rs | 287 +++++++++++ src/collections/mod.rs | 158 +++++- src/collections/operator/collection.json | 20 + src/collections/operator/collection.toml | 5 - src/collections/ralph_loop/PRD.json | 105 ++++ src/collections/ralph_loop/PRD.md | 20 + src/collections/ralph_loop/RLOOP.json | 95 ++++ src/collections/ralph_loop/RLOOP.md | 11 + src/collections/ralph_loop/STORY.json | 116 +++++ src/collections/ralph_loop/STORY.md | 16 + src/collections/ralph_loop/collection.json | 26 + src/collections/simple/collection.json | 16 + src/collections/simple/collection.toml | 5 - src/config.rs | 25 + src/docs_gen/collections_manifest.rs | 218 +++++++++ src/docs_gen/mod.rs | 2 + src/docs_gen/schema_index.rs | 10 + src/issuetypes/collection.rs | 25 + src/issuetypes/loader.rs | 124 ++++- src/issuetypes/mod.rs | 5 + src/lib.rs | 2 +- src/main.rs | 12 +- src/rest/dto/issue_types.rs | 68 +++ src/rest/openapi.rs | 5 +- src/schemas/issuetype_collection_schema.json | 77 +++ src/startup/templates.rs | 8 +- src/templates/mod.rs | 69 +++ src/ui/setup/mod.rs | 398 +++++++++------ src/ui/setup/steps/collection.rs | 153 +----- src/ui/setup/steps/hosted.rs | 60 ++- src/ui/setup/tests.rs | 163 ++++++- src/ui/setup/types.rs | 140 ++++-- 147 files changed, 8676 insertions(+), 402 deletions(-) create mode 100644 docs/collections/dev_kanban/FEAT.json create mode 100644 docs/collections/dev_kanban/FEAT.md create mode 100644 docs/collections/dev_kanban/FIX.json create mode 100644 docs/collections/dev_kanban/FIX.md create mode 100644 docs/collections/dev_kanban/TASK.json create mode 100644 docs/collections/dev_kanban/TASK.md create mode 100644 docs/collections/dev_kanban/collection.json create mode 100644 docs/collections/devops_kanban/FEAT.json create mode 100644 docs/collections/devops_kanban/FEAT.md create mode 100644 docs/collections/devops_kanban/FIX.json create mode 100644 docs/collections/devops_kanban/FIX.md create mode 100644 docs/collections/devops_kanban/INV.json create mode 100644 docs/collections/devops_kanban/INV.md create mode 100644 docs/collections/devops_kanban/SPIKE.json create mode 100644 docs/collections/devops_kanban/SPIKE.md create mode 100644 docs/collections/devops_kanban/TASK.json create mode 100644 docs/collections/devops_kanban/TASK.md create mode 100644 docs/collections/devops_kanban/collection.json create mode 100644 docs/collections/elves_overnight/ELVBATCH.json create mode 100644 docs/collections/elves_overnight/ELVBATCH.md create mode 100644 docs/collections/elves_overnight/ELVRPT.json create mode 100644 docs/collections/elves_overnight/ELVRPT.md create mode 100644 docs/collections/elves_overnight/ELVSTAGE.json create mode 100644 docs/collections/elves_overnight/ELVSTAGE.md create mode 100644 docs/collections/elves_overnight/LANDPR.json create mode 100644 docs/collections/elves_overnight/LANDPR.md create mode 100644 docs/collections/elves_overnight/collection.json create mode 100644 docs/collections/full/ASSESS.json create mode 100644 docs/collections/full/ASSESS.md create mode 100644 docs/collections/full/FEAT.json create mode 100644 docs/collections/full/FEAT.md create mode 100644 docs/collections/full/FIX.json create mode 100644 docs/collections/full/FIX.md create mode 100644 docs/collections/full/INIT.json create mode 100644 docs/collections/full/INIT.md create mode 100644 docs/collections/full/INV.json create mode 100644 docs/collections/full/INV.md create mode 100644 docs/collections/full/SPIKE.json create mode 100644 docs/collections/full/SPIKE.md create mode 100644 docs/collections/full/SYNC.json create mode 100644 docs/collections/full/SYNC.md create mode 100644 docs/collections/full/TASK.json create mode 100644 docs/collections/full/TASK.md create mode 100644 docs/collections/full/collection.json create mode 100644 docs/collections/index.json create mode 100644 docs/collections/jr_orchestration/JRFEAT.json create mode 100644 docs/collections/jr_orchestration/JRFEAT.md create mode 100644 docs/collections/jr_orchestration/JRPLAN.json create mode 100644 docs/collections/jr_orchestration/JRPLAN.md create mode 100644 docs/collections/jr_orchestration/JRREBASE.json create mode 100644 docs/collections/jr_orchestration/JRREBASE.md create mode 100644 docs/collections/jr_orchestration/JRREV.json create mode 100644 docs/collections/jr_orchestration/JRREV.md create mode 100644 docs/collections/jr_orchestration/JRTASK.json create mode 100644 docs/collections/jr_orchestration/JRTASK.md create mode 100644 docs/collections/jr_orchestration/collection.json create mode 100644 docs/collections/operator/AGENT-SETUP.json create mode 100644 docs/collections/operator/AGENT-SETUP.md create mode 100644 docs/collections/operator/ASSESS.json create mode 100644 docs/collections/operator/ASSESS.md create mode 100644 docs/collections/operator/INIT.json create mode 100644 docs/collections/operator/INIT.md create mode 100644 docs/collections/operator/PROJECT-INIT.json create mode 100644 docs/collections/operator/PROJECT-INIT.md create mode 100644 docs/collections/operator/SYNC.json create mode 100644 docs/collections/operator/SYNC.md create mode 100644 docs/collections/operator/collection.json create mode 100644 docs/collections/ralph_loop/PRD.json create mode 100644 docs/collections/ralph_loop/PRD.md create mode 100644 docs/collections/ralph_loop/RLOOP.json create mode 100644 docs/collections/ralph_loop/RLOOP.md create mode 100644 docs/collections/ralph_loop/STORY.json create mode 100644 docs/collections/ralph_loop/STORY.md create mode 100644 docs/collections/ralph_loop/collection.json create mode 100644 docs/collections/schema.json create mode 100644 docs/collections/simple/TASK.json create mode 100644 docs/collections/simple/TASK.md create mode 100644 docs/collections/simple/collection.json create mode 100644 src/collections/dev_kanban/collection.json delete mode 100644 src/collections/dev_kanban/collection.toml create mode 100644 src/collections/devops_kanban/collection.json delete mode 100644 src/collections/devops_kanban/collection.toml create mode 100644 src/collections/elves_overnight/ELVBATCH.json create mode 100644 src/collections/elves_overnight/ELVBATCH.md create mode 100644 src/collections/elves_overnight/ELVRPT.json create mode 100644 src/collections/elves_overnight/ELVRPT.md create mode 100644 src/collections/elves_overnight/ELVSTAGE.json create mode 100644 src/collections/elves_overnight/ELVSTAGE.md create mode 100644 src/collections/elves_overnight/LANDPR.json create mode 100644 src/collections/elves_overnight/LANDPR.md create mode 100644 src/collections/elves_overnight/collection.json create mode 100644 src/collections/fetch.rs create mode 100644 src/collections/full/collection.json delete mode 100644 src/collections/full/collection.toml create mode 100644 src/collections/jr_orchestration/JRFEAT.json create mode 100644 src/collections/jr_orchestration/JRFEAT.md create mode 100644 src/collections/jr_orchestration/JRPLAN.json create mode 100644 src/collections/jr_orchestration/JRPLAN.md create mode 100644 src/collections/jr_orchestration/JRREBASE.json create mode 100644 src/collections/jr_orchestration/JRREBASE.md create mode 100644 src/collections/jr_orchestration/JRREV.json create mode 100644 src/collections/jr_orchestration/JRREV.md create mode 100644 src/collections/jr_orchestration/JRTASK.json create mode 100644 src/collections/jr_orchestration/JRTASK.md create mode 100644 src/collections/jr_orchestration/collection.json create mode 100644 src/collections/manifest.rs create mode 100644 src/collections/operator/collection.json delete mode 100644 src/collections/operator/collection.toml create mode 100644 src/collections/ralph_loop/PRD.json create mode 100644 src/collections/ralph_loop/PRD.md create mode 100644 src/collections/ralph_loop/RLOOP.json create mode 100644 src/collections/ralph_loop/RLOOP.md create mode 100644 src/collections/ralph_loop/STORY.json create mode 100644 src/collections/ralph_loop/STORY.md create mode 100644 src/collections/ralph_loop/collection.json create mode 100644 src/collections/simple/collection.json delete mode 100644 src/collections/simple/collection.toml create mode 100644 src/docs_gen/collections_manifest.rs create mode 100644 src/schemas/issuetype_collection_schema.json diff --git a/docs/collections/dev_kanban/FEAT.json b/docs/collections/dev_kanban/FEAT.json new file mode 100644 index 00000000..844a7c8b --- /dev/null +++ b/docs/collections/dev_kanban/FEAT.json @@ -0,0 +1,112 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "FEAT", + "name": "Feature", + "description": "New feature or enhancement ticket", + "mode": "autonomous", + "glyph": "*", + "color": "green", + "project_required": true, + "agent_prompt": "Review this project and prepare to create an agent for implementing new features. The agent should read tickets from .tickets/, create feature branches, implement code following existing patterns, run tests and linting, and create PRs. Output ONLY the agent system prompt.", + "prompt": "You are implementing a new feature for the {{ project }} project.\n\nBefore starting, review the acceptance criteria and understand the requirements.\n\n## Acceptance Criteria\n{{ acceptance_criteria }}\n\n## Definition of Done\nFollow this definition of done before claiming success:\n{{ definition_of_done }}", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "priority", + "description": "Ticket priority level", + "type": "enum", + "required": false, + "default": "P2-medium", + "options": ["P0-critical", "P1-high", "P2-medium", "P3-low"], + "display_order": 1 + }, + { + "name": "branch", + "description": "Git branch name", + "type": "string", + "required": true, + "auto": "branch", + "display_order": 2, + "user_editable": false + }, + { + "name": "summary", + "description": "name or summary of this feature", + "type": "string", + "required": true, + "default": "", + "placeholder": "Brief description of the feature", + "max_length": 120, + "display_order": 3 + }, + { + "name": "user_story", + "description": "Why is this feature needed? User story format preferred", + "type": "text", + "required": false, + "default": "", + "placeholder": "As a USER, I want to X, so I can Y", + "display_order": 4 + } + ], + "steps": [ + { + "name": "plan", + "display_name": "Planning", + "outputs": ["plan"], + "prompt": "You are implementing a new feature. First, read the ticket and explore the codebase to understand:\n1. Where the feature should be implemented\n2. What existing code/patterns to follow\n3. What tests need to be added\n\nCreate a detailed implementation plan in `.tickets/plans/{{ id }}.md` with:\n- Files to create/modify\n- Key implementation steps\n- Test coverage requirements\n- Any dependencies or risks", + "allowed_tools": ["Read", "Glob", "Grep", "Write"], + "review_type": "plan", + "artifact_patterns": [".tickets/plans/{{ id }}.md"], + "on_reject": { + "goto_step": "plan", + "prompt": "Plan rejected: {{ rejection_reason }}\n\nRevise the plan based on feedback." + }, + "next_step": "build" + }, + { + "name": "build", + "display_name": "Building", + "outputs": ["code"], + "prompt": "Build the feature structure based on the plan in `.tickets/plans/{{ id }}.md`.\n\nIn this step, focus on:\n- Creating new files and modules\n- Setting up the basic structure\n- Adding type definitions and interfaces\n- Creating stub implementations\n\nDo not implement full logic yet - that comes in the next step.", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], + "next_step": "code" + }, + { + "name": "code", + "display_name": "Coding", + "outputs": ["code"], + "prompt": "Implement the feature logic based on the plan and structure.\n\nGuidelines:\n- Follow existing code patterns and conventions\n- Add appropriate comments for complex logic\n- Keep changes minimal and focused\n- Run formatters after making changes", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], + "next_step": "test" + }, + { + "name": "test", + "display_name": "Testing", + "outputs": ["test", "code"], + "prompt": "Add tests for the new feature and ensure all tests pass.\n\n1. Write unit tests for new functions/modules\n2. Add integration tests if applicable\n3. Run the full test suite: `cargo test`\n4. Run linting: `cargo clippy`\n5. Run formatting: `cargo fmt`\n\nFix any failures before proceeding.", + "allowed_tools": ["Read", "Write", "Edit", "Bash"], + "next_step": "deploy" + }, + { + "name": "deploy", + "display_name": "Deploying", + "outputs": ["pr"], + "prompt": "Create a pull request for the feature:\n\n1. Commit all changes with a descriptive message\n2. Push the feature branch\n3. Create a PR with:\n - Clear title: `feat({{ project }}): {{ summary }}`\n - Description of changes\n - Link to ticket: {{ id }}\n - Test instructions\n\nFeature complete. Move ticket to completed after PR is merged.", + "allowed_tools": ["Bash", "Read"], + "review_type": "pr", + "on_reject": { + "goto_step": "code", + "prompt": "Deploy rejected: {{ rejection_reason }}\n\nAddress feedback and revise the implementation." + } + } + ] +} diff --git a/docs/collections/dev_kanban/FEAT.md b/docs/collections/dev_kanban/FEAT.md new file mode 100644 index 00000000..10d13ae3 --- /dev/null +++ b/docs/collections/dev_kanban/FEAT.md @@ -0,0 +1,15 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +branch: {{ branch }} +{{#if priority }}priority: {{ priority }} +{{/if}}--- + +# Feature: {{ summary }} + +{{#if context }} +## Context +{{ context }} +{{/if}} diff --git a/docs/collections/dev_kanban/FIX.json b/docs/collections/dev_kanban/FIX.json new file mode 100644 index 00000000..39320e99 --- /dev/null +++ b/docs/collections/dev_kanban/FIX.json @@ -0,0 +1,138 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "FIX", + "name": "Fix", + "description": "Bug fix, follow-up work, tech debt, or refactoring ticket", + "mode": "autonomous", + "glyph": "#", + "color": "magenta", + "project_required": true, + "agent_prompt": "Review this project and prepare to create an agent for bug fixes. The agent should read tickets from .tickets/, reproduce issues, implement minimal fixes, verify with tests, and create PRs. Output ONLY the agent system prompt.", + "prompt": "You are fixing a bug or addressing technical debt in the {{ project }} project.\n\nBefore starting, review the acceptance criteria and understand the root cause.\n\n## Acceptance Criteria\n{{ acceptance_criteria }}\n\n## Definition of Done\nFollow this definition of done before claiming success:\n{{ definition_of_done }}", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "priority", + "description": "Ticket priority level", + "type": "enum", + "required": false, + "default": "P2-medium", + "options": ["P0-critical", "P1-high", "P2-medium", "P3-low"], + "display_order": 1 + }, + { + "name": "severity", + "description": "Bug severity (if applicable)", + "type": "enum", + "required": false, + "default": "N/A", + "options": ["S0-outage", "S1-major", "S2-minor", "S3-cosmetic", "N/A"], + "display_order": 2 + }, + { + "name": "branch", + "description": "Git branch name", + "type": "string", + "required": true, + "auto": "branch", + "display_order": 3, + "user_editable": false + }, + { + "name": "fix_type", + "description": "Type of fix work", + "type": "enum", + "required": false, + "default": "Bug fix", + "options": ["Bug fix", "Follow-up work", "Technical debt", "Refactoring", "Test coverage", "Documentation"], + "display_order": 4 + }, + { + "name": "summary", + "description": "name or summary of this bugfix", + "type": "string", + "required": true, + "default": "", + "placeholder": "Brief description of what needs to be fixed", + "max_length": 120, + "display_order": 5 + }, + { + "name": "parent", + "description": "Parent / Epic ticket ID", + "type": "string", + "required": false, + "default": "", + "placeholder": "Parent ticket ID if applicable", + "display_order": 6 + }, + { + "name": "user_story", + "description": "Context for the fix (steps to reproduce, background)", + "type": "text", + "required": false, + "default": "", + "placeholder": "Steps to reproduce bug, or context for tech debt", + "display_order": 7 + } + ], + "steps": [ + { + "name": "plan", + "display_name": "Planning", + "outputs": ["plan"], + "prompt": "Assess the project and plan reproduction:\n\n1. Understand the project structure\n2. Find how to run the project and tests\n3. Locate applicable test code\n4. Create a plan to reproduce the bug\n\nWrite plan to `.tickets/plans/{{ id }}.md` with:\n- How to run the project\n- Relevant test commands\n- Steps to reproduce the issue\n- Initial hypotheses about the root cause", + "allowed_tools": ["Read", "Glob", "Grep", "Write", "Bash"], + "review_type": "plan", + "on_reject": { + "goto_step": "plan", + "prompt": "Plan rejected: {{ rejection_reason }}\n\nRevise the reproduction plan." + }, + "next_step": "reproduce" + }, + { + "name": "reproduce", + "display_name": "Reproducing", + "outputs": ["test", "report"], + "prompt": "Reproduce the bug:\n\n1. Follow the plan to reproduce the issue\n2. Write a failing test that demonstrates the bug\n3. Document the reproduction in `.tickets/notes/{{ id }}.md`\n4. Confirm the test fails as expected", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], + "next_step": "test" + }, + { + "name": "test", + "display_name": "Baseline Testing", + "outputs": ["report"], + "prompt": "Establish test baseline:\n\n1. Run full test suite to understand current state\n2. Document which tests pass/fail\n3. Identify any related test coverage gaps\n4. Note any flaky or slow tests", + "allowed_tools": ["Read", "Bash"], + "next_step": "fix" + }, + { + "name": "fix", + "display_name": "Fixing", + "outputs": ["code"], + "prompt": "Implement the fix:\n\n1. Apply minimal fix to address the root cause\n2. Verify the previously failing test now passes\n3. Run full test suite: `cargo test`\n4. Run linting: `cargo clippy`\n5. Run formatting: `cargo fmt`\n\nKeep the fix focused and minimal.", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], + "next_step": "deploy" + }, + { + "name": "deploy", + "display_name": "Deploying", + "outputs": ["review"], + "prompt": "Create a pull request for the fix:\n\n1. Commit all changes with message: `fix({{ project }}): {{ summary }}`\n2. Push the fix branch\n3. Create a PR with:\n - Root cause analysis\n - Description of the fix\n - Test verification\n - Link to ticket: {{ id }}\n\nFix complete. Move ticket to completed after PR is merged.", + "allowed_tools": ["Bash", "Read"], + "review_type": "plan", + "on_reject": { + "goto_step": "fix", + "prompt": "Deploy rejected: {{ rejection_reason }}\n\nAddress feedback and revise the fix." + } + } + ] +} diff --git a/docs/collections/dev_kanban/FIX.md b/docs/collections/dev_kanban/FIX.md new file mode 100644 index 00000000..e23158ff --- /dev/null +++ b/docs/collections/dev_kanban/FIX.md @@ -0,0 +1,18 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +branch: {{ branch }} +{{#if priority }}priority: {{ priority }} +{{/if}}{{#if severity }}severity: {{ severity }} +{{/if}}{{#if fix_type }}fix_type: {{ fix_type }} +{{/if}}{{#if parent }}parent: {{ parent }} +{{/if}}--- + +# Fix: {{ summary }} + +{{#if context }} +## Context +{{ context }} +{{/if}} diff --git a/docs/collections/dev_kanban/TASK.json b/docs/collections/dev_kanban/TASK.json new file mode 100644 index 00000000..04b95bd2 --- /dev/null +++ b/docs/collections/dev_kanban/TASK.json @@ -0,0 +1,75 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "TASK", + "name": "Task", + "description": "Focused task that executes one specific thing", + "mode": "autonomous", + "glyph": ">", + "color": "cyan", + "project_required": false, + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "priority", + "description": "Ticket priority level", + "type": "enum", + "required": false, + "default": "P2-medium", + "options": ["P0-critical", "P1-high", "P2-medium", "P3-low"], + "display_order": 1 + }, + { + "name": "summary", + "description": "One-line description of the task", + "type": "string", + "required": true, + "default": "", + "placeholder": "Brief description of the task", + "max_length": 120, + "display_order": 2 + }, + { + "name": "description", + "description": "Detailed description of the task", + "type": "text", + "required": true, + "default": "", + "placeholder": "Full description of what needs to be done", + "display_order": 3 + }, + { + "name": "points", + "description": "Story points estimate", + "type": "integer", + "required": false, + "default": "0", + "display_order": 4 + }, + { + "name": "user_story", + "description": "User story or background context", + "type": "text", + "required": false, + "default": "", + "placeholder": "As a USER, I want to X, so I can Y", + "display_order": 5 + } + ], + "steps": [ + { + "name": "execute", + "display_name": "Executing", + "outputs": ["code", "report"], + "prompt": "Execute this task:\n\n1. Read the ticket summary and context\n2. Explore the codebase as needed\n3. Complete the task as specified\n4. Document what was done\n\nThis is a focused task - complete it directly without creating follow-up tickets or plans.", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"] + } + ] +} diff --git a/docs/collections/dev_kanban/TASK.md b/docs/collections/dev_kanban/TASK.md new file mode 100644 index 00000000..b764c240 --- /dev/null +++ b/docs/collections/dev_kanban/TASK.md @@ -0,0 +1,26 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +{{#if priority }}priority: {{ priority }} +{{/if}}{{#if points }}points: {{ points }} +{{/if}}--- + +# Task: {{ summary }} + +## Description +{{ description }} + +{{#if user_story }} +## User Story +{{ user_story }} +{{/if}} + +{{#if acceptance_criteria }} +## Acceptance Criteria +{{ acceptance_criteria }} +{{/if}} + +## Plan +*Plan will be written to `.tickets/plans/{{ id }}.md`* diff --git a/docs/collections/dev_kanban/collection.json b/docs/collections/dev_kanban/collection.json new file mode 100644 index 00000000..76d13a50 --- /dev/null +++ b/docs/collections/dev_kanban/collection.json @@ -0,0 +1,62 @@ +{ + "schema_version": 1, + "id": "dev_kanban", + "name": "Dev Kanban", + "description": "Developer kanban with TASK, FEAT, FIX", + "version": "1.0.0", + "publisher": "untra", + "author": "Operator!", + "url": "https://github.com/untra/operator", + "license": "MIT", + "tags": [ + "builtin", + "kanban", + "dev" + ], + "compatibility": null, + "issue_types": [ + { + "key": "TASK", + "schema_path": "TASK.json", + "schema_checksum": "f654229454da050ca038c273cc74884c2dbe68f09b293eee3764f23d6771b939", + "template_path": "TASK.md", + "template_checksum": "7a71bf692588af1d1ac5d9192edd64316353ada616e22019dfff7683ab5d09c4" + }, + { + "key": "FEAT", + "schema_path": "FEAT.json", + "schema_checksum": "42a04e3c8c821e69f334e92b94d57e5aa18e08bc064fe82f53839284b834e520", + "template_path": "FEAT.md", + "template_checksum": "fbc7e17641d8e796dad14c09fe20ffcbbdbdb8f58199b0d29d9e9729b51b4d5a" + }, + { + "key": "FIX", + "schema_path": "FIX.json", + "schema_checksum": "4deafa08b2dcf1c94462d7079574be05d2efd6ff0abb6036b8fde955dca0e0a1", + "template_path": "FIX.md", + "template_checksum": "5237e1faf9b2c49d80fc9efff5a0d3e184f86c4fa5a34ff4dfed26ee883856bf" + } + ], + "workflow_hints": { + "loop_kind": "single_pass", + "memory_surfaces": [ + "ticket" + ], + "review_gates": [ + "test_suite" + ], + "external_tools": [ + "git" + ], + "stop_conditions": [ + "tests_green" + ], + "runner_semantics": "prompt_driven" + }, + "default_selected": [ + "TASK", + "FEAT", + "FIX" + ], + "checksum": "21b4b7f7b419660f776f8f162ad6f8a1049d623a7438eaf15e759cc4c278cfe4" +} diff --git a/docs/collections/devops_kanban/FEAT.json b/docs/collections/devops_kanban/FEAT.json new file mode 100644 index 00000000..4fcfaafa --- /dev/null +++ b/docs/collections/devops_kanban/FEAT.json @@ -0,0 +1,111 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "FEAT", + "name": "Feature", + "description": "New feature or enhancement ticket", + "mode": "autonomous", + "glyph": "*", + "color": "green", + "project_required": true, + "agent_prompt": "Review this project and prepare to create an agent for implementing new features. The agent should read tickets from .tickets/, create feature branches, implement code following existing patterns, run tests and linting, and create PRs. Output ONLY the agent system prompt.", + "prompt": "You are implementing a new feature for the {{ project }} project.\n\nBefore starting, review the acceptance criteria and understand the requirements.\n\n## Acceptance Criteria\n{{ acceptance_criteria }}\n\n## Definition of Done\nFollow this definition of done before claiming success:\n{{ definition_of_done }}", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "priority", + "description": "Ticket priority level", + "type": "enum", + "required": false, + "default": "P2-medium", + "options": ["P0-critical", "P1-high", "P2-medium", "P3-low"], + "display_order": 1 + }, + { + "name": "branch", + "description": "Git branch name", + "type": "string", + "required": true, + "auto": "branch", + "display_order": 2, + "user_editable": false + }, + { + "name": "summary", + "description": "name or summary of this feature", + "type": "string", + "required": true, + "default": "", + "placeholder": "Brief description of the feature", + "max_length": 120, + "display_order": 3 + }, + { + "name": "user_story", + "description": "Why is this feature needed? User story format preferred", + "type": "text", + "required": false, + "default": "", + "placeholder": "As a USER, I want to X, so I can Y", + "display_order": 4 + } + ], + "steps": [ + { + "name": "plan", + "display_name": "Planning", + "outputs": ["plan"], + "prompt": "You are implementing a new feature. First, read the ticket and explore the codebase to understand:\n1. Where the feature should be implemented\n2. What existing code/patterns to follow\n3. What tests need to be added\n\nCreate a detailed implementation plan in `.tickets/plans/{{ id }}.md` with:\n- Files to create/modify\n- Key implementation steps\n- Test coverage requirements\n- Any dependencies or risks", + "allowed_tools": ["Read", "Glob", "Grep", "Write"], + "review_type": "plan", + "on_reject": { + "goto_step": "plan", + "prompt": "Plan rejected: {{ rejection_reason }}\n\nRevise the plan based on feedback." + }, + "next_step": "build" + }, + { + "name": "build", + "display_name": "Building", + "outputs": ["code"], + "prompt": "Build the feature structure based on the plan in `.tickets/plans/{{ id }}.md`.\n\nIn this step, focus on:\n- Creating new files and modules\n- Setting up the basic structure\n- Adding type definitions and interfaces\n- Creating stub implementations\n\nDo not implement full logic yet - that comes in the next step.", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], + "next_step": "code" + }, + { + "name": "code", + "display_name": "Coding", + "outputs": ["code"], + "prompt": "Implement the feature logic based on the plan and structure.\n\nGuidelines:\n- Follow existing code patterns and conventions\n- Add appropriate comments for complex logic\n- Keep changes minimal and focused\n- Run formatters after making changes", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], + "next_step": "test" + }, + { + "name": "test", + "display_name": "Testing", + "outputs": ["test", "code"], + "prompt": "Add tests for the new feature and ensure all tests pass.\n\n1. Write unit tests for new functions/modules\n2. Add integration tests if applicable\n3. Run the full test suite: `cargo test`\n4. Run linting: `cargo clippy`\n5. Run formatting: `cargo fmt`\n\nFix any failures before proceeding.", + "allowed_tools": ["Read", "Write", "Edit", "Bash"], + "next_step": "deploy" + }, + { + "name": "deploy", + "display_name": "Deploying", + "outputs": ["pr"], + "prompt": "Create a pull request for the feature:\n\n1. Commit all changes with a descriptive message\n2. Push the feature branch\n3. Create a PR with:\n - Clear title: `feat({{ project }}): {{ summary }}`\n - Description of changes\n - Link to ticket: {{ id }}\n - Test instructions\n\nFeature complete. Move ticket to completed after PR is merged.", + "allowed_tools": ["Bash", "Read"], + "review_type": "pr", + "on_reject": { + "goto_step": "code", + "prompt": "Deploy rejected: {{ rejection_reason }}\n\nAddress feedback and revise the implementation." + } + } + ] +} diff --git a/docs/collections/devops_kanban/FEAT.md b/docs/collections/devops_kanban/FEAT.md new file mode 100644 index 00000000..10d13ae3 --- /dev/null +++ b/docs/collections/devops_kanban/FEAT.md @@ -0,0 +1,15 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +branch: {{ branch }} +{{#if priority }}priority: {{ priority }} +{{/if}}--- + +# Feature: {{ summary }} + +{{#if context }} +## Context +{{ context }} +{{/if}} diff --git a/docs/collections/devops_kanban/FIX.json b/docs/collections/devops_kanban/FIX.json new file mode 100644 index 00000000..39320e99 --- /dev/null +++ b/docs/collections/devops_kanban/FIX.json @@ -0,0 +1,138 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "FIX", + "name": "Fix", + "description": "Bug fix, follow-up work, tech debt, or refactoring ticket", + "mode": "autonomous", + "glyph": "#", + "color": "magenta", + "project_required": true, + "agent_prompt": "Review this project and prepare to create an agent for bug fixes. The agent should read tickets from .tickets/, reproduce issues, implement minimal fixes, verify with tests, and create PRs. Output ONLY the agent system prompt.", + "prompt": "You are fixing a bug or addressing technical debt in the {{ project }} project.\n\nBefore starting, review the acceptance criteria and understand the root cause.\n\n## Acceptance Criteria\n{{ acceptance_criteria }}\n\n## Definition of Done\nFollow this definition of done before claiming success:\n{{ definition_of_done }}", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "priority", + "description": "Ticket priority level", + "type": "enum", + "required": false, + "default": "P2-medium", + "options": ["P0-critical", "P1-high", "P2-medium", "P3-low"], + "display_order": 1 + }, + { + "name": "severity", + "description": "Bug severity (if applicable)", + "type": "enum", + "required": false, + "default": "N/A", + "options": ["S0-outage", "S1-major", "S2-minor", "S3-cosmetic", "N/A"], + "display_order": 2 + }, + { + "name": "branch", + "description": "Git branch name", + "type": "string", + "required": true, + "auto": "branch", + "display_order": 3, + "user_editable": false + }, + { + "name": "fix_type", + "description": "Type of fix work", + "type": "enum", + "required": false, + "default": "Bug fix", + "options": ["Bug fix", "Follow-up work", "Technical debt", "Refactoring", "Test coverage", "Documentation"], + "display_order": 4 + }, + { + "name": "summary", + "description": "name or summary of this bugfix", + "type": "string", + "required": true, + "default": "", + "placeholder": "Brief description of what needs to be fixed", + "max_length": 120, + "display_order": 5 + }, + { + "name": "parent", + "description": "Parent / Epic ticket ID", + "type": "string", + "required": false, + "default": "", + "placeholder": "Parent ticket ID if applicable", + "display_order": 6 + }, + { + "name": "user_story", + "description": "Context for the fix (steps to reproduce, background)", + "type": "text", + "required": false, + "default": "", + "placeholder": "Steps to reproduce bug, or context for tech debt", + "display_order": 7 + } + ], + "steps": [ + { + "name": "plan", + "display_name": "Planning", + "outputs": ["plan"], + "prompt": "Assess the project and plan reproduction:\n\n1. Understand the project structure\n2. Find how to run the project and tests\n3. Locate applicable test code\n4. Create a plan to reproduce the bug\n\nWrite plan to `.tickets/plans/{{ id }}.md` with:\n- How to run the project\n- Relevant test commands\n- Steps to reproduce the issue\n- Initial hypotheses about the root cause", + "allowed_tools": ["Read", "Glob", "Grep", "Write", "Bash"], + "review_type": "plan", + "on_reject": { + "goto_step": "plan", + "prompt": "Plan rejected: {{ rejection_reason }}\n\nRevise the reproduction plan." + }, + "next_step": "reproduce" + }, + { + "name": "reproduce", + "display_name": "Reproducing", + "outputs": ["test", "report"], + "prompt": "Reproduce the bug:\n\n1. Follow the plan to reproduce the issue\n2. Write a failing test that demonstrates the bug\n3. Document the reproduction in `.tickets/notes/{{ id }}.md`\n4. Confirm the test fails as expected", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], + "next_step": "test" + }, + { + "name": "test", + "display_name": "Baseline Testing", + "outputs": ["report"], + "prompt": "Establish test baseline:\n\n1. Run full test suite to understand current state\n2. Document which tests pass/fail\n3. Identify any related test coverage gaps\n4. Note any flaky or slow tests", + "allowed_tools": ["Read", "Bash"], + "next_step": "fix" + }, + { + "name": "fix", + "display_name": "Fixing", + "outputs": ["code"], + "prompt": "Implement the fix:\n\n1. Apply minimal fix to address the root cause\n2. Verify the previously failing test now passes\n3. Run full test suite: `cargo test`\n4. Run linting: `cargo clippy`\n5. Run formatting: `cargo fmt`\n\nKeep the fix focused and minimal.", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], + "next_step": "deploy" + }, + { + "name": "deploy", + "display_name": "Deploying", + "outputs": ["review"], + "prompt": "Create a pull request for the fix:\n\n1. Commit all changes with message: `fix({{ project }}): {{ summary }}`\n2. Push the fix branch\n3. Create a PR with:\n - Root cause analysis\n - Description of the fix\n - Test verification\n - Link to ticket: {{ id }}\n\nFix complete. Move ticket to completed after PR is merged.", + "allowed_tools": ["Bash", "Read"], + "review_type": "plan", + "on_reject": { + "goto_step": "fix", + "prompt": "Deploy rejected: {{ rejection_reason }}\n\nAddress feedback and revise the fix." + } + } + ] +} diff --git a/docs/collections/devops_kanban/FIX.md b/docs/collections/devops_kanban/FIX.md new file mode 100644 index 00000000..e23158ff --- /dev/null +++ b/docs/collections/devops_kanban/FIX.md @@ -0,0 +1,18 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +branch: {{ branch }} +{{#if priority }}priority: {{ priority }} +{{/if}}{{#if severity }}severity: {{ severity }} +{{/if}}{{#if fix_type }}fix_type: {{ fix_type }} +{{/if}}{{#if parent }}parent: {{ parent }} +{{/if}}--- + +# Fix: {{ summary }} + +{{#if context }} +## Context +{{ context }} +{{/if}} diff --git a/docs/collections/devops_kanban/INV.json b/docs/collections/devops_kanban/INV.json new file mode 100644 index 00000000..480e502b --- /dev/null +++ b/docs/collections/devops_kanban/INV.json @@ -0,0 +1,137 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "INV", + "name": "Investigation", + "description": "Incident investigation ticket (paired mode)", + "mode": "paired", + "glyph": "!", + "color": "yellow", + "project_required": false, + "agent_prompt": "Review this project and prepare to create an agent for incident investigations. The agent triages issues, investigates root causes, coordinates remediation, and documents postmortems. It works in paired mode with the operator. Output ONLY the agent system prompt.", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "scope", + "description": "Affected scope/project", + "type": "enum", + "required": false, + "default": "global", + "options": ["global", "adminsvc", "apisvc", "gamesvc", "g", "hushsvc", "uzersvc", "outboundsvc", "www", "iac", "proto", "e2e", "operator"], + "display_order": 1 + }, + { + "name": "severity", + "description": "Incident severity", + "type": "enum", + "required": false, + "default": "S1-major", + "options": ["S0-outage", "S1-major", "S2-minor"], + "display_order": 2 + }, + { + "name": "source", + "description": "How the incident was detected", + "type": "enum", + "required": false, + "default": "monitoring", + "options": ["alert", "user-report", "monitoring", "deploy-failure", "test-failure"], + "display_order": 3 + }, + { + "name": "summary", + "description": "named summary of the failure investigation", + "type": "string", + "required": true, + "default": "", + "placeholder": "Brief description of the observed failure", + "max_length": 120, + "display_order": 4 + }, + { + "name": "observed_behavior", + "description": "What is happening? Include error messages, logs", + "type": "text", + "required": false, + "default": "", + "placeholder": "Error messages, log excerpts, metrics", + "display_order": 5 + }, + { + "name": "expected_behavior", + "description": "What should be happening instead?", + "type": "text", + "required": false, + "default": "", + "placeholder": "Expected behavior under normal conditions", + "display_order": 6 + }, + { + "name": "impact", + "description": "Users/services affected", + "type": "string", + "required": false, + "default": "", + "placeholder": "Affected users, services, or systems", + "display_order": 7 + } + ], + "steps": [ + { + "name": "triage", + "display_name": "Triage", + "outputs": ["report"], + "prompt": "Triage the incident:\n\n1. Confirm the issue is real and ongoing\n2. Assess severity and impact\n3. Identify affected systems\n4. Begin documenting in `.tickets/incidents/{{ id }}.md`\n\nWork with the operator to gather initial information.", + "allowed_tools": ["Read", "Glob", "Grep", "Bash", "Write"], + "next_step": "investigate" + }, + { + "name": "investigate", + "display_name": "Investigating", + "outputs": ["report"], + "prompt": "Investigate the root cause:\n\n1. Search logs and metrics for anomalies\n2. Trace the error path through the code\n3. Identify recent changes that might be related\n4. Form hypotheses and test them\n5. Update `.tickets/incidents/{{ id }}.md` with findings", + "allowed_tools": ["Read", "Glob", "Grep", "Bash", "Write"], + "next_step": "remediate" + }, + { + "name": "remediate", + "display_name": "Remediating", + "outputs": ["code", "ticket"], + "prompt": "Determine remediation:\n\n1. If a quick fix is possible, implement it\n2. If a hotfix is needed, create a FIX ticket\n3. If a rollback is needed, coordinate with the operator\n4. Document the resolution in the incident report", + "allowed_tools": ["Read", "Write", "Edit", "Bash"], + "review_type": "plan", + "on_reject": { + "goto_step": "investigate", + "prompt": "More investigation needed. Continue searching for the root cause." + }, + "next_step": "postmortem" + }, + { + "name": "postmortem", + "display_name": "Postmortem", + "outputs": ["report", "ticket"], + "prompt": "Complete the postmortem:\n\n1. Finalize the incident report with:\n - Timeline of events\n - Root cause analysis\n - Remediation taken\n - Lessons learned\n2. Create follow-up tickets for preventive measures", + "allowed_tools": ["Read", "Write", "Bash"], + "review_type": "plan", + "on_reject": { + "goto_step": "postmortem", + "prompt": "Postmortem needs more detail. Expand the analysis." + }, + "next_step": "done" + }, + { + "name": "done", + "display_name": "Complete", + "outputs": ["review"], + "prompt": "Investigation complete. Move ticket to completed.", + "allowed_tools": ["Bash"] + } + ] +} diff --git a/docs/collections/devops_kanban/INV.md b/docs/collections/devops_kanban/INV.md new file mode 100644 index 00000000..b03380c9 --- /dev/null +++ b/docs/collections/devops_kanban/INV.md @@ -0,0 +1,34 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}{{#if scope }}scope: {{ scope }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +{{#if severity }}severity: {{ severity }} +{{/if}}{{#if source }}source: {{ source }} +{{/if}}--- + +# Investigation: {{ summary }} + +{{#if observed_behavior }} +## Observed Behavior +{{ observed_behavior }} +{{/if}} + +{{#if expected_behavior }} +## Expected Behavior +{{ expected_behavior }} +{{/if}} + +{{#if impact }} +## Impact +{{ impact }} +{{/if}} + +## Timeline +| Time | Event | +|------|-------| +| {{ created_datetime }} | Investigation opened | + +## Findings + diff --git a/docs/collections/devops_kanban/SPIKE.json b/docs/collections/devops_kanban/SPIKE.json new file mode 100644 index 00000000..a4577402 --- /dev/null +++ b/docs/collections/devops_kanban/SPIKE.json @@ -0,0 +1,98 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "SPIKE", + "name": "Spike", + "description": "Research or exploration ticket (paired mode)", + "mode": "paired", + "glyph": "?", + "color": "blue", + "project_required": false, + "agent_prompt": "Review this project and prepare to create an agent for research spikes. The agent works in paired mode, explores the codebase, researches external docs, and documents findings. It should ask questions when uncertain. Output ONLY the agent system prompt.", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "scope", + "description": "Scope of the spike", + "type": "enum", + "required": false, + "default": "global", + "options": ["global", "adminsvc", "apisvc", "gamesvc", "g", "hushsvc", "uzersvc", "outboundsvc", "www", "iac", "proto", "e2e", "operator"], + "display_order": 1 + }, + { + "name": "priority", + "description": "Ticket priority level", + "type": "enum", + "required": false, + "default": "P2-medium", + "options": ["P0-critical", "P1-high", "P2-medium", "P3-low"], + "display_order": 2 + }, + { + "name": "summary", + "description": "Research question or topic", + "type": "string", + "required": true, + "default": "", + "placeholder": "What needs to be researched or explored?", + "max_length": 120, + "display_order": 3 + }, + { + "name": "user_story", + "description": "Why is this spike needed? What decision does it inform?", + "type": "text", + "required": false, + "default": "", + "placeholder": "Background context and what decisions depend on this research", + "display_order": 4 + }, + { + "name": "success_criteria", + "description": "How will we know the spike is complete?", + "type": "text", + "required": false, + "default": "", + "placeholder": "Questions to answer, deliverables expected", + "display_order": 5 + } + ], + "steps": [ + { + "name": "explore", + "display_name": "Exploration", + "outputs": ["report"], + "prompt": "This is a research spike. Work with the operator to explore:\n\n1. Read and understand the research question\n2. Search the codebase for relevant patterns\n3. Research external documentation if needed\n4. Discuss findings with the operator\n\nDocument discoveries in `.tickets/spikes/{{ id }}.md`", + "allowed_tools": ["Read", "Glob", "Grep", "WebFetch", "WebSearch", "Write"], + "next_step": "summarize" + }, + { + "name": "summarize", + "display_name": "Summarizing", + "outputs": ["report", "ticket"], + "prompt": "Summarize findings from the spike:\n\n1. Update `.tickets/spikes/{{ id }}.md` with:\n - Key findings\n - Recommendations\n - Trade-offs identified\n - Open questions\n2. If follow-up work is identified, create ticket(s)", + "allowed_tools": ["Read", "Write", "Bash"], + "review_type": "plan", + "on_reject": { + "goto_step": "explore", + "prompt": "More research is needed. Continue exploring with the operator." + }, + "next_step": "done" + }, + { + "name": "done", + "display_name": "Complete", + "outputs": ["review"], + "prompt": "Spike complete. Move ticket to completed.", + "allowed_tools": ["Bash"] + } + ] +} diff --git a/docs/collections/devops_kanban/SPIKE.md b/docs/collections/devops_kanban/SPIKE.md new file mode 100644 index 00000000..bfe6f136 --- /dev/null +++ b/docs/collections/devops_kanban/SPIKE.md @@ -0,0 +1,26 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}{{#if scope }}scope: {{ scope }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +{{#if priority }}priority: {{ priority }} +{{/if}}--- + +# Spike: {{ summary }} + +{{#if context }} +## Context +{{ context }} +{{/if}} + +{{#if success_criteria }} +## Success Criteria +{{ success_criteria }} +{{/if}} + +## Conversation Log +### Session: {{ created_date }} + +## Findings + diff --git a/docs/collections/devops_kanban/TASK.json b/docs/collections/devops_kanban/TASK.json new file mode 100644 index 00000000..04b95bd2 --- /dev/null +++ b/docs/collections/devops_kanban/TASK.json @@ -0,0 +1,75 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "TASK", + "name": "Task", + "description": "Focused task that executes one specific thing", + "mode": "autonomous", + "glyph": ">", + "color": "cyan", + "project_required": false, + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "priority", + "description": "Ticket priority level", + "type": "enum", + "required": false, + "default": "P2-medium", + "options": ["P0-critical", "P1-high", "P2-medium", "P3-low"], + "display_order": 1 + }, + { + "name": "summary", + "description": "One-line description of the task", + "type": "string", + "required": true, + "default": "", + "placeholder": "Brief description of the task", + "max_length": 120, + "display_order": 2 + }, + { + "name": "description", + "description": "Detailed description of the task", + "type": "text", + "required": true, + "default": "", + "placeholder": "Full description of what needs to be done", + "display_order": 3 + }, + { + "name": "points", + "description": "Story points estimate", + "type": "integer", + "required": false, + "default": "0", + "display_order": 4 + }, + { + "name": "user_story", + "description": "User story or background context", + "type": "text", + "required": false, + "default": "", + "placeholder": "As a USER, I want to X, so I can Y", + "display_order": 5 + } + ], + "steps": [ + { + "name": "execute", + "display_name": "Executing", + "outputs": ["code", "report"], + "prompt": "Execute this task:\n\n1. Read the ticket summary and context\n2. Explore the codebase as needed\n3. Complete the task as specified\n4. Document what was done\n\nThis is a focused task - complete it directly without creating follow-up tickets or plans.", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"] + } + ] +} diff --git a/docs/collections/devops_kanban/TASK.md b/docs/collections/devops_kanban/TASK.md new file mode 100644 index 00000000..b764c240 --- /dev/null +++ b/docs/collections/devops_kanban/TASK.md @@ -0,0 +1,26 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +{{#if priority }}priority: {{ priority }} +{{/if}}{{#if points }}points: {{ points }} +{{/if}}--- + +# Task: {{ summary }} + +## Description +{{ description }} + +{{#if user_story }} +## User Story +{{ user_story }} +{{/if}} + +{{#if acceptance_criteria }} +## Acceptance Criteria +{{ acceptance_criteria }} +{{/if}} + +## Plan +*Plan will be written to `.tickets/plans/{{ id }}.md`* diff --git a/docs/collections/devops_kanban/collection.json b/docs/collections/devops_kanban/collection.json new file mode 100644 index 00000000..3866784d --- /dev/null +++ b/docs/collections/devops_kanban/collection.json @@ -0,0 +1,82 @@ +{ + "schema_version": 1, + "id": "devops_kanban", + "name": "DevOps Kanban", + "description": "DevOps kanban with TASK, FEAT, FIX, SPIKE, INV", + "version": "1.0.0", + "publisher": "untra", + "author": "Operator!", + "url": "https://github.com/untra/operator", + "license": "MIT", + "tags": [ + "builtin", + "kanban", + "devops" + ], + "compatibility": null, + "issue_types": [ + { + "key": "TASK", + "schema_path": "TASK.json", + "schema_checksum": "f654229454da050ca038c273cc74884c2dbe68f09b293eee3764f23d6771b939", + "template_path": "TASK.md", + "template_checksum": "7a71bf692588af1d1ac5d9192edd64316353ada616e22019dfff7683ab5d09c4" + }, + { + "key": "FEAT", + "schema_path": "FEAT.json", + "schema_checksum": "2e8405834ef2a8a62966c70357cbe23f5557f446009e8d434d9fae4efc8f626a", + "template_path": "FEAT.md", + "template_checksum": "fbc7e17641d8e796dad14c09fe20ffcbbdbdb8f58199b0d29d9e9729b51b4d5a" + }, + { + "key": "FIX", + "schema_path": "FIX.json", + "schema_checksum": "4deafa08b2dcf1c94462d7079574be05d2efd6ff0abb6036b8fde955dca0e0a1", + "template_path": "FIX.md", + "template_checksum": "5237e1faf9b2c49d80fc9efff5a0d3e184f86c4fa5a34ff4dfed26ee883856bf" + }, + { + "key": "SPIKE", + "schema_path": "SPIKE.json", + "schema_checksum": "1f69f05190fdff5545371c042c388e276accbbb50179ce65365f17dac042c0b5", + "template_path": "SPIKE.md", + "template_checksum": "030d37b1b4b3b8a26db62bd590a61fb5b9e01d5a39be6ab771bd56e31179f2c4" + }, + { + "key": "INV", + "schema_path": "INV.json", + "schema_checksum": "42129e41c6c735adb47061a7d99478a1e4ed9ca5ca9f42b3c300c4a833f9265b", + "template_path": "INV.md", + "template_checksum": "65ef45c01d30b93f9d81b830896f20fe4eb9645e82d8e16ea1bd77ac4f359050" + } + ], + "workflow_hints": { + "loop_kind": "review_loop", + "memory_surfaces": [ + "ticket", + "scratchpad" + ], + "review_gates": [ + "human", + "test_suite" + ], + "external_tools": [ + "git", + "ci" + ], + "stop_conditions": [ + "tests_green", + "review_approved" + ], + "runner_semantics": "prompt_driven" + }, + "default_selected": [ + "TASK", + "FEAT", + "FIX", + "SPIKE", + "INV" + ], + "checksum": "4daee86eaf261d0e069f4dcc7236a4ea581485a72ffc409d54c9dc372470a12a" +} diff --git a/docs/collections/elves_overnight/ELVBATCH.json b/docs/collections/elves_overnight/ELVBATCH.json new file mode 100644 index 00000000..e4185cfd --- /dev/null +++ b/docs/collections/elves_overnight/ELVBATCH.json @@ -0,0 +1,149 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "ELVBATCH", + "name": "Elves Batch", + "description": "Execute one independently shippable Elves batch with validation, review, and checkpointing.", + "mode": "autonomous", + "glyph": "B", + "color": "green", + "project_required": true, + "agent": "elves-coder", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "summary", + "description": "Batch summary", + "type": "string", + "required": true, + "default": "", + "placeholder": "One batch from survival guide", + "max_length": 120, + "display_order": 1 + }, + { + "name": "batch_goal", + "description": "Exact goal for this batch", + "type": "text", + "required": true, + "default": "", + "display_order": 2 + }, + { + "name": "validation_commands", + "description": "Commands required for this batch", + "type": "text", + "required": false, + "default": "", + "display_order": 3 + }, + { + "name": "continue_after_batch", + "description": "Whether the agent may suggest another batch after this one", + "type": "bool", + "required": false, + "default": "true", + "display_order": 4 + } + ], + "steps": [ + { + "name": "reread", + "display_name": "Reread Memory", + "outputs": ["plan"], + "prompt": "Start the Elves batch by rereading durable memory.\n\nRead `docs/elves/survival-guide.md`, `docs/elves/execution-log.md`, `docs/elves/learnings.md`, `.elves-session.json`, active PR state if present, and the batch goal. Write a short batch plan to `docs/elves/batches/{{ id }}.md`.", + "allowed_tools": ["Read", "Glob", "Grep", "Write", "Bash"], + "artifact_patterns": ["docs/elves/batches/{{ id }}.md"], + "next_step": "tag" + }, + { + "name": "tag", + "display_name": "Checkpoint Start", + "outputs": ["documentation"], + "prompt": "Create a pre-batch checkpoint.\n\nRecord current git status, branch, commit, PR status, and intended batch scope in `docs/elves/batches/{{ id }}.md`. If using git tags or commits for checkpoints, create the appropriate lightweight checkpoint.", + "allowed_tools": ["Read", "Write", "Edit", "Bash"], + "next_step": "implement" + }, + { + "name": "implement", + "display_name": "Implement Batch", + "outputs": ["code"], + "prompt": "Implement exactly this Elves batch: {{ batch_goal }}\n\nStay inside the batch boundary. Update the execution log as decisions are made. If scope expands, stop and document rather than continuing silently.", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], + "next_step": "validate" + }, + { + "name": "validate", + "display_name": "Validate Batch", + "outputs": ["test"], + "prompt": "Validate this Elves batch.\n\nRun touched-surface proof first, then the validation commands from the ticket and survival guide. Fix failures only when the fix is within batch scope. Record exact commands and results in `docs/elves/batches/{{ id }}.md`.", + "allowed_tools": ["Read", "Write", "Edit", "Bash"], + "review_type": "plan", + "on_reject": { + "goto_step": "implement", + "prompt": "Batch validation was rejected: {{ rejection_reason }}\n\nRepair the batch within scope and rerun validation." + }, + "next_step": "review" + }, + { + "name": "review", + "display_name": "Fresh Review", + "outputs": ["review"], + "prompt": "Perform a fresh review of this batch.\n\nRead the diff, execution log, validation evidence, and current PR comments/checks if any. Focus on correctness, regression risk, scope creep, and missing validation. Write findings to `docs/elves/batches/{{ id }}.md`.", + "agent": "elves-reviewer", + "allowed_tools": ["Read", "Glob", "Grep", "Bash", "Write"], + "review_type": "plan", + "on_reject": { + "goto_step": "implement", + "prompt": "Fresh review requested changes: {{ rejection_reason }}\n\nFix the review findings and repeat validation." + }, + "next_step": "judge" + }, + { + "name": "judge", + "display_name": "Judge Verdict", + "outputs": ["review"], + "prompt": "Judge whether the batch is safe to checkpoint.\n\nRead any project constitution, invariants, survival guide, diff, tests, and review notes. Emit pass, warn, or fail in `docs/elves/batches/{{ id }}.md`. Fail means return to implementation. Warn means checkpoint but clearly document residual risk.", + "agent": "elves-judge", + "allowed_tools": ["Read", "Glob", "Grep", "Write"], + "permission_mode": "plan", + "review_type": "plan", + "on_reject": { + "goto_step": "implement", + "prompt": "Judge verdict failed or was rejected: {{ rejection_reason }}\n\nAddress the unsafe condition or stop with a blocker." + }, + "next_step": "document" + }, + { + "name": "document", + "display_name": "Persist Memory", + "outputs": ["documentation"], + "prompt": "Persist durable memory after this batch.\n\nUpdate `docs/elves/execution-log.md`, `docs/elves/learnings.md`, `docs/elves/survival-guide.md`, and `.elves-session.json` with batch result, validation, decisions, risks, and exact resume instructions.", + "allowed_tools": ["Read", "Write", "Edit"], + "next_step": "push" + }, + { + "name": "push", + "display_name": "Checkpoint Push", + "outputs": ["pr", "code"], + "prompt": "Checkpoint this Elves batch.\n\nCommit focused changes if appropriate, push the branch when configured, update the PR if one exists, and record commit/push status in the batch log. Do not merge.", + "allowed_tools": ["Read", "Write", "Bash"], + "next_step": "entropy" + }, + { + "name": "entropy", + "display_name": "Continue Or Stop", + "outputs": ["report", "ticket"], + "prompt": "Decide whether to continue after this Elves batch.\n\nConsider `continue_after_batch`, remaining plan, time budget, validation confidence, review status, and risk. If continuing is safe, create or recommend the next ELVBATCH. If not, stop with exact human next steps.", + "allowed_tools": ["Read", "Write", "Bash"], + "artifact_patterns": ["docs/elves/batches/{{ id }}.md"] + } + ] +} diff --git a/docs/collections/elves_overnight/ELVBATCH.md b/docs/collections/elves_overnight/ELVBATCH.md new file mode 100644 index 00000000..cedcbbda --- /dev/null +++ b/docs/collections/elves_overnight/ELVBATCH.md @@ -0,0 +1,18 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +project: {{ project }} +continue_after_batch: {{ continue_after_batch }} +--- + +# Elves Batch: {{ summary }} + +## Batch Goal +{{ batch_goal }} + +{{#if validation_commands }} +## Validation Commands +{{ validation_commands }} +{{/if}} diff --git a/docs/collections/elves_overnight/ELVRPT.json b/docs/collections/elves_overnight/ELVRPT.json new file mode 100644 index 00000000..fbe4b95b --- /dev/null +++ b/docs/collections/elves_overnight/ELVRPT.json @@ -0,0 +1,71 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "ELVRPT", + "name": "Elves Report", + "description": "Produce a morning-after report for an Elves run.", + "mode": "paired", + "glyph": "R", + "color": "yellow", + "project_required": true, + "agent": "elves-coordinator", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "summary", + "description": "Report summary", + "type": "string", + "required": true, + "default": "", + "max_length": 120, + "display_order": 1 + }, + { + "name": "report_format", + "description": "Report output format", + "type": "enum", + "required": false, + "default": "markdown", + "options": ["markdown", "html"], + "display_order": 2 + } + ], + "steps": [ + { + "name": "gather", + "display_name": "Gather Run State", + "outputs": ["report"], + "prompt": "Gather all Elves run state.\n\nRead execution log, survival guide, learnings, batch logs, git commits, PR/check status, and any blockers. Record source paths in `docs/elves/report-{{ id }}.md`.", + "allowed_tools": ["Read", "Glob", "Grep", "Bash", "Write"], + "next_step": "summarize" + }, + { + "name": "summarize", + "display_name": "Write Report", + "outputs": ["report", "documentation"], + "prompt": "Write the morning-after Elves report.\n\nInclude final status, timeline, completed batches, validation evidence, PR status, unresolved issues, decisions made, lessons learned, risks, and concrete human next steps. Use the requested format: {{ report_format }}.", + "allowed_tools": ["Read", "Write", "Edit"], + "artifact_patterns": ["docs/elves/report-{{ id }}.md"], + "next_step": "review" + }, + { + "name": "review", + "display_name": "Report Review", + "outputs": ["review"], + "prompt": "Review the Elves report for accuracy.\n\nCross-check claims against logs, commits, tests, PR status, and remaining blockers. Fix overstatements or missing risks before handing to the user.", + "allowed_tools": ["Read", "Edit", "Grep", "Bash"], + "review_type": "plan", + "on_reject": { + "goto_step": "summarize", + "prompt": "The report was rejected: {{ rejection_reason }}\n\nCorrect the report and review it again." + } + } + ] +} diff --git a/docs/collections/elves_overnight/ELVRPT.md b/docs/collections/elves_overnight/ELVRPT.md new file mode 100644 index 00000000..da8a3314 --- /dev/null +++ b/docs/collections/elves_overnight/ELVRPT.md @@ -0,0 +1,10 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +project: {{ project }} +report_format: {{ report_format }} +--- + +# Elves Report: {{ summary }} diff --git a/docs/collections/elves_overnight/ELVSTAGE.json b/docs/collections/elves_overnight/ELVSTAGE.json new file mode 100644 index 00000000..8dc61c1b --- /dev/null +++ b/docs/collections/elves_overnight/ELVSTAGE.json @@ -0,0 +1,96 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "ELVSTAGE", + "name": "Elves Stage", + "description": "Prepare a long-running Elves session without starting risky implementation work.", + "mode": "paired", + "glyph": "E", + "color": "blue", + "project_required": true, + "agent": "elves-coordinator", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "summary", + "description": "Run summary", + "type": "string", + "required": true, + "default": "", + "placeholder": "Prepare overnight work plan", + "max_length": 120, + "display_order": 1 + }, + { + "name": "run_goal", + "description": "Goal for the unattended run", + "type": "text", + "required": true, + "default": "", + "display_order": 2 + }, + { + "name": "validation_commands", + "description": "Commands that prove the run is safe", + "type": "text", + "required": false, + "default": "", + "display_order": 3 + }, + { + "name": "time_budget_hours", + "description": "Maximum intended runtime in hours", + "type": "integer", + "required": false, + "default": "8", + "display_order": 4 + } + ], + "steps": [ + { + "name": "orient", + "display_name": "Orient", + "outputs": ["plan"], + "prompt": "Orient for an Elves-style long-running session.\n\nRead the run goal, repo state, active branch, tests, PR state, existing docs, and risks. Identify exact batches that can be completed independently. Write the orientation to `docs/elves/execution-log.md` and `.elves-session.json`.", + "allowed_tools": ["Read", "Glob", "Grep", "Write", "Bash"], + "artifact_patterns": ["docs/elves/execution-log.md", ".elves-session.json"], + "next_step": "contract" + }, + { + "name": "contract", + "display_name": "Write Run Contract", + "outputs": ["documentation"], + "prompt": "Write the durable memory contract for the Elves run.\n\nCreate or refresh `docs/elves/survival-guide.md`, `docs/elves/learnings.md`, `docs/elves/execution-log.md`, and `.elves-session.json`. Include goal, non-goals, stop rules, validation commands, branch/PR details, batch list, current risks, and how to resume after sleep or context loss.", + "allowed_tools": ["Read", "Write", "Edit"], + "artifact_patterns": ["docs/elves/survival-guide.md", "docs/elves/learnings.md", "docs/elves/execution-log.md", ".elves-session.json"], + "next_step": "preflight" + }, + { + "name": "preflight", + "display_name": "Preflight", + "outputs": ["test", "report"], + "prompt": "Run Elves preflight.\n\nVerify clean or intentionally dirty git state, remote/auth, base branch, validation commands, PR status if any, sleep/session readiness, collision risks, and whether unattended work is appropriate. Record exact readiness and blockers in `docs/elves/execution-log.md`.", + "allowed_tools": ["Read", "Write", "Edit", "Bash"], + "next_step": "stage" + }, + { + "name": "stage", + "display_name": "Stage Review", + "outputs": ["review"], + "prompt": "Stop for staging review.\n\nSummarize the launch command, first batch, stop conditions, validation gates, and known risks. Do not begin implementation in this ticket. This ticket is successful when the run is ready for an ELVBATCH launch.", + "allowed_tools": ["Read", "Write"], + "review_type": "plan", + "on_reject": { + "goto_step": "contract", + "prompt": "The Elves stage review requested changes: {{ rejection_reason }}\n\nRevise the run contract and preflight before asking for launch approval again." + } + } + ] +} diff --git a/docs/collections/elves_overnight/ELVSTAGE.md b/docs/collections/elves_overnight/ELVSTAGE.md new file mode 100644 index 00000000..d9a4577a --- /dev/null +++ b/docs/collections/elves_overnight/ELVSTAGE.md @@ -0,0 +1,18 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +project: {{ project }} +time_budget_hours: {{ time_budget_hours }} +--- + +# Elves Stage: {{ summary }} + +## Run Goal +{{ run_goal }} + +{{#if validation_commands }} +## Validation Commands +{{ validation_commands }} +{{/if}} diff --git a/docs/collections/elves_overnight/LANDPR.json b/docs/collections/elves_overnight/LANDPR.json new file mode 100644 index 00000000..b2c72cda --- /dev/null +++ b/docs/collections/elves_overnight/LANDPR.json @@ -0,0 +1,109 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "LANDPR", + "name": "Land Pull Request", + "description": "Run the Elves PR landing loop: collect feedback, fix blockers, prove, wait, and land only when safe.", + "mode": "paired", + "glyph": "L", + "color": "magenta", + "project_required": true, + "agent": "elves-reviewer", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "summary", + "description": "PR landing summary", + "type": "string", + "required": true, + "default": "", + "max_length": 120, + "display_order": 1 + }, + { + "name": "pr_url", + "description": "Pull request URL or number", + "type": "string", + "required": true, + "default": "", + "display_order": 2 + }, + { + "name": "allow_merge", + "description": "Whether the agent may merge when all gates pass", + "type": "bool", + "required": false, + "default": "false", + "display_order": 3 + } + ], + "steps": [ + { + "name": "collect", + "display_name": "Collect PR State", + "outputs": ["report"], + "prompt": "Collect complete PR state for {{ pr_url }}.\n\nRead PR description, comments, reviews, checks, requested changes, branch state, and local diff. Write state to `docs/elves/pr-landing-{{ id }}.md`.", + "allowed_tools": ["Read", "Glob", "Grep", "Bash", "Write"], + "artifact_patterns": ["docs/elves/pr-landing-{{ id }}.md"], + "next_step": "fresh" + }, + { + "name": "fresh", + "display_name": "Fresh Review", + "outputs": ["review"], + "prompt": "Perform a fresh read-only review of the PR diff.\n\nCompare base to HEAD, check review comments against current code, and identify true blockers versus already-fixed comments. Update `docs/elves/pr-landing-{{ id }}.md`.", + "allowed_tools": ["Read", "Glob", "Grep", "Bash", "Write"], + "permission_mode": "plan", + "next_step": "fix" + }, + { + "name": "fix", + "display_name": "Fix Blockers", + "outputs": ["code"], + "prompt": "Fix only PR landing blockers.\n\nAddress unresolved requested changes, failing checks, and fresh-review blockers. Avoid new feature work. If a blocker is unsafe or ambiguous, stop and document the exact human decision needed.", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], + "next_step": "prove" + }, + { + "name": "prove", + "display_name": "Prove Ready", + "outputs": ["test"], + "prompt": "Prove the PR is ready to land.\n\nRun targeted tests and broader sensible checks. Re-read PR checks and review status. Update landing notes with exact evidence.", + "allowed_tools": ["Read", "Write", "Bash"], + "review_type": "plan", + "on_reject": { + "goto_step": "fix", + "prompt": "PR proof failed or was rejected: {{ rejection_reason }}\n\nFix blockers and prove again." + }, + "next_step": "wait" + }, + { + "name": "wait", + "display_name": "Wait And Re-read", + "outputs": ["review"], + "prompt": "Wait for and re-read asynchronous PR surfaces.\n\nRefresh checks, reviews, bot comments, and inline threads. If new blockers appear, reject back to fix. If no blockers remain, proceed to land gate.", + "allowed_tools": ["Read", "Bash", "Write"], + "review_type": "plan", + "on_reject": { + "goto_step": "fix", + "prompt": "New PR feedback appeared: {{ rejection_reason }}\n\nAddress it before attempting to land." + }, + "next_step": "land" + }, + { + "name": "land", + "display_name": "Land Gate", + "outputs": ["pr"], + "prompt": "Land the PR only if safe.\n\nIf `allow_merge` is false, stop with a final ready-to-merge report. If true, merge only when the worktree is clean, checks are green, approvals are sufficient, no requested changes remain, and the branch is current. Record the result.", + "allowed_tools": ["Read", "Bash", "Write"], + "review_type": "pr" + } + ] +} diff --git a/docs/collections/elves_overnight/LANDPR.md b/docs/collections/elves_overnight/LANDPR.md new file mode 100644 index 00000000..91f20307 --- /dev/null +++ b/docs/collections/elves_overnight/LANDPR.md @@ -0,0 +1,11 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +project: {{ project }} +pr_url: {{ pr_url }} +allow_merge: {{ allow_merge }} +--- + +# Land PR: {{ summary }} diff --git a/docs/collections/elves_overnight/collection.json b/docs/collections/elves_overnight/collection.json new file mode 100644 index 00000000..70c6661d --- /dev/null +++ b/docs/collections/elves_overnight/collection.json @@ -0,0 +1,85 @@ +{ + "schema_version": 1, + "id": "elves_overnight", + "name": "Elves Overnight", + "description": "Long-running staged batch workflow with durable memory, validation, PR review, and reporting.", + "version": "1.0.0", + "publisher": "untra", + "author": "Aigora", + "url": "https://github.com/aigorahub/elves", + "license": "MIT", + "tags": [ + "agentic-loop", + "overnight", + "batch", + "elves" + ], + "compatibility": null, + "issue_types": [ + { + "key": "ELVSTAGE", + "schema_path": "ELVSTAGE.json", + "schema_checksum": "35dd4dc5460d4d88e28278df3a8591eb3a38c639e8e548a6fd893aee0c5a3982", + "template_path": "ELVSTAGE.md", + "template_checksum": "31a80e4ee58c016dd13076a6042008a32021c5d9b974b29a912eb301d850e1b2" + }, + { + "key": "ELVBATCH", + "schema_path": "ELVBATCH.json", + "schema_checksum": "16c957079f1cc5777f8feff8dc97b79a1b59d08a27df533d23bb610ad3ebaeb3", + "template_path": "ELVBATCH.md", + "template_checksum": "e92e6f5a71629a1c9eb39c77799d304d5fc9175f8077f6a7c595bb6e60f7d1da" + }, + { + "key": "LANDPR", + "schema_path": "LANDPR.json", + "schema_checksum": "663cd76ad36b3fddf8516c7d489bc595ab7e8ee4ef36c84d8bd2916de886afe5", + "template_path": "LANDPR.md", + "template_checksum": "b0f09ec49c38f5f66d0af77bcc018337bff1df1e814b0ee05aadd38be17a685b" + }, + { + "key": "ELVRPT", + "schema_path": "ELVRPT.json", + "schema_checksum": "491f175c67462671e862420ce3a1b7275e3fcfda4edd91501cc491069bee4569", + "template_path": "ELVRPT.md", + "template_checksum": "747897b723b96b5945c14ef46f44af2a7b733517b24ceb569ff8bec1b81a0c20" + } + ], + "workflow_hints": { + "loop_kind": "staged_long_running_batch_loop", + "memory_surfaces": [ + "docs/elves/survival-guide.md", + "docs/elves/execution-log.md", + "docs/elves/learnings.md", + ".elves-session.json", + "PR comments and checks" + ], + "review_gates": [ + "stage_review", + "batch_validation", + "fresh_review", + "judge_verdict", + "human_land_gate" + ], + "external_tools": [ + "git", + "gh", + "project test command", + "optional browser or visual review command" + ], + "stop_conditions": [ + "batch complete and checkpointed", + "validation cannot be repaired safely", + "PR has unresolved requested changes", + "time/risk budget exhausted" + ], + "runner_semantics": "prompt_driven" + }, + "default_selected": [ + "ELVSTAGE", + "ELVBATCH", + "LANDPR", + "ELVRPT" + ], + "checksum": "49e91c287046b5a9af307bfe8c09b9dc1938d573806712bef04b9dee0349d1d8" +} diff --git a/docs/collections/full/ASSESS.json b/docs/collections/full/ASSESS.json new file mode 100644 index 00000000..4b2c4671 --- /dev/null +++ b/docs/collections/full/ASSESS.json @@ -0,0 +1,64 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "ASSESS", + "name": "Project Assessment", + "description": "Analyze project structure and generate project-context.json and catalog-info.yaml", + "mode": "autonomous", + "glyph": "~", + "color": "magenta", + "project_required": true, + "agent_prompt": "Review this project to create an agent for project assessment. The agent analyzes project structure, detects the project Kind from file patterns (using the 25-Kind taxonomy), identifies commands/entry points/environment variables, and generates both project-context.json (for AI agents) and catalog-info.yaml (for project catalog). Output ONLY the agent system prompt.", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "summary", + "description": "Assessment description", + "type": "string", + "required": true, + "default": "", + "placeholder": "Assess project structure", + "max_length": 120, + "display_order": 1 + }, + { + "name": "kind_override", + "description": "Override detected Kind (optional)", + "type": "string", + "required": false, + "default": "", + "placeholder": "e.g., microservice, infrastructure", + "display_order": 2 + } + ], + "steps": [ + { + "name": "analyze", + "display_name": "Analyzing", + "outputs": ["report"], + "jsonSchemaFile": ".tickets/schemas/project_analysis.schema.json", + "prompt": "Analyze the project structure and output structured JSON conforming to the schema:\n\n1. **Kind Detection**: Match file patterns against the 25-Kind taxonomy:\n - Foundation: infrastructure, identity-access, config-policy, monorepo-meta\n - Standards: design-system, software-library, proto-sdk, blueprint, security-tooling, compliance-audit\n - Engines: ml-model, data-etl, microservice, api-gateway, ui-frontend, internal-tool\n - Ecosystem: build-tool, e2e-test, docs-site, playbook, cli-devtool\n - Noncurrent: reference-example, experiment-sandbox, archival-fork, test-data-fixtures\n\n2. **Languages/Frameworks/Databases**: Detect from manifest files (package.json, Cargo.toml, requirements.txt, go.mod), imports, and file extensions.\n\n3. **Commands**: Extract from:\n - package.json scripts (start, dev, test, build, lint)\n - Makefile targets\n - Cargo.toml [[bin]] sections\n - Scripts in bin/ or scripts/\n\n4. **Entry Points**: Identify key files:\n - binary_entry: src/main.rs, index.js, main.py\n - library_entry: src/lib.rs, lib/index.js\n - config: config/*.toml, .env.example\n - routes: src/routes.rs, routes/*.ts\n - main_component: src/App.tsx, src/App.vue\n\n5. **Environment Variables**: Scan .env.example, docker-compose.yml, config files for required env vars.\n\nIf kind_override is set, use that instead of auto-detection.", + "allowed_tools": ["Read", "Glob", "Grep"], + "next_step": "generate" + }, + { + "name": "generate", + "display_name": "Generating", + "outputs": ["code"], + "prompt": "Generate output files from the analysis JSON:\n\n1. **Write `project-context.json`** (for AI agents):\n - Save the complete structured analysis JSON to project root\n - This is the primary output for AI consumption\n\n2. **Write `catalog-info.yaml`** (for project catalog):\n ```yaml\n apiVersion: backstage.io/v1alpha1\n kind: Component\n metadata:\n name: {{ project }}\n description: \n annotations:\n backstage.io/techdocs-ref: dir:.\n tags:\n - \n - \n spec:\n type: \n lifecycle: production\n owner: \n ```\n\n3. **Document assessment** in `.tickets/assessments/{{ id }}.md`:\n - Kind detected with confidence score\n - Key technologies found\n - Commands available\n - Entry points identified", + "allowed_tools": ["Read", "Write", "Edit"], + "review_type": "plan", + "on_reject": { + "goto_step": "analyze", + "prompt": "Re-analyze the project with feedback: {{ rejection_reason }}" + } + } + ] +} diff --git a/docs/collections/full/ASSESS.md b/docs/collections/full/ASSESS.md new file mode 100644 index 00000000..4772d288 --- /dev/null +++ b/docs/collections/full/ASSESS.md @@ -0,0 +1,14 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +{{#if kind_override }}kind_override: {{ kind_override }} +{{/if}}--- + +# Assessment: {{ summary }} + +{{#if kind_override }} +## Kind Override +{{ kind_override }} +{{/if}} diff --git a/docs/collections/full/FEAT.json b/docs/collections/full/FEAT.json new file mode 100644 index 00000000..1574db95 --- /dev/null +++ b/docs/collections/full/FEAT.json @@ -0,0 +1,112 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "FEAT", + "name": "Feature", + "description": "New feature or enhancement ticket", + "mode": "autonomous", + "glyph": "*", + "color": "green", + "project_required": true, + "agent_prompt": "Review this project and prepare to create an agent for implementing new features. The agent should read tickets from .tickets/, create feature branches, implement code following existing patterns, run tests and linting, and create PRs. Output ONLY the agent system prompt.", + "prompt": "You are implementing a new feature for the {{ project }} project.\n\nBefore starting, review the acceptance criteria and understand the requirements.\n\n## Acceptance Criteria\n{{ acceptance_criteria }}\n\n## Definition of Done\nFollow this definition of done before claiming success:\n{{ definition_of_done }}", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "priority", + "description": "Ticket priority level", + "type": "enum", + "required": false, + "default": "P2-medium", + "options": ["P0-critical", "P1-high", "P2-medium", "P3-low"], + "display_order": 1 + }, + { + "name": "branch", + "description": "Git branch name", + "type": "string", + "required": true, + "auto": "branch", + "display_order": 2, + "user_editable": false + }, + { + "name": "summary", + "description": "name or summary of this feature", + "type": "string", + "required": true, + "default": "", + "placeholder": "Brief description of the feature", + "max_length": 120, + "display_order": 3 + }, + { + "name": "user_story", + "description": "Why is this feature needed? User story format preferred", + "type": "text", + "required": false, + "default": "", + "placeholder": "As a USER, I want to X, so I can Y", + "display_order": 4 + } + ], + "steps": [ + { + "name": "plan", + "display_name": "Planning", + "outputs": ["plan"], + "prompt": "You are implementing a new feature. First, read the ticket and explore the codebase to understand:\n1. Where the feature should be implemented\n2. What existing code/patterns to follow\n3. What tests need to be added\n\nCreate a detailed implementation plan in `.tickets/plans/{{ id }}.md` with:\n- Files to create/modify\n- Key implementation steps\n- Test coverage requirements\n- Any dependencies or risks", + "allowed_tools": ["Read", "Glob", "Grep", "Write"], + "review_type": "plan", + "on_reject": { + "goto_step": "plan", + "prompt": "Plan rejected: {{ rejection_reason }}\n\nRevise the plan based on feedback." + }, + "next_step": "build" + }, + { + "name": "build", + "display_name": "Building", + "outputs": ["code"], + "prompt": "Build the feature structure based on the plan in `.tickets/plans/{{ id }}.md`.\n\nIn this step, focus on:\n- Creating new files and modules\n- Setting up the basic structure\n- Adding type definitions and interfaces\n- Creating stub implementations\n\nDo not implement full logic yet - that comes in the next step.", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], + "next_step": "code" + }, + { + "name": "code", + "display_name": "Coding", + "outputs": ["code"], + "prompt": "Implement the feature logic based on the plan and structure.\n\nGuidelines:\n- Follow existing code patterns and conventions\n- Add appropriate comments for complex logic\n- Keep changes minimal and focused\n- Run formatters after making changes", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], + "agent": "claude-opus", + "next_step": "test" + }, + { + "name": "test", + "display_name": "Testing", + "outputs": ["test", "code"], + "prompt": "Add tests for the new feature and ensure all tests pass.\n\n1. Write unit tests for new functions/modules\n2. Add integration tests if applicable\n3. Run the full test suite: `cargo test`\n4. Run linting: `cargo clippy`\n5. Run formatting: `cargo fmt`\n\nFix any failures before proceeding.", + "allowed_tools": ["Read", "Write", "Edit", "Bash"], + "next_step": "deploy" + }, + { + "name": "deploy", + "display_name": "Deploying", + "outputs": ["pr"], + "prompt": "Create a pull request for the feature:\n\n1. Commit all changes with a descriptive message\n2. Push the feature branch\n3. Create a PR with:\n - Clear title: `feat({{ project }}): {{ summary }}`\n - Description of changes\n - Link to ticket: {{ id }}\n - Test instructions\n\nFeature complete. Move ticket to completed after PR is merged.", + "allowed_tools": ["Bash", "Read"], + "review_type": "pr", + "on_reject": { + "goto_step": "code", + "prompt": "Deploy rejected: {{ rejection_reason }}\n\nAddress feedback and revise the implementation." + } + } + ] +} diff --git a/docs/collections/full/FEAT.md b/docs/collections/full/FEAT.md new file mode 100644 index 00000000..10d13ae3 --- /dev/null +++ b/docs/collections/full/FEAT.md @@ -0,0 +1,15 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +branch: {{ branch }} +{{#if priority }}priority: {{ priority }} +{{/if}}--- + +# Feature: {{ summary }} + +{{#if context }} +## Context +{{ context }} +{{/if}} diff --git a/docs/collections/full/FIX.json b/docs/collections/full/FIX.json new file mode 100644 index 00000000..39320e99 --- /dev/null +++ b/docs/collections/full/FIX.json @@ -0,0 +1,138 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "FIX", + "name": "Fix", + "description": "Bug fix, follow-up work, tech debt, or refactoring ticket", + "mode": "autonomous", + "glyph": "#", + "color": "magenta", + "project_required": true, + "agent_prompt": "Review this project and prepare to create an agent for bug fixes. The agent should read tickets from .tickets/, reproduce issues, implement minimal fixes, verify with tests, and create PRs. Output ONLY the agent system prompt.", + "prompt": "You are fixing a bug or addressing technical debt in the {{ project }} project.\n\nBefore starting, review the acceptance criteria and understand the root cause.\n\n## Acceptance Criteria\n{{ acceptance_criteria }}\n\n## Definition of Done\nFollow this definition of done before claiming success:\n{{ definition_of_done }}", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "priority", + "description": "Ticket priority level", + "type": "enum", + "required": false, + "default": "P2-medium", + "options": ["P0-critical", "P1-high", "P2-medium", "P3-low"], + "display_order": 1 + }, + { + "name": "severity", + "description": "Bug severity (if applicable)", + "type": "enum", + "required": false, + "default": "N/A", + "options": ["S0-outage", "S1-major", "S2-minor", "S3-cosmetic", "N/A"], + "display_order": 2 + }, + { + "name": "branch", + "description": "Git branch name", + "type": "string", + "required": true, + "auto": "branch", + "display_order": 3, + "user_editable": false + }, + { + "name": "fix_type", + "description": "Type of fix work", + "type": "enum", + "required": false, + "default": "Bug fix", + "options": ["Bug fix", "Follow-up work", "Technical debt", "Refactoring", "Test coverage", "Documentation"], + "display_order": 4 + }, + { + "name": "summary", + "description": "name or summary of this bugfix", + "type": "string", + "required": true, + "default": "", + "placeholder": "Brief description of what needs to be fixed", + "max_length": 120, + "display_order": 5 + }, + { + "name": "parent", + "description": "Parent / Epic ticket ID", + "type": "string", + "required": false, + "default": "", + "placeholder": "Parent ticket ID if applicable", + "display_order": 6 + }, + { + "name": "user_story", + "description": "Context for the fix (steps to reproduce, background)", + "type": "text", + "required": false, + "default": "", + "placeholder": "Steps to reproduce bug, or context for tech debt", + "display_order": 7 + } + ], + "steps": [ + { + "name": "plan", + "display_name": "Planning", + "outputs": ["plan"], + "prompt": "Assess the project and plan reproduction:\n\n1. Understand the project structure\n2. Find how to run the project and tests\n3. Locate applicable test code\n4. Create a plan to reproduce the bug\n\nWrite plan to `.tickets/plans/{{ id }}.md` with:\n- How to run the project\n- Relevant test commands\n- Steps to reproduce the issue\n- Initial hypotheses about the root cause", + "allowed_tools": ["Read", "Glob", "Grep", "Write", "Bash"], + "review_type": "plan", + "on_reject": { + "goto_step": "plan", + "prompt": "Plan rejected: {{ rejection_reason }}\n\nRevise the reproduction plan." + }, + "next_step": "reproduce" + }, + { + "name": "reproduce", + "display_name": "Reproducing", + "outputs": ["test", "report"], + "prompt": "Reproduce the bug:\n\n1. Follow the plan to reproduce the issue\n2. Write a failing test that demonstrates the bug\n3. Document the reproduction in `.tickets/notes/{{ id }}.md`\n4. Confirm the test fails as expected", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], + "next_step": "test" + }, + { + "name": "test", + "display_name": "Baseline Testing", + "outputs": ["report"], + "prompt": "Establish test baseline:\n\n1. Run full test suite to understand current state\n2. Document which tests pass/fail\n3. Identify any related test coverage gaps\n4. Note any flaky or slow tests", + "allowed_tools": ["Read", "Bash"], + "next_step": "fix" + }, + { + "name": "fix", + "display_name": "Fixing", + "outputs": ["code"], + "prompt": "Implement the fix:\n\n1. Apply minimal fix to address the root cause\n2. Verify the previously failing test now passes\n3. Run full test suite: `cargo test`\n4. Run linting: `cargo clippy`\n5. Run formatting: `cargo fmt`\n\nKeep the fix focused and minimal.", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], + "next_step": "deploy" + }, + { + "name": "deploy", + "display_name": "Deploying", + "outputs": ["review"], + "prompt": "Create a pull request for the fix:\n\n1. Commit all changes with message: `fix({{ project }}): {{ summary }}`\n2. Push the fix branch\n3. Create a PR with:\n - Root cause analysis\n - Description of the fix\n - Test verification\n - Link to ticket: {{ id }}\n\nFix complete. Move ticket to completed after PR is merged.", + "allowed_tools": ["Bash", "Read"], + "review_type": "plan", + "on_reject": { + "goto_step": "fix", + "prompt": "Deploy rejected: {{ rejection_reason }}\n\nAddress feedback and revise the fix." + } + } + ] +} diff --git a/docs/collections/full/FIX.md b/docs/collections/full/FIX.md new file mode 100644 index 00000000..e23158ff --- /dev/null +++ b/docs/collections/full/FIX.md @@ -0,0 +1,18 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +branch: {{ branch }} +{{#if priority }}priority: {{ priority }} +{{/if}}{{#if severity }}severity: {{ severity }} +{{/if}}{{#if fix_type }}fix_type: {{ fix_type }} +{{/if}}{{#if parent }}parent: {{ parent }} +{{/if}}--- + +# Fix: {{ summary }} + +{{#if context }} +## Context +{{ context }} +{{/if}} diff --git a/docs/collections/full/INIT.json b/docs/collections/full/INIT.json new file mode 100644 index 00000000..1c2bf388 --- /dev/null +++ b/docs/collections/full/INIT.json @@ -0,0 +1,85 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "INIT", + "name": "Workspace Init", + "description": "Initialize workspace structure", + "mode": "paired", + "glyph": "%", + "color": "green", + "project_required": false, + "agent_prompt": "Review this workspace to create an agent for workspace initialization. The agent scaffolds the workspace directory structure in .tickets/operator/workspace/, configures project locations and authentication, and verifies tool installation. It works in paired mode with the operator for configuration decisions. Output ONLY the agent system prompt.", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "workspace", + "description": "Workspace root path", + "type": "string", + "required": true, + "default": "", + "placeholder": "Path to workspace root", + "display_order": 1 + }, + { + "name": "branding_name", + "description": "Custom branding name", + "type": "string", + "required": false, + "default": "", + "placeholder": "e.g., My Company Portal", + "display_order": 2 + }, + { + "name": "summary", + "description": "Initialization description", + "type": "string", + "required": true, + "default": "", + "placeholder": "Initialize workspace", + "max_length": 120, + "display_order": 3 + } + ], + "steps": [ + { + "name": "scaffold", + "display_name": "Scaffolding", + "outputs": ["code"], + "prompt": "Create the workspace directory structure:\n\n1. Create `.tickets/operator/workspace/` directory\n2. Generate workspace configuration files\n3. Create standard directory structure\n4. Set up branding directory with defaults\n\nAsk operator for branding preferences if not specified.", + "allowed_tools": ["Read", "Write", "Bash"], + "next_step": "configure" + }, + { + "name": "configure", + "display_name": "Configuring", + "outputs": ["code"], + "prompt": "Configure workspace for local development:\n\n1. Generate workspace configuration with:\n - File-based catalog locations\n - Local database (SQLite)\n2. Scan workspace for existing catalog-info.yaml files\n3. Add all discovered locations to config\n4. Configure branding if branding_name is set\n\nReview configuration with operator before proceeding.", + "allowed_tools": ["Read", "Write", "Glob"], + "review_type": "plan", + "on_reject": { + "goto_step": "configure", + "prompt": "Revise configuration based on feedback: {{ rejection_reason }}" + }, + "next_step": "verify" + }, + { + "name": "verify", + "display_name": "Verifying", + "outputs": ["report"], + "prompt": "Verify the workspace setup:\n\n1. Verify configuration is valid\n2. Check all referenced projects exist\n3. Document setup in `.tickets/operator/workspace/README.md`\n\nReport any issues that need manual resolution.", + "allowed_tools": ["Read", "Write", "Bash"], + "review_type": "plan", + "on_reject": { + "goto_step": "scaffold", + "prompt": "Setup verification failed. Re-scaffold with feedback: {{ rejection_reason }}" + } + } + ] +} diff --git a/docs/collections/full/INIT.md b/docs/collections/full/INIT.md new file mode 100644 index 00000000..05ef2b5d --- /dev/null +++ b/docs/collections/full/INIT.md @@ -0,0 +1,18 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}workspace: {{ workspace }} +{{#if branding_name }}branding_name: {{ branding_name }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +--- + +# Workspace Init: {{ summary }} + +## Workspace +{{ workspace }} + +{{#if branding_name }} +## Branding +{{ branding_name }} +{{/if}} diff --git a/docs/collections/full/INV.json b/docs/collections/full/INV.json new file mode 100644 index 00000000..480e502b --- /dev/null +++ b/docs/collections/full/INV.json @@ -0,0 +1,137 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "INV", + "name": "Investigation", + "description": "Incident investigation ticket (paired mode)", + "mode": "paired", + "glyph": "!", + "color": "yellow", + "project_required": false, + "agent_prompt": "Review this project and prepare to create an agent for incident investigations. The agent triages issues, investigates root causes, coordinates remediation, and documents postmortems. It works in paired mode with the operator. Output ONLY the agent system prompt.", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "scope", + "description": "Affected scope/project", + "type": "enum", + "required": false, + "default": "global", + "options": ["global", "adminsvc", "apisvc", "gamesvc", "g", "hushsvc", "uzersvc", "outboundsvc", "www", "iac", "proto", "e2e", "operator"], + "display_order": 1 + }, + { + "name": "severity", + "description": "Incident severity", + "type": "enum", + "required": false, + "default": "S1-major", + "options": ["S0-outage", "S1-major", "S2-minor"], + "display_order": 2 + }, + { + "name": "source", + "description": "How the incident was detected", + "type": "enum", + "required": false, + "default": "monitoring", + "options": ["alert", "user-report", "monitoring", "deploy-failure", "test-failure"], + "display_order": 3 + }, + { + "name": "summary", + "description": "named summary of the failure investigation", + "type": "string", + "required": true, + "default": "", + "placeholder": "Brief description of the observed failure", + "max_length": 120, + "display_order": 4 + }, + { + "name": "observed_behavior", + "description": "What is happening? Include error messages, logs", + "type": "text", + "required": false, + "default": "", + "placeholder": "Error messages, log excerpts, metrics", + "display_order": 5 + }, + { + "name": "expected_behavior", + "description": "What should be happening instead?", + "type": "text", + "required": false, + "default": "", + "placeholder": "Expected behavior under normal conditions", + "display_order": 6 + }, + { + "name": "impact", + "description": "Users/services affected", + "type": "string", + "required": false, + "default": "", + "placeholder": "Affected users, services, or systems", + "display_order": 7 + } + ], + "steps": [ + { + "name": "triage", + "display_name": "Triage", + "outputs": ["report"], + "prompt": "Triage the incident:\n\n1. Confirm the issue is real and ongoing\n2. Assess severity and impact\n3. Identify affected systems\n4. Begin documenting in `.tickets/incidents/{{ id }}.md`\n\nWork with the operator to gather initial information.", + "allowed_tools": ["Read", "Glob", "Grep", "Bash", "Write"], + "next_step": "investigate" + }, + { + "name": "investigate", + "display_name": "Investigating", + "outputs": ["report"], + "prompt": "Investigate the root cause:\n\n1. Search logs and metrics for anomalies\n2. Trace the error path through the code\n3. Identify recent changes that might be related\n4. Form hypotheses and test them\n5. Update `.tickets/incidents/{{ id }}.md` with findings", + "allowed_tools": ["Read", "Glob", "Grep", "Bash", "Write"], + "next_step": "remediate" + }, + { + "name": "remediate", + "display_name": "Remediating", + "outputs": ["code", "ticket"], + "prompt": "Determine remediation:\n\n1. If a quick fix is possible, implement it\n2. If a hotfix is needed, create a FIX ticket\n3. If a rollback is needed, coordinate with the operator\n4. Document the resolution in the incident report", + "allowed_tools": ["Read", "Write", "Edit", "Bash"], + "review_type": "plan", + "on_reject": { + "goto_step": "investigate", + "prompt": "More investigation needed. Continue searching for the root cause." + }, + "next_step": "postmortem" + }, + { + "name": "postmortem", + "display_name": "Postmortem", + "outputs": ["report", "ticket"], + "prompt": "Complete the postmortem:\n\n1. Finalize the incident report with:\n - Timeline of events\n - Root cause analysis\n - Remediation taken\n - Lessons learned\n2. Create follow-up tickets for preventive measures", + "allowed_tools": ["Read", "Write", "Bash"], + "review_type": "plan", + "on_reject": { + "goto_step": "postmortem", + "prompt": "Postmortem needs more detail. Expand the analysis." + }, + "next_step": "done" + }, + { + "name": "done", + "display_name": "Complete", + "outputs": ["review"], + "prompt": "Investigation complete. Move ticket to completed.", + "allowed_tools": ["Bash"] + } + ] +} diff --git a/docs/collections/full/INV.md b/docs/collections/full/INV.md new file mode 100644 index 00000000..b03380c9 --- /dev/null +++ b/docs/collections/full/INV.md @@ -0,0 +1,34 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}{{#if scope }}scope: {{ scope }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +{{#if severity }}severity: {{ severity }} +{{/if}}{{#if source }}source: {{ source }} +{{/if}}--- + +# Investigation: {{ summary }} + +{{#if observed_behavior }} +## Observed Behavior +{{ observed_behavior }} +{{/if}} + +{{#if expected_behavior }} +## Expected Behavior +{{ expected_behavior }} +{{/if}} + +{{#if impact }} +## Impact +{{ impact }} +{{/if}} + +## Timeline +| Time | Event | +|------|-------| +| {{ created_datetime }} | Investigation opened | + +## Findings + diff --git a/docs/collections/full/SPIKE.json b/docs/collections/full/SPIKE.json new file mode 100644 index 00000000..a4577402 --- /dev/null +++ b/docs/collections/full/SPIKE.json @@ -0,0 +1,98 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "SPIKE", + "name": "Spike", + "description": "Research or exploration ticket (paired mode)", + "mode": "paired", + "glyph": "?", + "color": "blue", + "project_required": false, + "agent_prompt": "Review this project and prepare to create an agent for research spikes. The agent works in paired mode, explores the codebase, researches external docs, and documents findings. It should ask questions when uncertain. Output ONLY the agent system prompt.", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "scope", + "description": "Scope of the spike", + "type": "enum", + "required": false, + "default": "global", + "options": ["global", "adminsvc", "apisvc", "gamesvc", "g", "hushsvc", "uzersvc", "outboundsvc", "www", "iac", "proto", "e2e", "operator"], + "display_order": 1 + }, + { + "name": "priority", + "description": "Ticket priority level", + "type": "enum", + "required": false, + "default": "P2-medium", + "options": ["P0-critical", "P1-high", "P2-medium", "P3-low"], + "display_order": 2 + }, + { + "name": "summary", + "description": "Research question or topic", + "type": "string", + "required": true, + "default": "", + "placeholder": "What needs to be researched or explored?", + "max_length": 120, + "display_order": 3 + }, + { + "name": "user_story", + "description": "Why is this spike needed? What decision does it inform?", + "type": "text", + "required": false, + "default": "", + "placeholder": "Background context and what decisions depend on this research", + "display_order": 4 + }, + { + "name": "success_criteria", + "description": "How will we know the spike is complete?", + "type": "text", + "required": false, + "default": "", + "placeholder": "Questions to answer, deliverables expected", + "display_order": 5 + } + ], + "steps": [ + { + "name": "explore", + "display_name": "Exploration", + "outputs": ["report"], + "prompt": "This is a research spike. Work with the operator to explore:\n\n1. Read and understand the research question\n2. Search the codebase for relevant patterns\n3. Research external documentation if needed\n4. Discuss findings with the operator\n\nDocument discoveries in `.tickets/spikes/{{ id }}.md`", + "allowed_tools": ["Read", "Glob", "Grep", "WebFetch", "WebSearch", "Write"], + "next_step": "summarize" + }, + { + "name": "summarize", + "display_name": "Summarizing", + "outputs": ["report", "ticket"], + "prompt": "Summarize findings from the spike:\n\n1. Update `.tickets/spikes/{{ id }}.md` with:\n - Key findings\n - Recommendations\n - Trade-offs identified\n - Open questions\n2. If follow-up work is identified, create ticket(s)", + "allowed_tools": ["Read", "Write", "Bash"], + "review_type": "plan", + "on_reject": { + "goto_step": "explore", + "prompt": "More research is needed. Continue exploring with the operator." + }, + "next_step": "done" + }, + { + "name": "done", + "display_name": "Complete", + "outputs": ["review"], + "prompt": "Spike complete. Move ticket to completed.", + "allowed_tools": ["Bash"] + } + ] +} diff --git a/docs/collections/full/SPIKE.md b/docs/collections/full/SPIKE.md new file mode 100644 index 00000000..bfe6f136 --- /dev/null +++ b/docs/collections/full/SPIKE.md @@ -0,0 +1,26 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}{{#if scope }}scope: {{ scope }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +{{#if priority }}priority: {{ priority }} +{{/if}}--- + +# Spike: {{ summary }} + +{{#if context }} +## Context +{{ context }} +{{/if}} + +{{#if success_criteria }} +## Success Criteria +{{ success_criteria }} +{{/if}} + +## Conversation Log +### Session: {{ created_date }} + +## Findings + diff --git a/docs/collections/full/SYNC.json b/docs/collections/full/SYNC.json new file mode 100644 index 00000000..519cd3b5 --- /dev/null +++ b/docs/collections/full/SYNC.json @@ -0,0 +1,71 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "SYNC", + "name": "Catalog Sync", + "description": "Refresh catalog entries from projects", + "mode": "autonomous", + "glyph": "@", + "color": "blue", + "project_required": false, + "agent_prompt": "Review this workspace to create an agent for catalog synchronization. The agent discovers all projects with catalog-info.yaml files, validates them against the catalog schema, checks for inconsistencies, and updates catalog entries as needed. It can run across the entire workspace or target specific projects. Output ONLY the agent system prompt.", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "scope", + "description": "Sync scope", + "type": "enum", + "required": false, + "default": "all", + "options": ["all", "changed", "project"], + "display_order": 1 + }, + { + "name": "summary", + "description": "Sync description", + "type": "string", + "required": true, + "default": "", + "placeholder": "Sync catalog entries", + "max_length": 120, + "display_order": 2 + } + ], + "steps": [ + { + "name": "scan", + "display_name": "Scanning", + "outputs": ["report"], + "prompt": "Discover all catalog entries in the workspace:\n\n1. Find all catalog-info.yaml files in projects\n2. Parse each file and validate structure\n3. Build a dependency graph from relations\n4. Identify:\n - New entries (not in catalog)\n - Changed entries (modified since last sync)\n - Removed entries (catalog-info.yaml deleted)\n5. Document findings in `.tickets/syncs/{{ id }}.md`", + "allowed_tools": ["Read", "Glob", "Grep", "Write"], + "next_step": "validate" + }, + { + "name": "validate", + "display_name": "Validating", + "outputs": ["report"], + "prompt": "Validate all catalog entries:\n\n1. Check each catalog-info.yaml against schema\n2. Verify all referenced entities exist\n3. Check for circular dependencies\n4. Validate owner references\n5. Report any validation errors\n\nUpdate `.tickets/syncs/{{ id }}.md` with validation results.", + "allowed_tools": ["Read", "Write"], + "next_step": "update" + }, + { + "name": "update", + "display_name": "Updating", + "outputs": ["report"], + "prompt": "Update the project catalog:\n\n1. If catalog server is running, trigger catalog refresh\n2. If file-based, update the locations config\n3. Log all changes made\n4. Verify catalog is consistent after update\n\nComplete the sync report in `.tickets/syncs/{{ id }}.md`.", + "allowed_tools": ["Read", "Write", "Bash"], + "review_type": "plan", + "on_reject": { + "goto_step": "validate", + "prompt": "Re-validate with feedback: {{ rejection_reason }}" + } + } + ] +} diff --git a/docs/collections/full/SYNC.md b/docs/collections/full/SYNC.md new file mode 100644 index 00000000..6838583b --- /dev/null +++ b/docs/collections/full/SYNC.md @@ -0,0 +1,12 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}scope: {{ scope }} +status: {{ status }} +created: {{ created_datetime }} +--- + +# Catalog Sync: {{ summary }} + +## Scope +{{ scope }} diff --git a/docs/collections/full/TASK.json b/docs/collections/full/TASK.json new file mode 100644 index 00000000..04b95bd2 --- /dev/null +++ b/docs/collections/full/TASK.json @@ -0,0 +1,75 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "TASK", + "name": "Task", + "description": "Focused task that executes one specific thing", + "mode": "autonomous", + "glyph": ">", + "color": "cyan", + "project_required": false, + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "priority", + "description": "Ticket priority level", + "type": "enum", + "required": false, + "default": "P2-medium", + "options": ["P0-critical", "P1-high", "P2-medium", "P3-low"], + "display_order": 1 + }, + { + "name": "summary", + "description": "One-line description of the task", + "type": "string", + "required": true, + "default": "", + "placeholder": "Brief description of the task", + "max_length": 120, + "display_order": 2 + }, + { + "name": "description", + "description": "Detailed description of the task", + "type": "text", + "required": true, + "default": "", + "placeholder": "Full description of what needs to be done", + "display_order": 3 + }, + { + "name": "points", + "description": "Story points estimate", + "type": "integer", + "required": false, + "default": "0", + "display_order": 4 + }, + { + "name": "user_story", + "description": "User story or background context", + "type": "text", + "required": false, + "default": "", + "placeholder": "As a USER, I want to X, so I can Y", + "display_order": 5 + } + ], + "steps": [ + { + "name": "execute", + "display_name": "Executing", + "outputs": ["code", "report"], + "prompt": "Execute this task:\n\n1. Read the ticket summary and context\n2. Explore the codebase as needed\n3. Complete the task as specified\n4. Document what was done\n\nThis is a focused task - complete it directly without creating follow-up tickets or plans.", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"] + } + ] +} diff --git a/docs/collections/full/TASK.md b/docs/collections/full/TASK.md new file mode 100644 index 00000000..b764c240 --- /dev/null +++ b/docs/collections/full/TASK.md @@ -0,0 +1,26 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +{{#if priority }}priority: {{ priority }} +{{/if}}{{#if points }}points: {{ points }} +{{/if}}--- + +# Task: {{ summary }} + +## Description +{{ description }} + +{{#if user_story }} +## User Story +{{ user_story }} +{{/if}} + +{{#if acceptance_criteria }} +## Acceptance Criteria +{{ acceptance_criteria }} +{{/if}} + +## Plan +*Plan will be written to `.tickets/plans/{{ id }}.md`* diff --git a/docs/collections/full/collection.json b/docs/collections/full/collection.json new file mode 100644 index 00000000..521155bb --- /dev/null +++ b/docs/collections/full/collection.json @@ -0,0 +1,85 @@ +{ + "schema_version": 1, + "id": "full", + "name": "Full", + "description": "Full workflow: all issue types combined", + "version": "1.0.0", + "publisher": "untra", + "author": "Operator!", + "url": "https://github.com/untra/operator", + "license": "MIT", + "tags": [ + "builtin" + ], + "compatibility": null, + "issue_types": [ + { + "key": "TASK", + "schema_path": "TASK.json", + "schema_checksum": "f654229454da050ca038c273cc74884c2dbe68f09b293eee3764f23d6771b939", + "template_path": "TASK.md", + "template_checksum": "7a71bf692588af1d1ac5d9192edd64316353ada616e22019dfff7683ab5d09c4" + }, + { + "key": "FEAT", + "schema_path": "FEAT.json", + "schema_checksum": "f647a5aaec7514f3dfbaad70bc757e3360e49ba7e35ce496e98dec010ea9e51b", + "template_path": "FEAT.md", + "template_checksum": "fbc7e17641d8e796dad14c09fe20ffcbbdbdb8f58199b0d29d9e9729b51b4d5a" + }, + { + "key": "FIX", + "schema_path": "FIX.json", + "schema_checksum": "4deafa08b2dcf1c94462d7079574be05d2efd6ff0abb6036b8fde955dca0e0a1", + "template_path": "FIX.md", + "template_checksum": "5237e1faf9b2c49d80fc9efff5a0d3e184f86c4fa5a34ff4dfed26ee883856bf" + }, + { + "key": "SPIKE", + "schema_path": "SPIKE.json", + "schema_checksum": "1f69f05190fdff5545371c042c388e276accbbb50179ce65365f17dac042c0b5", + "template_path": "SPIKE.md", + "template_checksum": "030d37b1b4b3b8a26db62bd590a61fb5b9e01d5a39be6ab771bd56e31179f2c4" + }, + { + "key": "INV", + "schema_path": "INV.json", + "schema_checksum": "42129e41c6c735adb47061a7d99478a1e4ed9ca5ca9f42b3c300c4a833f9265b", + "template_path": "INV.md", + "template_checksum": "65ef45c01d30b93f9d81b830896f20fe4eb9645e82d8e16ea1bd77ac4f359050" + }, + { + "key": "ASSESS", + "schema_path": "ASSESS.json", + "schema_checksum": "63d218368c58df22606fbd654920edeb304ebc1bc7282501be38ae14027aa9e1", + "template_path": "ASSESS.md", + "template_checksum": "e2fb41d771ccf37af6189cdc0ef9b2752d3e1ddb1fa193f0264fbbdd432e7e0f" + }, + { + "key": "SYNC", + "schema_path": "SYNC.json", + "schema_checksum": "6e52fb05db6e09cd723becb8ffe0388957c639b39fb81efe38d7e9cb8d725e55", + "template_path": "SYNC.md", + "template_checksum": "fc5cf44f553720b44ef328c560155fda137102cee28d60505b289d35e45d79cf" + }, + { + "key": "INIT", + "schema_path": "INIT.json", + "schema_checksum": "bd7387742f9d81e49dcbe05cf25d3db0544e2f48843fb855df881d7ab094a0d6", + "template_path": "INIT.md", + "template_checksum": "ff54445fb446a8ba8155e00259ebf44860c2e8d1865df6943ceec648fe3650e8" + } + ], + "workflow_hints": null, + "default_selected": [ + "TASK", + "FEAT", + "FIX", + "SPIKE", + "INV", + "ASSESS", + "SYNC", + "INIT" + ], + "checksum": "e1f9940d1c3f84b26ec6e574bc4f4804d2f9e6dc64298344ef3c06ec9dabbb77" +} diff --git a/docs/collections/index.json b/docs/collections/index.json new file mode 100644 index 00000000..d410a91c --- /dev/null +++ b/docs/collections/index.json @@ -0,0 +1,108 @@ +{ + "schema_version": 1, + "generated_at": null, + "collections": [ + { + "id": "simple", + "name": "Simple", + "description": "Simple workflow with TASK only", + "version": "1.0.0", + "tags": [ + "builtin" + ], + "manifest_path": "simple/collection.json", + "checksum": "fce81369f98e00ee6fe35194076055b59f4882d8d1613a914bbce660593a05b4" + }, + { + "id": "dev_kanban", + "name": "Dev Kanban", + "description": "Developer kanban with TASK, FEAT, FIX", + "version": "1.0.0", + "tags": [ + "builtin", + "kanban", + "dev" + ], + "manifest_path": "dev_kanban/collection.json", + "checksum": "87460062d63068e9e96f6ee628cc466bec6951c0b766f338178b66cb5ac1d23f" + }, + { + "id": "devops_kanban", + "name": "DevOps Kanban", + "description": "DevOps kanban with TASK, FEAT, FIX, SPIKE, INV", + "version": "1.0.0", + "tags": [ + "builtin", + "kanban", + "devops" + ], + "manifest_path": "devops_kanban/collection.json", + "checksum": "068c5112a615ccc4f9a1c11e0b90c1a193b07e5b07565100880750f1624edaa4" + }, + { + "id": "operator", + "name": "Operator", + "description": "Operator automation tasks: ASSESS, SYNC, INIT", + "version": "1.0.0", + "tags": [ + "builtin", + "automation" + ], + "manifest_path": "operator/collection.json", + "checksum": "9a5aa2ac773bd4e445cb951f870b7e78ff3e0002be9a66bba05c14e9abe9c4f3" + }, + { + "id": "full", + "name": "Full", + "description": "Full workflow: all issue types combined", + "version": "1.0.0", + "tags": [ + "builtin" + ], + "manifest_path": "full/collection.json", + "checksum": "305830707c108a825bd5aab14aaf9e489e0d7b286e36c77269ad0da94420f2d4" + }, + { + "id": "ralph_loop", + "name": "Ralph Loop", + "description": "PRD-to-story loop for completing one right-sized story per fresh agent context.", + "version": "1.0.0", + "tags": [ + "agentic-loop", + "prd", + "stories", + "ralph" + ], + "manifest_path": "ralph_loop/collection.json", + "checksum": "f37e1379c1ffea6208d25add591daa468b680c2a796b04fc490c0a4e30f96c39" + }, + { + "id": "jr_orchestration", + "name": "JR Orchestration", + "description": "Feature/task orchestration with coder, reviewer, architect, and rebase work units.", + "version": "1.0.0", + "tags": [ + "agentic-loop", + "feature-graph", + "review", + "jr" + ], + "manifest_path": "jr_orchestration/collection.json", + "checksum": "e89a9ec49af83f5c431fc95e0418943d7aedc3fd087b5b05ee12b339007c0f39" + }, + { + "id": "elves_overnight", + "name": "Elves Overnight", + "description": "Long-running staged batch workflow with durable memory, validation, PR review, and reporting.", + "version": "1.0.0", + "tags": [ + "agentic-loop", + "overnight", + "batch", + "elves" + ], + "manifest_path": "elves_overnight/collection.json", + "checksum": "1078479804ba0d581bff29d7e153904dee0d46cfb41d4f05841891814a9f62eb" + } + ] +} diff --git a/docs/collections/jr_orchestration/JRFEAT.json b/docs/collections/jr_orchestration/JRFEAT.json new file mode 100644 index 00000000..1d283fd5 --- /dev/null +++ b/docs/collections/jr_orchestration/JRFEAT.json @@ -0,0 +1,104 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "JRFEAT", + "name": "JR Feature", + "description": "Coordinate one JR feature branch, its task chain, and final architect/human review.", + "mode": "paired", + "glyph": "F", + "color": "green", + "project_required": true, + "agent": "jr-architect-reviewer", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "summary", + "description": "Feature summary", + "type": "string", + "required": true, + "default": "", + "placeholder": "Feature branch outcome", + "max_length": 120, + "display_order": 1 + }, + { + "name": "parent_plan", + "description": "JRPLAN ticket or plan path", + "type": "string", + "required": false, + "default": "", + "display_order": 2 + }, + { + "name": "branch", + "description": "Feature branch name", + "type": "string", + "required": true, + "auto": "branch", + "display_order": 3, + "user_editable": false + }, + { + "name": "task_chain", + "description": "Ordered child tasks and dependencies", + "type": "text", + "required": true, + "default": "", + "placeholder": "JRTASK-1 -> JRTASK-2 -> JRTASK-3", + "display_order": 4 + } + ], + "steps": [ + { + "name": "branch", + "display_name": "Prepare Branch", + "outputs": ["code", "documentation"], + "prompt": "Prepare the JR feature workspace.\n\nVerify the intended base branch, current git state, and whether this feature should use an isolated worktree. Record branch/worktree information and child task order in `.tickets/jr/{{ id }}/feature.md`.", + "allowed_tools": ["Read", "Write", "Glob", "Grep", "Bash"], + "artifact_patterns": [".tickets/jr/{{ id }}/feature.md"], + "next_step": "coordinate" + }, + { + "name": "coordinate", + "display_name": "Coordinate Tasks", + "outputs": ["documentation", "ticket"], + "prompt": "Coordinate this feature's child task chain.\n\nRead the task chain and current ticket state. Identify the next ready JRTASK, required handoff context, and review expectations. Update `.tickets/jr/{{ id }}/handoff.md` with current feature state and next action.", + "allowed_tools": ["Read", "Write", "Edit", "Grep"], + "artifact_patterns": [".tickets/jr/{{ id }}/handoff.md"], + "next_step": "architect" + }, + { + "name": "architect", + "display_name": "Architect Review", + "outputs": ["review"], + "prompt": "Perform a JR architect review of the feature branch.\n\nReview the whole feature diff, task handoffs, acceptance criteria, tests, and integration coherence. Look for architectural drift, incomplete task sequencing, missing validation, and dependency mistakes. Write findings to `.tickets/jr/{{ id }}/architect-review.md`.", + "allowed_tools": ["Read", "Glob", "Grep", "Bash", "Write"], + "review_type": "pr", + "artifact_patterns": [".tickets/jr/{{ id }}/architect-review.md"], + "on_reject": { + "goto_step": "coordinate", + "prompt": "Architect review requested changes: {{ rejection_reason }}\n\nCoordinate the needed JRTASK or JRREBASE work, then return to architect review." + }, + "next_step": "human" + }, + { + "name": "human", + "display_name": "Human Gate", + "outputs": ["pr", "review"], + "prompt": "Prepare this JR feature for human review.\n\nEnsure the branch is pushed, PR description is accurate, task chain is summarized, validation evidence is included, and unresolved review feedback is documented. Stop for human approval rather than merging automatically.", + "allowed_tools": ["Read", "Write", "Bash"], + "review_type": "pr", + "on_reject": { + "goto_step": "architect", + "prompt": "Human review requested changes: {{ rejection_reason }}\n\nAddress the feedback through task/rebase work and return to architect review." + } + } + ] +} diff --git a/docs/collections/jr_orchestration/JRFEAT.md b/docs/collections/jr_orchestration/JRFEAT.md new file mode 100644 index 00000000..7eca4f58 --- /dev/null +++ b/docs/collections/jr_orchestration/JRFEAT.md @@ -0,0 +1,14 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +project: {{ project }} +branch: {{ branch }} +{{#if parent_plan }}parent_plan: {{ parent_plan }} +{{/if}}--- + +# JR Feature: {{ summary }} + +## Task Chain +{{ task_chain }} diff --git a/docs/collections/jr_orchestration/JRPLAN.json b/docs/collections/jr_orchestration/JRPLAN.json new file mode 100644 index 00000000..822e6c1b --- /dev/null +++ b/docs/collections/jr_orchestration/JRPLAN.json @@ -0,0 +1,99 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "JRPLAN", + "name": "JR Plan", + "description": "Decompose a plan into JR-style features, sequential tasks, and review handoffs.", + "mode": "paired", + "glyph": "J", + "color": "blue", + "project_required": true, + "agent": "jr-architect", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "priority", + "description": "Priority level", + "type": "enum", + "required": false, + "default": "P2-medium", + "options": ["P0-critical", "P1-high", "P2-medium", "P3-low"], + "display_order": 1 + }, + { + "name": "summary", + "description": "Plan summary", + "type": "string", + "required": true, + "default": "", + "placeholder": "What should be decomposed into features and tasks?", + "max_length": 120, + "display_order": 2 + }, + { + "name": "plan_source", + "description": "Existing plan, issue, spec, or notes", + "type": "text", + "required": true, + "default": "", + "placeholder": "Paste the plan or describe where to find it.", + "display_order": 3 + }, + { + "name": "review_policy", + "description": "Review expectations for generated tasks and features", + "type": "text", + "required": false, + "default": "Every task gets code review; every feature gets architect review before human PR review.", + "display_order": 4 + } + ], + "steps": [ + { + "name": "analyze", + "display_name": "Analyze Plan", + "outputs": ["plan"], + "prompt": "Analyze the supplied plan for JR-style orchestration.\n\nRead relevant repo files and identify feature boundaries, dependency order, likely worktrees or branches, risk areas, and validation commands. Write findings to `.tickets/jr/{{ id }}/analysis.md`.", + "allowed_tools": ["Read", "Glob", "Grep", "Write"], + "artifact_patterns": [".tickets/jr/{{ id }}/analysis.md"], + "next_step": "decompose" + }, + { + "name": "decompose", + "display_name": "Decompose Work", + "outputs": ["ticket", "documentation"], + "prompt": "Create a JR work decomposition in `.tickets/jr/{{ id }}/plan.md`.\n\nDefine JRFEAT entries for feature branches/worktrees and JRTASK entries ordered within each feature. Include parent feature ids, dependencies, expected files, validation commands, and reviewer role. Keep task scopes small enough for one focused implementation pass.", + "allowed_tools": ["Read", "Write", "Edit"], + "artifact_patterns": [".tickets/jr/{{ id }}/plan.md"], + "next_step": "verify" + }, + { + "name": "verify", + "display_name": "Verify Graph", + "outputs": ["review"], + "prompt": "Review `.tickets/jr/{{ id }}/plan.md` for JR orchestration quality.\n\nEnsure each feature has a clear branch/worktree target, tasks are sequential within a feature, cross-feature dependencies are explicit, and review gates are clear. Rewrite vague tasks. Do not leave hidden ordering assumptions.", + "allowed_tools": ["Read", "Edit", "Grep"], + "review_type": "plan", + "on_reject": { + "goto_step": "decompose", + "prompt": "The JR decomposition was rejected: {{ rejection_reason }}\n\nRevise feature boundaries, task order, dependencies, or review gates." + }, + "next_step": "queue" + }, + { + "name": "queue", + "display_name": "Queue Ready Work", + "outputs": ["ticket"], + "prompt": "Prepare the first ready JR work units.\n\nCreate or describe the initial JRFEAT and JRTASK tickets that have no unmet dependencies. Record the queue decision in `.tickets/jr/{{ id }}/queue.md` so future agents can resume deterministically.", + "allowed_tools": ["Read", "Write", "Bash"], + "artifact_patterns": [".tickets/jr/{{ id }}/queue.md"] + } + ] +} diff --git a/docs/collections/jr_orchestration/JRPLAN.md b/docs/collections/jr_orchestration/JRPLAN.md new file mode 100644 index 00000000..229870f4 --- /dev/null +++ b/docs/collections/jr_orchestration/JRPLAN.md @@ -0,0 +1,16 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +project: {{ project }} +{{#if priority }}priority: {{ priority }} +{{/if}}--- + +# JR Plan: {{ summary }} + +## Plan Source +{{ plan_source }} + +## Review Policy +{{ review_policy }} diff --git a/docs/collections/jr_orchestration/JRREBASE.json b/docs/collections/jr_orchestration/JRREBASE.json new file mode 100644 index 00000000..91d19778 --- /dev/null +++ b/docs/collections/jr_orchestration/JRREBASE.json @@ -0,0 +1,94 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "JRREBASE", + "name": "JR Rebase", + "description": "Repair a JR feature branch after base changes, conflicts, or upstream API drift.", + "mode": "autonomous", + "glyph": "B", + "color": "red", + "project_required": true, + "agent": "jr-coder", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "summary", + "description": "Rebase summary", + "type": "string", + "required": true, + "default": "", + "max_length": 120, + "display_order": 1 + }, + { + "name": "feature_id", + "description": "Parent JRFEAT ticket", + "type": "string", + "required": true, + "default": "", + "display_order": 2 + }, + { + "name": "base_ref", + "description": "Target base branch or commit", + "type": "string", + "required": false, + "default": "main", + "display_order": 3 + }, + { + "name": "reason", + "description": "Why rebase or repair is needed", + "type": "text", + "required": true, + "default": "", + "display_order": 4 + } + ], + "steps": [ + { + "name": "resolve", + "display_name": "Resolve Impact", + "outputs": ["report"], + "prompt": "Investigate why JR rebase/repair is needed.\n\nRead the parent feature handoff, current branch, target base `{{ base_ref }}`, conflicts, failing tests, and upstream API changes. Write an impact report to `.tickets/jr/{{ feature_id }}/rebase-{{ id }}.md`.", + "allowed_tools": ["Read", "Glob", "Grep", "Bash", "Write"], + "artifact_patterns": [".tickets/jr/{{ feature_id }}/rebase-{{ id }}.md"], + "next_step": "rebase" + }, + { + "name": "rebase", + "display_name": "Rebase Repair", + "outputs": ["code"], + "prompt": "Perform the minimal JR rebase or repair.\n\nRebase, merge, or apply targeted fixes needed to make the feature branch coherent with `{{ base_ref }}`. Preserve the feature's intended behavior. Avoid unrelated cleanup.", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], + "next_step": "repair" + }, + { + "name": "repair", + "display_name": "Repair Drift", + "outputs": ["code", "ticket"], + "prompt": "Repair downstream task or API drift caused by the rebase.\n\nRun targeted checks, update affected task notes, and create follow-up JRTASK tickets only when the fix is too large for this rebase ticket.", + "allowed_tools": ["Read", "Write", "Edit", "Bash"], + "next_step": "review" + }, + { + "name": "review", + "display_name": "Post-Rebase Review", + "outputs": ["review", "test"], + "prompt": "Review the rebased JR feature branch.\n\nRun relevant checks and inspect the diff for accidental changes. Update the parent feature handoff with validation evidence and any remaining risk.", + "allowed_tools": ["Read", "Write", "Edit", "Bash"], + "review_type": "plan", + "on_reject": { + "goto_step": "rebase", + "prompt": "Post-rebase review requested changes: {{ rejection_reason }}\n\nRepair the rebase and rerun validation." + } + } + ] +} diff --git a/docs/collections/jr_orchestration/JRREBASE.md b/docs/collections/jr_orchestration/JRREBASE.md new file mode 100644 index 00000000..c8a5b115 --- /dev/null +++ b/docs/collections/jr_orchestration/JRREBASE.md @@ -0,0 +1,14 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +project: {{ project }} +feature_id: {{ feature_id }} +base_ref: {{ base_ref }} +--- + +# JR Rebase: {{ summary }} + +## Reason +{{ reason }} diff --git a/docs/collections/jr_orchestration/JRREV.json b/docs/collections/jr_orchestration/JRREV.json new file mode 100644 index 00000000..69f36fb8 --- /dev/null +++ b/docs/collections/jr_orchestration/JRREV.json @@ -0,0 +1,88 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "JRREV", + "name": "JR Review", + "description": "Fresh review work unit for a JR task or feature branch.", + "mode": "paired", + "glyph": "V", + "color": "yellow", + "project_required": true, + "agent": "jr-code-reviewer", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "summary", + "description": "Review target summary", + "type": "string", + "required": true, + "default": "", + "max_length": 120, + "display_order": 1 + }, + { + "name": "target_ref", + "description": "Task id, feature id, branch, commit range, or PR", + "type": "string", + "required": true, + "default": "", + "display_order": 2 + }, + { + "name": "review_focus", + "description": "Specific concerns for this review", + "type": "text", + "required": false, + "default": "", + "display_order": 3 + } + ], + "steps": [ + { + "name": "diff", + "display_name": "Inspect Diff", + "outputs": ["report"], + "prompt": "Inspect the JR review target `{{ target_ref }}`.\n\nGather the relevant diff, task/feature notes, acceptance criteria, tests, and prior review comments. Do not edit code in this step. Write review context to `.tickets/jr/reviews/{{ id }}.md`.", + "allowed_tools": ["Read", "Glob", "Grep", "Bash", "Write"], + "permission_mode": "plan", + "artifact_patterns": [".tickets/jr/reviews/{{ id }}.md"], + "next_step": "quality" + }, + { + "name": "quality", + "display_name": "Quality Review", + "outputs": ["review"], + "prompt": "Review quality for `{{ target_ref }}`.\n\nCheck correctness, missing tests, scope creep, architectural fit, compatibility, error handling, docs, and likely regressions. Focus especially on: {{ review_focus }}. Add findings to `.tickets/jr/reviews/{{ id }}.md`.", + "allowed_tools": ["Read", "Glob", "Grep", "Bash", "Write"], + "permission_mode": "plan", + "next_step": "decision" + }, + { + "name": "decision", + "display_name": "Review Decision", + "outputs": ["review"], + "prompt": "Make a clear JR review decision for `{{ target_ref }}`.\n\nWrite one of: `approved`, `changes-requested`, or `escalate`. For requested changes, provide precise file-level tasks. For escalation, explain what a human or architect must decide.", + "allowed_tools": ["Read", "Write"], + "review_type": "plan", + "on_reject": { + "goto_step": "quality", + "prompt": "The review decision was not clear enough: {{ rejection_reason }}\n\nRe-read the target and produce a sharper decision." + }, + "next_step": "handoff" + }, + { + "name": "handoff", + "display_name": "Review Handoff", + "outputs": ["documentation"], + "prompt": "Write the review handoff.\n\nUpdate `.tickets/jr/reviews/{{ id }}.md` with the final decision, required next tickets if any, suggested assignee role, and exact validation to rerun after fixes.", + "allowed_tools": ["Read", "Write", "Edit"] + } + ] +} diff --git a/docs/collections/jr_orchestration/JRREV.md b/docs/collections/jr_orchestration/JRREV.md new file mode 100644 index 00000000..eb6a305e --- /dev/null +++ b/docs/collections/jr_orchestration/JRREV.md @@ -0,0 +1,15 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +project: {{ project }} +target_ref: {{ target_ref }} +--- + +# JR Review: {{ summary }} + +{{#if review_focus }} +## Review Focus +{{ review_focus }} +{{/if}} diff --git a/docs/collections/jr_orchestration/JRTASK.json b/docs/collections/jr_orchestration/JRTASK.json new file mode 100644 index 00000000..0b8e594e --- /dev/null +++ b/docs/collections/jr_orchestration/JRTASK.json @@ -0,0 +1,114 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "JRTASK", + "name": "JR Task", + "description": "Implement one focused task inside a JR feature branch, then hand off for review.", + "mode": "autonomous", + "glyph": "T", + "color": "cyan", + "project_required": true, + "agent": "jr-coder", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "priority", + "description": "Priority level", + "type": "enum", + "required": false, + "default": "P2-medium", + "options": ["P0-critical", "P1-high", "P2-medium", "P3-low"], + "display_order": 1 + }, + { + "name": "summary", + "description": "Task summary", + "type": "string", + "required": true, + "default": "", + "placeholder": "One scoped implementation task", + "max_length": 120, + "display_order": 2 + }, + { + "name": "feature_id", + "description": "Parent JRFEAT ticket", + "type": "string", + "required": true, + "default": "", + "display_order": 3 + }, + { + "name": "dependencies", + "description": "Prior tasks or feature dependencies", + "type": "text", + "required": false, + "default": "", + "display_order": 4 + }, + { + "name": "acceptance", + "description": "Task acceptance criteria", + "type": "text", + "required": true, + "default": "", + "display_order": 5 + } + ], + "steps": [ + { + "name": "assign", + "display_name": "Assign Scope", + "outputs": ["plan"], + "prompt": "Prepare to implement this JRTASK.\n\nRead the parent feature handoff, dependencies, acceptance criteria, and relevant repo files. Write a narrow task plan to `.tickets/jr/{{ feature_id }}/tasks/{{ id }}.md`. If dependencies are unmet, stop and document the blocker.", + "allowed_tools": ["Read", "Glob", "Grep", "Write"], + "artifact_patterns": [".tickets/jr/{{ feature_id }}/tasks/{{ id }}.md"], + "next_step": "code" + }, + { + "name": "code", + "display_name": "Code Task", + "outputs": ["code"], + "prompt": "Implement only this JRTASK.\n\nStay on the parent feature branch/worktree. Keep the change small and reviewable. Do not take adjacent tasks. Update the task note with changed files and decisions.", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], + "next_step": "test" + }, + { + "name": "test", + "display_name": "Test Task", + "outputs": ["test"], + "prompt": "Validate this JRTASK.\n\nRun targeted tests and the task's required quality commands. Add or update tests for changed behavior. Record evidence in `.tickets/jr/{{ feature_id }}/tasks/{{ id }}.md`.", + "allowed_tools": ["Read", "Write", "Edit", "Bash"], + "next_step": "review" + }, + { + "name": "review", + "display_name": "Code Review", + "outputs": ["review"], + "prompt": "Perform a fresh JR code review for this task.\n\nReview the diff for correctness, scope discipline, tests, style, hidden regressions, and whether acceptance criteria are met. Write actionable findings into the task note. Approve only if it is ready for the parent feature.", + "agent": "jr-code-reviewer", + "allowed_tools": ["Read", "Glob", "Grep", "Bash", "Write"], + "review_type": "plan", + "on_reject": { + "goto_step": "code", + "prompt": "JR code review requested changes: {{ rejection_reason }}\n\nReturn to task implementation, fix the issues, and rerun tests." + }, + "next_step": "close" + }, + { + "name": "close", + "display_name": "Close Task", + "outputs": ["documentation", "code"], + "prompt": "Close this JRTASK.\n\nUpdate the parent feature handoff with task result, changed files, validation evidence, and next task readiness. Commit focused changes if this workflow uses per-task commits.", + "allowed_tools": ["Read", "Write", "Edit", "Bash"], + "artifact_patterns": [".tickets/jr/{{ feature_id }}/handoff.md"] + } + ] +} diff --git a/docs/collections/jr_orchestration/JRTASK.md b/docs/collections/jr_orchestration/JRTASK.md new file mode 100644 index 00000000..7641242a --- /dev/null +++ b/docs/collections/jr_orchestration/JRTASK.md @@ -0,0 +1,19 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +project: {{ project }} +feature_id: {{ feature_id }} +{{#if priority }}priority: {{ priority }} +{{/if}}--- + +# JR Task: {{ summary }} + +## Acceptance +{{ acceptance }} + +{{#if dependencies }} +## Dependencies +{{ dependencies }} +{{/if}} diff --git a/docs/collections/jr_orchestration/collection.json b/docs/collections/jr_orchestration/collection.json new file mode 100644 index 00000000..889dfba2 --- /dev/null +++ b/docs/collections/jr_orchestration/collection.json @@ -0,0 +1,88 @@ +{ + "schema_version": 1, + "id": "jr_orchestration", + "name": "JR Orchestration", + "description": "Feature/task orchestration with coder, reviewer, architect, and rebase work units.", + "version": "1.0.0", + "publisher": "untra", + "author": "snapwich", + "url": "https://github.com/snapwich/jr", + "license": "MIT", + "tags": [ + "agentic-loop", + "feature-graph", + "review", + "jr" + ], + "compatibility": null, + "issue_types": [ + { + "key": "JRPLAN", + "schema_path": "JRPLAN.json", + "schema_checksum": "f123c0fbf30095cfc0f42298789656bfccbc2a5bac12ce48a836820110c550ac", + "template_path": "JRPLAN.md", + "template_checksum": "d6b07074e56971a6e451cb620fe4a478740d17882140d6ad58da3209874a19f2" + }, + { + "key": "JRFEAT", + "schema_path": "JRFEAT.json", + "schema_checksum": "8de197203daf3ba5635d4cd0bdee5665372a732f0b8110ad8a92615b36906c20", + "template_path": "JRFEAT.md", + "template_checksum": "85da760b93724d4f7eb05c86ca6ae427c58c240a141a797091aa4b4bcf828160" + }, + { + "key": "JRTASK", + "schema_path": "JRTASK.json", + "schema_checksum": "48decac3204ad569f35c2b7603272db35d7084990d46d90483b326e1d718d6b6", + "template_path": "JRTASK.md", + "template_checksum": "a3029a01c71f68d505d4c209eaa022ca8f3925797bb41173d5fe78c6d5c8a6fe" + }, + { + "key": "JRREV", + "schema_path": "JRREV.json", + "schema_checksum": "e99767e4d217392b321118e3a7e7f016636437361ed94f8453667348ff1e9394", + "template_path": "JRREV.md", + "template_checksum": "2ae11b478d2c87d68a729907037d67a56385bf9580748f278e1ce67166b51e14" + }, + { + "key": "JRREBASE", + "schema_path": "JRREBASE.json", + "schema_checksum": "08772fab7390c7c2bc88f37d2584975c76173aba1c4618493662632fcd2f0dc2", + "template_path": "JRREBASE.md", + "template_checksum": "032e592b86ec4a40610e51a073af930e0aff5e34a920e0be9a2128b57c432afa" + } + ], + "workflow_hints": { + "loop_kind": "feature_task_review_graph", + "memory_surfaces": [ + ".tickets/jr/{{ id }}/plan.md", + ".tickets/jr/{{ feature_id }}/handoff.md", + "ticket parent/dependency notes" + ], + "review_gates": [ + "code_review", + "architect_review", + "human_pr_review" + ], + "external_tools": [ + "git", + "gh", + "project test command" + ], + "stop_conditions": [ + "feature PR ready for human review", + "review changes requested", + "blocked dependency documented", + "review escalated to human after repeated changes (operator stops; no auto-handoff)" + ], + "runner_semantics": "prompt_driven" + }, + "default_selected": [ + "JRPLAN", + "JRFEAT", + "JRTASK", + "JRREV", + "JRREBASE" + ], + "checksum": "df35300baa40948d6be3fd7dda31aae33c9d4e2b9031e15c1882415fb5baa12d" +} diff --git a/docs/collections/operator/AGENT-SETUP.json b/docs/collections/operator/AGENT-SETUP.json new file mode 100644 index 00000000..3826b811 --- /dev/null +++ b/docs/collections/operator/AGENT-SETUP.json @@ -0,0 +1,72 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "AGENT-SETUP", + "name": "Agent Setup", + "description": "Set up Claude agent configuration for a project", + "mode": "paired", + "glyph": "@", + "color": "blue", + "project_required": true, + "agent_prompt": "Review this project to configure a Claude agent. The agent analyzes project structure, creates or updates CLAUDE.md with project-specific context, and ensures the agent can work effectively. Output ONLY the agent system prompt.", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "summary", + "description": "Setup description", + "type": "string", + "required": true, + "default": "", + "placeholder": "Set up Claude agent for project", + "max_length": 120, + "display_order": 1 + }, + { + "name": "agent_tool", + "description": "Target agent tool (claude, aider, etc.)", + "type": "enum", + "values": ["claude", "aider", "gemini"], + "required": true, + "default": "claude", + "display_order": 2 + } + ], + "steps": [ + { + "name": "analyze_project", + "display_name": "Analyzing Project", + "outputs": ["report"], + "prompt": "Analyze the project to understand its structure and requirements:\n\n1. Read existing CLAUDE.md if present\n2. Identify project type (Rust, TypeScript, Python, etc.)\n3. Find build commands, test commands, and lint commands\n4. Identify key directories and file patterns\n5. Document findings for agent configuration", + "allowed_tools": ["Read", "Glob", "Grep"], + "next_step": "configure_agent" + }, + { + "name": "configure_agent", + "display_name": "Configuring Agent", + "outputs": ["code"], + "prompt": "Create or update agent configuration:\n\n1. Create/update CLAUDE.md with:\n - Project overview\n - Development commands (build, test, lint)\n - Key architecture concepts\n - File organization\n2. Ensure CLAUDE.md follows best practices for agent context\n3. Add any project-specific instructions", + "allowed_tools": ["Read", "Write", "Edit"], + "review_type": "plan", + "next_step": "test_agent" + }, + { + "name": "test_agent", + "display_name": "Testing Agent", + "outputs": ["report"], + "prompt": "Verify the agent configuration works:\n\n1. Confirm CLAUDE.md is readable and complete\n2. Test that documented commands work\n3. Verify key file paths are accurate\n4. Document any issues found", + "allowed_tools": ["Read", "Bash"], + "review_type": "plan", + "on_reject": { + "goto_step": "configure_agent", + "prompt": "Fix agent configuration based on feedback: {{ rejection_reason }}" + } + } + ] +} diff --git a/docs/collections/operator/AGENT-SETUP.md b/docs/collections/operator/AGENT-SETUP.md new file mode 100644 index 00000000..02dbee21 --- /dev/null +++ b/docs/collections/operator/AGENT-SETUP.md @@ -0,0 +1,14 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +{{#if agent_tool }}agent_tool: {{ agent_tool }} +{{/if}}--- + +# Agent Setup: {{ summary }} + +{{#if agent_tool }} +## Target Agent +{{ agent_tool }} +{{/if}} diff --git a/docs/collections/operator/ASSESS.json b/docs/collections/operator/ASSESS.json new file mode 100644 index 00000000..4b2c4671 --- /dev/null +++ b/docs/collections/operator/ASSESS.json @@ -0,0 +1,64 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "ASSESS", + "name": "Project Assessment", + "description": "Analyze project structure and generate project-context.json and catalog-info.yaml", + "mode": "autonomous", + "glyph": "~", + "color": "magenta", + "project_required": true, + "agent_prompt": "Review this project to create an agent for project assessment. The agent analyzes project structure, detects the project Kind from file patterns (using the 25-Kind taxonomy), identifies commands/entry points/environment variables, and generates both project-context.json (for AI agents) and catalog-info.yaml (for project catalog). Output ONLY the agent system prompt.", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "summary", + "description": "Assessment description", + "type": "string", + "required": true, + "default": "", + "placeholder": "Assess project structure", + "max_length": 120, + "display_order": 1 + }, + { + "name": "kind_override", + "description": "Override detected Kind (optional)", + "type": "string", + "required": false, + "default": "", + "placeholder": "e.g., microservice, infrastructure", + "display_order": 2 + } + ], + "steps": [ + { + "name": "analyze", + "display_name": "Analyzing", + "outputs": ["report"], + "jsonSchemaFile": ".tickets/schemas/project_analysis.schema.json", + "prompt": "Analyze the project structure and output structured JSON conforming to the schema:\n\n1. **Kind Detection**: Match file patterns against the 25-Kind taxonomy:\n - Foundation: infrastructure, identity-access, config-policy, monorepo-meta\n - Standards: design-system, software-library, proto-sdk, blueprint, security-tooling, compliance-audit\n - Engines: ml-model, data-etl, microservice, api-gateway, ui-frontend, internal-tool\n - Ecosystem: build-tool, e2e-test, docs-site, playbook, cli-devtool\n - Noncurrent: reference-example, experiment-sandbox, archival-fork, test-data-fixtures\n\n2. **Languages/Frameworks/Databases**: Detect from manifest files (package.json, Cargo.toml, requirements.txt, go.mod), imports, and file extensions.\n\n3. **Commands**: Extract from:\n - package.json scripts (start, dev, test, build, lint)\n - Makefile targets\n - Cargo.toml [[bin]] sections\n - Scripts in bin/ or scripts/\n\n4. **Entry Points**: Identify key files:\n - binary_entry: src/main.rs, index.js, main.py\n - library_entry: src/lib.rs, lib/index.js\n - config: config/*.toml, .env.example\n - routes: src/routes.rs, routes/*.ts\n - main_component: src/App.tsx, src/App.vue\n\n5. **Environment Variables**: Scan .env.example, docker-compose.yml, config files for required env vars.\n\nIf kind_override is set, use that instead of auto-detection.", + "allowed_tools": ["Read", "Glob", "Grep"], + "next_step": "generate" + }, + { + "name": "generate", + "display_name": "Generating", + "outputs": ["code"], + "prompt": "Generate output files from the analysis JSON:\n\n1. **Write `project-context.json`** (for AI agents):\n - Save the complete structured analysis JSON to project root\n - This is the primary output for AI consumption\n\n2. **Write `catalog-info.yaml`** (for project catalog):\n ```yaml\n apiVersion: backstage.io/v1alpha1\n kind: Component\n metadata:\n name: {{ project }}\n description: \n annotations:\n backstage.io/techdocs-ref: dir:.\n tags:\n - \n - \n spec:\n type: \n lifecycle: production\n owner: \n ```\n\n3. **Document assessment** in `.tickets/assessments/{{ id }}.md`:\n - Kind detected with confidence score\n - Key technologies found\n - Commands available\n - Entry points identified", + "allowed_tools": ["Read", "Write", "Edit"], + "review_type": "plan", + "on_reject": { + "goto_step": "analyze", + "prompt": "Re-analyze the project with feedback: {{ rejection_reason }}" + } + } + ] +} diff --git a/docs/collections/operator/ASSESS.md b/docs/collections/operator/ASSESS.md new file mode 100644 index 00000000..4772d288 --- /dev/null +++ b/docs/collections/operator/ASSESS.md @@ -0,0 +1,14 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +{{#if kind_override }}kind_override: {{ kind_override }} +{{/if}}--- + +# Assessment: {{ summary }} + +{{#if kind_override }} +## Kind Override +{{ kind_override }} +{{/if}} diff --git a/docs/collections/operator/INIT.json b/docs/collections/operator/INIT.json new file mode 100644 index 00000000..1c2bf388 --- /dev/null +++ b/docs/collections/operator/INIT.json @@ -0,0 +1,85 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "INIT", + "name": "Workspace Init", + "description": "Initialize workspace structure", + "mode": "paired", + "glyph": "%", + "color": "green", + "project_required": false, + "agent_prompt": "Review this workspace to create an agent for workspace initialization. The agent scaffolds the workspace directory structure in .tickets/operator/workspace/, configures project locations and authentication, and verifies tool installation. It works in paired mode with the operator for configuration decisions. Output ONLY the agent system prompt.", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "workspace", + "description": "Workspace root path", + "type": "string", + "required": true, + "default": "", + "placeholder": "Path to workspace root", + "display_order": 1 + }, + { + "name": "branding_name", + "description": "Custom branding name", + "type": "string", + "required": false, + "default": "", + "placeholder": "e.g., My Company Portal", + "display_order": 2 + }, + { + "name": "summary", + "description": "Initialization description", + "type": "string", + "required": true, + "default": "", + "placeholder": "Initialize workspace", + "max_length": 120, + "display_order": 3 + } + ], + "steps": [ + { + "name": "scaffold", + "display_name": "Scaffolding", + "outputs": ["code"], + "prompt": "Create the workspace directory structure:\n\n1. Create `.tickets/operator/workspace/` directory\n2. Generate workspace configuration files\n3. Create standard directory structure\n4. Set up branding directory with defaults\n\nAsk operator for branding preferences if not specified.", + "allowed_tools": ["Read", "Write", "Bash"], + "next_step": "configure" + }, + { + "name": "configure", + "display_name": "Configuring", + "outputs": ["code"], + "prompt": "Configure workspace for local development:\n\n1. Generate workspace configuration with:\n - File-based catalog locations\n - Local database (SQLite)\n2. Scan workspace for existing catalog-info.yaml files\n3. Add all discovered locations to config\n4. Configure branding if branding_name is set\n\nReview configuration with operator before proceeding.", + "allowed_tools": ["Read", "Write", "Glob"], + "review_type": "plan", + "on_reject": { + "goto_step": "configure", + "prompt": "Revise configuration based on feedback: {{ rejection_reason }}" + }, + "next_step": "verify" + }, + { + "name": "verify", + "display_name": "Verifying", + "outputs": ["report"], + "prompt": "Verify the workspace setup:\n\n1. Verify configuration is valid\n2. Check all referenced projects exist\n3. Document setup in `.tickets/operator/workspace/README.md`\n\nReport any issues that need manual resolution.", + "allowed_tools": ["Read", "Write", "Bash"], + "review_type": "plan", + "on_reject": { + "goto_step": "scaffold", + "prompt": "Setup verification failed. Re-scaffold with feedback: {{ rejection_reason }}" + } + } + ] +} diff --git a/docs/collections/operator/INIT.md b/docs/collections/operator/INIT.md new file mode 100644 index 00000000..05ef2b5d --- /dev/null +++ b/docs/collections/operator/INIT.md @@ -0,0 +1,18 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}workspace: {{ workspace }} +{{#if branding_name }}branding_name: {{ branding_name }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +--- + +# Workspace Init: {{ summary }} + +## Workspace +{{ workspace }} + +{{#if branding_name }} +## Branding +{{ branding_name }} +{{/if}} diff --git a/docs/collections/operator/PROJECT-INIT.json b/docs/collections/operator/PROJECT-INIT.json new file mode 100644 index 00000000..66c72848 --- /dev/null +++ b/docs/collections/operator/PROJECT-INIT.json @@ -0,0 +1,54 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "PROJECT-INIT", + "name": "Project Initialization", + "description": "Initialize project with Operator conventions", + "mode": "autonomous", + "glyph": "*", + "color": "green", + "project_required": true, + "agent_prompt": "Review this project to initialize it with Operator conventions. The agent scans project structure, creates required files, and ensures the project follows established patterns. Output ONLY the agent system prompt.", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "summary", + "description": "Initialization description", + "type": "string", + "required": true, + "default": "", + "placeholder": "Initialize project with Operator conventions", + "max_length": 120, + "display_order": 1 + } + ], + "steps": [ + { + "name": "scan_structure", + "display_name": "Scanning Structure", + "outputs": ["report"], + "prompt": "Scan the project structure to understand what's needed:\n\n1. Check for existing configuration files\n2. Identify project type and language\n3. Find any missing required files:\n - CLAUDE.md (agent context)\n - catalog-info.yaml (project catalog)\n - .tickets/ directory structure\n4. Document current state and gaps", + "allowed_tools": ["Read", "Glob", "Grep"], + "next_step": "apply_conventions" + }, + { + "name": "apply_conventions", + "display_name": "Applying Conventions", + "outputs": ["code"], + "prompt": "Apply Operator conventions to the project:\n\n1. Create missing directories:\n - .tickets/queue/\n - .tickets/in-progress/\n - .tickets/completed/\n2. Create CLAUDE.md if missing (based on project type)\n3. Create catalog-info.yaml if missing\n4. Add any standard configuration files\n5. Document all changes made", + "allowed_tools": ["Read", "Write", "Edit", "Bash"], + "review_type": "plan", + "on_reject": { + "goto_step": "scan_structure", + "prompt": "Re-scan and adjust based on feedback: {{ rejection_reason }}" + } + } + ] +} diff --git a/docs/collections/operator/PROJECT-INIT.md b/docs/collections/operator/PROJECT-INIT.md new file mode 100644 index 00000000..b2017028 --- /dev/null +++ b/docs/collections/operator/PROJECT-INIT.md @@ -0,0 +1,8 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +--- + +# Project Init: {{ summary }} diff --git a/docs/collections/operator/SYNC.json b/docs/collections/operator/SYNC.json new file mode 100644 index 00000000..519cd3b5 --- /dev/null +++ b/docs/collections/operator/SYNC.json @@ -0,0 +1,71 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "SYNC", + "name": "Catalog Sync", + "description": "Refresh catalog entries from projects", + "mode": "autonomous", + "glyph": "@", + "color": "blue", + "project_required": false, + "agent_prompt": "Review this workspace to create an agent for catalog synchronization. The agent discovers all projects with catalog-info.yaml files, validates them against the catalog schema, checks for inconsistencies, and updates catalog entries as needed. It can run across the entire workspace or target specific projects. Output ONLY the agent system prompt.", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "scope", + "description": "Sync scope", + "type": "enum", + "required": false, + "default": "all", + "options": ["all", "changed", "project"], + "display_order": 1 + }, + { + "name": "summary", + "description": "Sync description", + "type": "string", + "required": true, + "default": "", + "placeholder": "Sync catalog entries", + "max_length": 120, + "display_order": 2 + } + ], + "steps": [ + { + "name": "scan", + "display_name": "Scanning", + "outputs": ["report"], + "prompt": "Discover all catalog entries in the workspace:\n\n1. Find all catalog-info.yaml files in projects\n2. Parse each file and validate structure\n3. Build a dependency graph from relations\n4. Identify:\n - New entries (not in catalog)\n - Changed entries (modified since last sync)\n - Removed entries (catalog-info.yaml deleted)\n5. Document findings in `.tickets/syncs/{{ id }}.md`", + "allowed_tools": ["Read", "Glob", "Grep", "Write"], + "next_step": "validate" + }, + { + "name": "validate", + "display_name": "Validating", + "outputs": ["report"], + "prompt": "Validate all catalog entries:\n\n1. Check each catalog-info.yaml against schema\n2. Verify all referenced entities exist\n3. Check for circular dependencies\n4. Validate owner references\n5. Report any validation errors\n\nUpdate `.tickets/syncs/{{ id }}.md` with validation results.", + "allowed_tools": ["Read", "Write"], + "next_step": "update" + }, + { + "name": "update", + "display_name": "Updating", + "outputs": ["report"], + "prompt": "Update the project catalog:\n\n1. If catalog server is running, trigger catalog refresh\n2. If file-based, update the locations config\n3. Log all changes made\n4. Verify catalog is consistent after update\n\nComplete the sync report in `.tickets/syncs/{{ id }}.md`.", + "allowed_tools": ["Read", "Write", "Bash"], + "review_type": "plan", + "on_reject": { + "goto_step": "validate", + "prompt": "Re-validate with feedback: {{ rejection_reason }}" + } + } + ] +} diff --git a/docs/collections/operator/SYNC.md b/docs/collections/operator/SYNC.md new file mode 100644 index 00000000..6838583b --- /dev/null +++ b/docs/collections/operator/SYNC.md @@ -0,0 +1,12 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}scope: {{ scope }} +status: {{ status }} +created: {{ created_datetime }} +--- + +# Catalog Sync: {{ summary }} + +## Scope +{{ scope }} diff --git a/docs/collections/operator/collection.json b/docs/collections/operator/collection.json new file mode 100644 index 00000000..bfc8425f --- /dev/null +++ b/docs/collections/operator/collection.json @@ -0,0 +1,60 @@ +{ + "schema_version": 1, + "id": "operator", + "name": "Operator", + "description": "Operator automation tasks: ASSESS, SYNC, INIT", + "version": "1.0.0", + "publisher": "untra", + "author": "Operator!", + "url": "https://github.com/untra/operator", + "license": "MIT", + "tags": [ + "builtin", + "automation" + ], + "compatibility": null, + "issue_types": [ + { + "key": "ASSESS", + "schema_path": "ASSESS.json", + "schema_checksum": "63d218368c58df22606fbd654920edeb304ebc1bc7282501be38ae14027aa9e1", + "template_path": "ASSESS.md", + "template_checksum": "e2fb41d771ccf37af6189cdc0ef9b2752d3e1ddb1fa193f0264fbbdd432e7e0f" + }, + { + "key": "SYNC", + "schema_path": "SYNC.json", + "schema_checksum": "6e52fb05db6e09cd723becb8ffe0388957c639b39fb81efe38d7e9cb8d725e55", + "template_path": "SYNC.md", + "template_checksum": "fc5cf44f553720b44ef328c560155fda137102cee28d60505b289d35e45d79cf" + }, + { + "key": "INIT", + "schema_path": "INIT.json", + "schema_checksum": "bd7387742f9d81e49dcbe05cf25d3db0544e2f48843fb855df881d7ab094a0d6", + "template_path": "INIT.md", + "template_checksum": "ff54445fb446a8ba8155e00259ebf44860c2e8d1865df6943ceec648fe3650e8" + }, + { + "key": "AGENT-SETUP", + "schema_path": "AGENT-SETUP.json", + "schema_checksum": "c611e58617b180838b286ec0907ef11d17e25581f56699cedef4d31c1223b15a", + "template_path": "AGENT-SETUP.md", + "template_checksum": "528f9437434e5ed8f8ca2f8e09c3acb04d021e8fa2fbb978cb6aed5c005aa8f7" + }, + { + "key": "PROJECT-INIT", + "schema_path": "PROJECT-INIT.json", + "schema_checksum": "0fa43b3dbb839b0a440c2e702a9fd499ebd172bfcaaa4266a20aed7db7c90072", + "template_path": "PROJECT-INIT.md", + "template_checksum": "767a9a6481908826809222e87a36c840f759047170b0ff9d917ffa2846a19805" + } + ], + "workflow_hints": null, + "default_selected": [ + "ASSESS", + "SYNC", + "INIT" + ], + "checksum": "4f8f40dde1b85d01d5da929ff4329ea126ffbf9da94c5e5de60154101d1881bd" +} diff --git a/docs/collections/ralph_loop/PRD.json b/docs/collections/ralph_loop/PRD.json new file mode 100644 index 00000000..e8943e2a --- /dev/null +++ b/docs/collections/ralph_loop/PRD.json @@ -0,0 +1,105 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "PRD", + "name": "Product Requirements Document", + "description": "Draft, normalize, and slice a product requirements document into Ralph-style executable stories.", + "mode": "paired", + "glyph": "P", + "color": "blue", + "project_required": true, + "agent": "ralph-planner", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "priority", + "description": "Priority level", + "type": "enum", + "required": false, + "default": "P2-medium", + "options": ["P0-critical", "P1-high", "P2-medium", "P3-low"], + "display_order": 1 + }, + { + "name": "summary", + "description": "Product or feature summary", + "type": "string", + "required": true, + "default": "", + "placeholder": "What product capability should be specified?", + "max_length": 120, + "display_order": 2 + }, + { + "name": "source_notes", + "description": "Raw requirements, links, constraints, or notes", + "type": "text", + "required": false, + "default": "", + "placeholder": "Paste product notes, acceptance criteria, constraints, or relevant links.", + "display_order": 3 + }, + { + "name": "quality_commands", + "description": "Commands each story should pass before completion", + "type": "text", + "required": false, + "default": "", + "placeholder": "Example: npm test && npm run lint", + "display_order": 4 + } + ], + "steps": [ + { + "name": "draft", + "display_name": "Draft PRD", + "outputs": ["plan", "documentation"], + "prompt": "Draft a product requirements document for {{ summary }}.\n\nUse the ticket notes as source material, then inspect the project enough to make the requirements concrete. Write the human-readable PRD to `.tickets/workflows/{{ id }}/prd.md`.\n\nThe PRD must include goals, non-goals, users, constraints, risks, acceptance criteria, and a story list. Each story should be small enough for one fresh agent context.", + "allowed_tools": ["Read", "Glob", "Grep", "Write"], + "artifact_patterns": [".tickets/workflows/{{ id }}/prd.md"], + "review_type": "plan", + "on_reject": { + "goto_step": "draft", + "prompt": "The PRD draft was rejected: {{ rejection_reason }}\n\nRevise `.tickets/workflows/{{ id }}/prd.md` and tighten the story list before asking for review again." + }, + "next_step": "structure" + }, + { + "name": "structure", + "display_name": "Structure Stories", + "outputs": ["documentation"], + "prompt": "Convert `.tickets/workflows/{{ id }}/prd.md` into Ralph-style structured state at `.tickets/workflows/{{ id }}/prd.json`.\n\nThe JSON should include an ordered `stories` array. Each story needs a stable id, title, description, acceptance criteria, dependencies, and `passes: false`. Include a `quality_commands` field from the ticket when provided.\n\nDo not mark any story complete yet.", + "allowed_tools": ["Read", "Write", "Edit"], + "artifact_patterns": [".tickets/workflows/{{ id }}/prd.json"], + "next_step": "slice" + }, + { + "name": "slice", + "display_name": "Slice Check", + "outputs": ["review"], + "prompt": "Review `.tickets/workflows/{{ id }}/prd.json` for story size and execution order.\n\nA good Ralph story can be completed independently by a fresh agent using only the PRD, progress file, repo context, and current ticket. Split any story that is too broad. Make dependencies explicit. If a story is ambiguous, update the PRD and JSON rather than relying on future memory.", + "allowed_tools": ["Read", "Edit", "Grep"], + "review_type": "plan", + "on_reject": { + "goto_step": "draft", + "prompt": "The story slicing review found problems: {{ rejection_reason }}\n\nRevise the PRD and structured story list so each story is independently executable." + }, + "next_step": "ready" + }, + { + "name": "ready", + "display_name": "Initialize Progress", + "outputs": ["documentation", "ticket"], + "prompt": "Initialize Ralph loop state for this PRD.\n\nWrite `.tickets/workflows/{{ id }}/progress.txt` with: project context, quality commands, current branch, how to select the next story, and any constraints a fresh STORY agent must know. Add a short note explaining that each STORY ticket should complete exactly one story and update `prd.json` plus `progress.txt`.\n\nIf useful, create the first STORY ticket for the first incomplete story.", + "allowed_tools": ["Read", "Write", "Bash"], + "artifact_patterns": [".tickets/workflows/{{ id }}/progress.txt"] + } + ] +} diff --git a/docs/collections/ralph_loop/PRD.md b/docs/collections/ralph_loop/PRD.md new file mode 100644 index 00000000..8e8fd554 --- /dev/null +++ b/docs/collections/ralph_loop/PRD.md @@ -0,0 +1,20 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +project: {{ project }} +{{#if priority }}priority: {{ priority }} +{{/if}}--- + +# PRD: {{ summary }} + +{{#if source_notes }} +## Source Notes +{{ source_notes }} +{{/if}} + +{{#if quality_commands }} +## Quality Commands +{{ quality_commands }} +{{/if}} diff --git a/docs/collections/ralph_loop/RLOOP.json b/docs/collections/ralph_loop/RLOOP.json new file mode 100644 index 00000000..40a5d9ee --- /dev/null +++ b/docs/collections/ralph_loop/RLOOP.json @@ -0,0 +1,95 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "RLOOP", + "name": "Ralph Loop Coordinator", + "description": "Coordinate repeated single-story Ralph iterations until the PRD is complete.", + "mode": "paired", + "glyph": "R", + "color": "magenta", + "project_required": true, + "agent": "ralph-coordinator", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "summary", + "description": "Loop summary", + "type": "string", + "required": true, + "default": "", + "placeholder": "Complete the PRD story loop", + "max_length": 120, + "display_order": 1 + }, + { + "name": "prd_path", + "description": "Path to prd.json", + "type": "string", + "required": true, + "default": ".tickets/workflows/{{ id }}/prd.json", + "placeholder": ".tickets/workflows/PRD-1234/prd.json", + "display_order": 2 + }, + { + "name": "max_iterations", + "description": "Advisory cap on story iterations before stopping for review (Ralph default 10). The exported workflow's review loop is bounded independently.", + "type": "integer", + "required": false, + "default": "10", + "display_order": 3 + } + ], + "steps": [ + { + "name": "preflight", + "display_name": "Loop Preflight", + "outputs": ["plan"], + "prompt": "Preflight the Ralph loop.\n\nVerify `{{ prd_path }}` exists, find its sibling `progress.txt`, inspect git status, read quality commands, and count unfinished stories. Write a short loop plan to `.tickets/workflows/{{ id }}/loop.md`.\n\nIf state is missing, repair it or stop with exact instructions.", + "allowed_tools": ["Read", "Glob", "Grep", "Write", "Bash"], + "artifact_patterns": [".tickets/workflows/{{ id }}/loop.md"], + "review_type": "plan", + "on_reject": { + "goto_step": "preflight", + "prompt": "Loop preflight was rejected: {{ rejection_reason }}\n\nFix the loop state and update the plan." + }, + "next_step": "iterate" + }, + { + "name": "iterate", + "display_name": "Run One Story", + "outputs": ["ticket", "code"], + "prompt": "Run exactly one Ralph story iteration.\n\nSelect the next story with `passes: false` from `{{ prd_path }}`. Prefer creating or launching a STORY ticket that references this loop and story id. If operating directly, follow the STORY issue type discipline: implement one story, verify it, update progress, and mark only that story as passing.\n\nStop after one story iteration.", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], + "artifact_patterns": ["{{ prd_path }}"], + "next_step": "check" + }, + { + "name": "check", + "display_name": "Completion Check", + "outputs": ["review"], + "prompt": "Check Ralph loop completion.\n\nRead `{{ prd_path }}` and determine whether every story has `passes: true`. If all stories pass, summarize completion and proceed. If unfinished stories remain and `max_iterations` is not exhausted, request another iteration by rejecting this step with clear next-story instructions. If blocked, document the blocker and stop for human input.", + "allowed_tools": ["Read", "Grep", "Write"], + "review_type": "plan", + "on_reject": { + "goto_step": "iterate", + "prompt": "The PRD is not complete yet or the previous story needs correction: {{ rejection_reason }}\n\nRun one more STORY iteration, then return to completion check." + }, + "next_step": "complete" + }, + { + "name": "complete", + "display_name": "Complete Loop", + "outputs": ["report"], + "prompt": "Write the final Ralph loop report to `.tickets/workflows/{{ id }}/summary.md`.\n\nInclude completed stories, commits or changed files, validation evidence, lessons learned, and any deferred follow-up work. Do not create new broad work unless explicitly asked.", + "allowed_tools": ["Read", "Write", "Grep", "Bash"], + "artifact_patterns": [".tickets/workflows/{{ id }}/summary.md"] + } + ] +} diff --git a/docs/collections/ralph_loop/RLOOP.md b/docs/collections/ralph_loop/RLOOP.md new file mode 100644 index 00000000..d2af29a4 --- /dev/null +++ b/docs/collections/ralph_loop/RLOOP.md @@ -0,0 +1,11 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +project: {{ project }} +prd_path: {{ prd_path }} +max_iterations: {{ max_iterations }} +--- + +# Ralph Loop: {{ summary }} diff --git a/docs/collections/ralph_loop/STORY.json b/docs/collections/ralph_loop/STORY.json new file mode 100644 index 00000000..f6e98213 --- /dev/null +++ b/docs/collections/ralph_loop/STORY.json @@ -0,0 +1,116 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "STORY", + "name": "Ralph Story", + "description": "Execute one Ralph story in a fresh context, then persist progress for the next iteration.", + "mode": "autonomous", + "glyph": "S", + "color": "green", + "project_required": true, + "agent": "ralph-coder", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "priority", + "description": "Priority level", + "type": "enum", + "required": false, + "default": "P2-medium", + "options": ["P0-critical", "P1-high", "P2-medium", "P3-low"], + "display_order": 1 + }, + { + "name": "summary", + "description": "Story title", + "type": "string", + "required": true, + "default": "", + "placeholder": "One story from prd.json", + "max_length": 120, + "display_order": 2 + }, + { + "name": "workflow_id", + "description": "PRD/RLOOP workflow id whose prd.json owns this story", + "type": "string", + "required": true, + "default": "", + "placeholder": "PRD-1234 or RLOOP-1234", + "display_order": 3 + }, + { + "name": "story_id", + "description": "Story id inside prd.json", + "type": "string", + "required": false, + "default": "", + "placeholder": "story-001", + "display_order": 4 + }, + { + "name": "notes", + "description": "Additional constraints or human guidance", + "type": "text", + "required": false, + "default": "", + "display_order": 5 + } + ], + "steps": [ + { + "name": "select", + "display_name": "Select Story", + "outputs": ["ticket", "plan"], + "prompt": "Read `.tickets/workflows/{{ workflow_id }}/prd.json` and `.tickets/workflows/{{ workflow_id }}/progress.txt`.\n\nIf `story_id` is set, select that story. Otherwise select the first highest-priority story where `passes` is false and dependencies are satisfied. Write the selected story details and planned files to `.tickets/workflows/{{ workflow_id }}/stories/{{ id }}.md`.\n\nDo not start broad work. This ticket is responsible for exactly one story.", + "allowed_tools": ["Read", "Glob", "Grep", "Write"], + "artifact_patterns": [".tickets/workflows/{{ workflow_id }}/stories/{{ id }}.md"], + "next_step": "implement" + }, + { + "name": "implement", + "display_name": "Implement Story", + "outputs": ["code"], + "prompt": "Implement only the selected story recorded in `.tickets/workflows/{{ workflow_id }}/stories/{{ id }}.md`.\n\nUse fresh context discipline: read the PRD, progress file, selected story, and relevant repo files. Avoid opportunistic adjacent work. If the story is blocked or too large, stop and document the blocker instead of widening scope.", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], + "next_step": "verify" + }, + { + "name": "verify", + "display_name": "Verify Story", + "outputs": ["test"], + "prompt": "Verify the story is actually complete.\n\nRun the quality commands from `.tickets/workflows/{{ workflow_id }}/prd.json` and `.tickets/workflows/{{ workflow_id }}/progress.txt` when present. Add or update tests where the story changes behavior. Record evidence in `.tickets/workflows/{{ workflow_id }}/stories/{{ id }}.md`.\n\nFix failures before proceeding.", + "allowed_tools": ["Read", "Write", "Edit", "Bash"], + "review_type": "plan", + "on_reject": { + "goto_step": "implement", + "prompt": "Story verification was rejected: {{ rejection_reason }}\n\nReturn to the implementation, fix the issue, and rerun the quality checks." + }, + "next_step": "learn" + }, + { + "name": "learn", + "display_name": "Persist Learnings", + "outputs": ["documentation"], + "prompt": "Persist memory for the next fresh context.\n\nUpdate `.tickets/workflows/{{ workflow_id }}/progress.txt` with what changed, quality evidence, important files, surprises, and any reusable repo knowledge. If the repo has `AGENTS.md`, `CLAUDE.md`, or similar guidance files and you learned stable reusable instructions, update them conservatively.", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep"], + "artifact_patterns": [".tickets/workflows/{{ workflow_id }}/progress.txt"], + "next_step": "commit" + }, + { + "name": "commit", + "display_name": "Mark Passing", + "outputs": ["code", "documentation"], + "prompt": "Finalize this one-story iteration.\n\nUpdate `.tickets/workflows/{{ workflow_id }}/prd.json` so the selected story has `passes: true`, a concise result note, and validation evidence. Commit focused changes if this repo uses commits for agent progress. Do not mark unrelated stories complete.", + "allowed_tools": ["Read", "Write", "Edit", "Bash"], + "artifact_patterns": [".tickets/workflows/{{ workflow_id }}/prd.json"] + } + ] +} diff --git a/docs/collections/ralph_loop/STORY.md b/docs/collections/ralph_loop/STORY.md new file mode 100644 index 00000000..20a83066 --- /dev/null +++ b/docs/collections/ralph_loop/STORY.md @@ -0,0 +1,16 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +project: {{ project }} +workflow_id: {{ workflow_id }} +{{#if story_id }}story_id: {{ story_id }} +{{/if}}--- + +# Ralph Story: {{ summary }} + +{{#if notes }} +## Notes +{{ notes }} +{{/if}} diff --git a/docs/collections/ralph_loop/collection.json b/docs/collections/ralph_loop/collection.json new file mode 100644 index 00000000..b75a4d14 --- /dev/null +++ b/docs/collections/ralph_loop/collection.json @@ -0,0 +1,71 @@ +{ + "schema_version": 1, + "id": "ralph_loop", + "name": "Ralph Loop", + "description": "PRD-to-story loop for completing one right-sized story per fresh agent context.", + "version": "1.0.0", + "publisher": "untra", + "author": "snarktank", + "url": "https://github.com/snarktank/ralph", + "license": "MIT", + "tags": [ + "agentic-loop", + "prd", + "stories", + "ralph" + ], + "compatibility": null, + "issue_types": [ + { + "key": "PRD", + "schema_path": "PRD.json", + "schema_checksum": "6d70b60b8c3986d4782c5e8c5e453c1b9dc44234cb2bbd054498231945435ce1", + "template_path": "PRD.md", + "template_checksum": "c719e1af7c715cdac7d59ec8586d06c612debfbf8c560d5be3ae738499fe9518" + }, + { + "key": "STORY", + "schema_path": "STORY.json", + "schema_checksum": "7af1d443e7083d08c860a51a6524fec969c99812e6ee51d181058d7b8e92bd0e", + "template_path": "STORY.md", + "template_checksum": "340c5fda8830dd79fbe8432dc6921d1556e145d657933b8575f1092013bd9f54" + }, + { + "key": "RLOOP", + "schema_path": "RLOOP.json", + "schema_checksum": "edb318f1adf22fb6ce18ebff5b99f3e741ff8aee911e34d4864bd756e038ab8d", + "template_path": "RLOOP.md", + "template_checksum": "c29422d0a45c58aa22aee9d010f9ea38d3ecaf47940e53611b082ac180194fb3" + } + ], + "workflow_hints": { + "loop_kind": "fresh_context_story_loop", + "memory_surfaces": [ + ".tickets/workflows/{{ id }}/prd.json", + ".tickets/workflows/{{ id }}/progress.txt", + "AGENTS.md" + ], + "review_gates": [ + "plan_review", + "test_suite", + "story_completion_check" + ], + "external_tools": [ + "git", + "project test command" + ], + "stop_conditions": [ + "all stories have passes=true", + "blocked story documented", + "quality gates fail repeatedly", + "max_iterations reached (advisory; outer story loop is operator-queue-driven)" + ], + "runner_semantics": "prompt_driven" + }, + "default_selected": [ + "PRD", + "STORY", + "RLOOP" + ], + "checksum": "e2cf02402f73f87c6547e87e90ae3aeef47d6add5c23935b716085e5e9d3703b" +} diff --git a/docs/collections/schema.json b/docs/collections/schema.json new file mode 100644 index 00000000..d889f9dc --- /dev/null +++ b/docs/collections/schema.json @@ -0,0 +1,77 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://operator.untra.io/collections/schema.json", + "title": "Operator Hosted Collection Manifest", + "description": "Format for a shareable, hostable issuetype collection (collection.json). A collection references per-issuetype schema files (issuetype_schema.json format) by relative path, each with a SHA-256 checksum. This same format is used for the offline collections embedded in the operator binary.", + "type": "object", + "required": ["schema_version", "id", "name", "issue_types"], + "additionalProperties": false, + "properties": { + "schema_version": { + "type": "integer", + "const": 1, + "description": "Manifest schema version. Unknown versions are rejected by the fetcher (offline fallback used)." + }, + "id": { "type": "string", "description": "Stable collection id (e.g. dev_kanban)." }, + "name": { "type": "string", "description": "Display name." }, + "description": { "type": "string", "description": "One-line description." }, + "version": { "type": "string", "description": "Collection semver." }, + "publisher": { "type": ["string", "null"], "description": "Publisher identifier (e.g. untra)." }, + "author": { "type": ["string", "null"], "description": "Human author/attribution shown in the setup picker. Built-ins are authored by 'Operator!'; a kanban-imported collection lists the provider name + workspace/project." }, + "url": { "type": ["string", "null"], "description": "Link to the collection's source (GitHub repo or project page)." }, + "license": { "type": ["string", "null"], "description": "SPDX license id." }, + "tags": { "type": "array", "items": { "type": "string" } }, + "compatibility": { + "type": ["object", "null"], + "additionalProperties": false, + "properties": { + "operator_version": { + "type": ["string", "null"], + "description": "Minimum operator version this collection targets (e.g. >=0.2.0)." + } + } + }, + "issue_types": { + "type": "array", + "description": "Issue types in this collection, in display order.", + "items": { + "type": "object", + "required": ["key", "schema_path"], + "additionalProperties": false, + "properties": { + "key": { "type": "string", "description": "Issue type key (e.g. TASK)." }, + "schema_path": { "type": "string", "description": "Path to the issuetype JSON, relative to the manifest." }, + "schema_checksum": { "type": "string", "description": "SHA-256 (lowercase hex) of the issuetype JSON bytes. Required for hosted manifests; omitted for embedded ones." }, + "template_path": { "type": ["string", "null"], "description": "Optional path to the markdown template, relative to the manifest." }, + "template_checksum": { "type": ["string", "null"], "description": "SHA-256 (lowercase hex) of the markdown template bytes, if present." } + } + } + }, + "workflow_hints": { + "type": ["object", "null"], + "description": "Descriptive metadata about the collection's intended agentic loop shape. v1 is metadata only: stored and displayed but not executed.", + "additionalProperties": false, + "properties": { + "loop_kind": { "type": ["string", "null"], "description": "Loop shape (e.g. single_pass, ralph, review_loop)." }, + "memory_surfaces": { "type": "array", "items": { "type": "string" } }, + "review_gates": { "type": "array", "items": { "type": "string" } }, + "external_tools": { "type": "array", "items": { "type": "string" } }, + "stop_conditions": { "type": "array", "items": { "type": "string" } }, + "runner_semantics": { + "type": "string", + "const": "prompt_driven", + "description": "How the runner interprets the hints. v1 is always prompt_driven." + } + } + }, + "default_selected": { + "type": "array", + "items": { "type": "string" }, + "description": "Subset of issue_types[].key selected by default in the setup picker." + }, + "checksum": { + "type": ["string", "null"], + "description": "SHA-256 (lowercase hex) derived from the issue-type file checksums (schema_checksum + template_checksum joined by newlines, in issue_types order)." + } + } +} diff --git a/docs/collections/simple/TASK.json b/docs/collections/simple/TASK.json new file mode 100644 index 00000000..04b95bd2 --- /dev/null +++ b/docs/collections/simple/TASK.json @@ -0,0 +1,75 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "TASK", + "name": "Task", + "description": "Focused task that executes one specific thing", + "mode": "autonomous", + "glyph": ">", + "color": "cyan", + "project_required": false, + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "priority", + "description": "Ticket priority level", + "type": "enum", + "required": false, + "default": "P2-medium", + "options": ["P0-critical", "P1-high", "P2-medium", "P3-low"], + "display_order": 1 + }, + { + "name": "summary", + "description": "One-line description of the task", + "type": "string", + "required": true, + "default": "", + "placeholder": "Brief description of the task", + "max_length": 120, + "display_order": 2 + }, + { + "name": "description", + "description": "Detailed description of the task", + "type": "text", + "required": true, + "default": "", + "placeholder": "Full description of what needs to be done", + "display_order": 3 + }, + { + "name": "points", + "description": "Story points estimate", + "type": "integer", + "required": false, + "default": "0", + "display_order": 4 + }, + { + "name": "user_story", + "description": "User story or background context", + "type": "text", + "required": false, + "default": "", + "placeholder": "As a USER, I want to X, so I can Y", + "display_order": 5 + } + ], + "steps": [ + { + "name": "execute", + "display_name": "Executing", + "outputs": ["code", "report"], + "prompt": "Execute this task:\n\n1. Read the ticket summary and context\n2. Explore the codebase as needed\n3. Complete the task as specified\n4. Document what was done\n\nThis is a focused task - complete it directly without creating follow-up tickets or plans.", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"] + } + ] +} diff --git a/docs/collections/simple/TASK.md b/docs/collections/simple/TASK.md new file mode 100644 index 00000000..b764c240 --- /dev/null +++ b/docs/collections/simple/TASK.md @@ -0,0 +1,26 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +{{#if priority }}priority: {{ priority }} +{{/if}}{{#if points }}points: {{ points }} +{{/if}}--- + +# Task: {{ summary }} + +## Description +{{ description }} + +{{#if user_story }} +## User Story +{{ user_story }} +{{/if}} + +{{#if acceptance_criteria }} +## Acceptance Criteria +{{ acceptance_criteria }} +{{/if}} + +## Plan +*Plan will be written to `.tickets/plans/{{ id }}.md`* diff --git a/docs/collections/simple/collection.json b/docs/collections/simple/collection.json new file mode 100644 index 00000000..79d68938 --- /dev/null +++ b/docs/collections/simple/collection.json @@ -0,0 +1,29 @@ +{ + "schema_version": 1, + "id": "simple", + "name": "Simple", + "description": "Simple workflow with TASK only", + "version": "1.0.0", + "publisher": "untra", + "author": "Operator!", + "url": "https://github.com/untra/operator", + "license": "MIT", + "tags": [ + "builtin" + ], + "compatibility": null, + "issue_types": [ + { + "key": "TASK", + "schema_path": "TASK.json", + "schema_checksum": "f654229454da050ca038c273cc74884c2dbe68f09b293eee3764f23d6771b939", + "template_path": "TASK.md", + "template_checksum": "7a71bf692588af1d1ac5d9192edd64316353ada616e22019dfff7683ab5d09c4" + } + ], + "workflow_hints": null, + "default_selected": [ + "TASK" + ], + "checksum": "8f8083c35c5573254ac0f4b75e542557bdf759664e0aaeaea244753d0e392c83" +} diff --git a/docs/configuration/index.md b/docs/configuration/index.md index daca344f..5512a70c 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -105,6 +105,9 @@ Issue type collections and presets | `preset` | → `CollectionPreset` | - | Named preset for issue type collection Options: simple, `dev_kanban`, `devops_kanban`, custom | | `collection` | `array`[`string`] | - | Custom issuetype collection (only used when preset = custom) List of issue type keys: TASK, FEAT, FIX, SPIKE, INV | | `active_collection` | `string` \| `null` | - | Active collection name (overrides preset if set) Can be a builtin preset name or a user-defined collection | +| `collections_fetch_enabled` | `boolean` | - | Enable fetching hosted issuetype collections during setup. When disabled, only the embedded (offline) collections are offered. | +| `collections_manifest_url` | `string` \| `null` | - | URL of the hosted collection index manifest, fetched during setup. Points at a `CollectionIndex` JSON document listing available collections. | +| `collections_fetch_timeout_secs` | `integer` | - | Timeout in seconds for hosted collection fetch HTTP requests. | ## `[api]` @@ -224,6 +227,9 @@ enabled = false [templates] preset = "dev_kanban" collection = [] +collections_fetch_enabled = true +collections_manifest_url = "https://operator.untra.io/collections/index.json" +collections_fetch_timeout_secs = 5 [api] pr_check_interval_secs = 60 diff --git a/docs/schemas/config.json b/docs/schemas/config.json index c0b8bd4f..ffd1bd28 100644 --- a/docs/schemas/config.json +++ b/docs/schemas/config.json @@ -594,6 +594,26 @@ "null" ], "default": null + }, + "collections_fetch_enabled": { + "description": "Enable fetching hosted issuetype collections during setup.\nWhen disabled, only the embedded (offline) collections are offered.", + "type": "boolean", + "default": true + }, + "collections_manifest_url": { + "description": "URL of the hosted collection index manifest, fetched during setup.\nPoints at a `CollectionIndex` JSON document listing available collections.", + "type": [ + "string", + "null" + ], + "default": "https://operator.untra.io/collections/index.json" + }, + "collections_fetch_timeout_secs": { + "description": "Timeout in seconds for hosted collection fetch HTTP requests.", + "type": "integer", + "format": "uint64", + "minimum": 0, + "default": 5 } } }, diff --git a/docs/schemas/config.md b/docs/schemas/config.md index 596d8ce1..36fec321 100644 --- a/docs/schemas/config.md +++ b/docs/schemas/config.md @@ -175,6 +175,9 @@ YOLO (auto-accept) mode configuration for fully autonomous execution | `preset` | → `CollectionPreset` | No | Named preset for issue type collection Options: simple, `dev_kanban`, `devops_kanban`, custom | | `collection` | `array` | No | Custom issuetype collection (only used when preset = custom) List of issue type keys: TASK, FEAT, FIX, SPIKE, INV | | `active_collection` | `string` \| `null` | No | Active collection name (overrides preset if set) Can be a builtin preset name or a user-defined collection | +| `collections_fetch_enabled` | `boolean` | No | Enable fetching hosted issuetype collections during setup. When disabled, only the embedded (offline) collections are offered. | +| `collections_manifest_url` | `string` \| `null` | No | URL of the hosted collection index manifest, fetched during setup. Points at a `CollectionIndex` JSON document listing available collections. | +| `collections_fetch_timeout_secs` | `integer` | No | Timeout in seconds for hosted collection fetch HTTP requests. | ### CollectionPreset diff --git a/docs/schemas/index.md b/docs/schemas/index.md index 36638ee0..29c82e04 100644 --- a/docs/schemas/index.md +++ b/docs/schemas/index.md @@ -31,6 +31,8 @@ Machine-readable JSON Schema files for validation and code generation: | [config.json](config.json) | JSON Schema | Configuration file schema (generated via schemars) | | [state.json](state.json) | JSON Schema | Runtime state file schema (generated via schemars) | | [openapi.json](openapi.json) | OpenAPI 3.0 | REST API specification (generated via utoipa) | +| [collections/schema.json](../collections/schema.json) | JSON Schema | Hosted issuetype collection manifest format (collection.json) | +| [collections/index.json](../collections/index.json) | JSON | Index of hosted issuetype collections (fetched during setup) | ## TypeScript Types diff --git a/docs/schemas/openapi.json b/docs/schemas/openapi.json index 86c5fd82..2a2347dc 100644 --- a/docs/schemas/openapi.json +++ b/docs/schemas/openapi.json @@ -2688,11 +2688,36 @@ "name": { "type": "string" }, + "publisher": { + "type": [ + "string", + "null" + ], + "description": "Publisher identifier (present for hosted collections)." + }, "types": { "type": "array", "items": { "type": "string" } + }, + "version": { + "type": [ + "string", + "null" + ], + "description": "Collection semver (present for hosted collections)." + }, + "workflow_hints": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/WorkflowHintsDto", + "description": "Descriptive workflow hints (present for hosted collections)." + } + ] } } }, @@ -5990,6 +6015,48 @@ "agnt" ] }, + "WorkflowHintsDto": { + "type": "object", + "description": "Descriptive workflow hints for a collection (v1: metadata only).", + "required": [ + "runner_semantics" + ], + "properties": { + "external_tools": { + "type": "array", + "items": { + "type": "string" + } + }, + "loop_kind": { + "type": [ + "string", + "null" + ] + }, + "memory_surfaces": { + "type": "array", + "items": { + "type": "string" + } + }, + "review_gates": { + "type": "array", + "items": { + "type": "string" + } + }, + "runner_semantics": { + "type": "string" + }, + "stop_conditions": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "WorkflowPreviewResponse": { "type": "object", "description": "Response for a *preview* workflow generated from an issue type alone (no\nconcrete ticket). Used by the UI to visualize an issue type's workflow shape.", diff --git a/shared/types.ts b/shared/types.ts index af3a1fa7..36d9d393 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -744,7 +744,21 @@ collection: Array, * Active collection name (overrides preset if set) * Can be a builtin preset name or a user-defined collection */ -active_collection: string | null, }; +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 = { /** @@ -899,7 +913,21 @@ export type UpdateStepRequest = { display_name: string | null, prompt: string | */ review_type: string | null, next_step: string | null, permission_mode: string | null, }; -export type CollectionResponse = { name: string, description: string, types: Array, is_active: boolean, }; +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, +/** + * 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, /** diff --git a/src/api/providers/kanban/mod.rs b/src/api/providers/kanban/mod.rs index bf78804f..c8cbbcf0 100644 --- a/src/api/providers/kanban/mod.rs +++ b/src/api/providers/kanban/mod.rs @@ -413,6 +413,59 @@ impl DetectedKanbanProvider { } } +/// The product of importing a provider's issue types into an operator collection. +/// +/// The `author`/`url` here populate the corresponding fields on the generated +/// collection manifest so an imported collection is attributed to its provider +/// workspace/project rather than to `Operator!`. +#[derive(Debug, Clone)] +pub struct ImportedCollection { + /// Author attribution (provider name + workspace, optionally `/ project`). + pub author: String, + /// Provider base URL. + pub url: String, + /// Imported issue type keys, in display order. + pub types: Vec, +} + +/// Structural import of issue types from a kanban provider into an operator +/// collection. +/// +/// The author attribution is implemented today; the actual fetch + conversion +/// (`get_issue_types` -> [`IssueType::new_imported`](crate::issuetypes::IssueType) +/// -> collection manifest) is deferred (scaffold). +pub trait IssueTypeImportSource { + /// Human author attribution for a collection imported from this source, + /// e.g. `Jira Cloud (acme.atlassian.net) / PROJ`. + fn author_attribution(&self, project: &str) -> String; + + /// Import the provider's issue types as an operator collection. Deferred: + /// structural conversion is not yet implemented and this returns an error. + fn import_collection(&self, project: &str) -> anyhow::Result; +} + +impl IssueTypeImportSource for DetectedKanbanProvider { + fn author_attribution(&self, project: &str) -> String { + if project.is_empty() { + format!("{} ({})", self.provider_type.display_name(), self.domain) + } else { + format!( + "{} ({}) / {}", + self.provider_type.display_name(), + self.domain, + project + ) + } + } + + fn import_collection(&self, _project: &str) -> anyhow::Result { + anyhow::bail!( + "importing issue types from {} is not yet implemented", + self.provider_type.display_name() + ) + } +} + /// Detect kanban providers from environment variables /// /// Scans for `OPERATOR_JIRA_*` and `OPERATOR_LINEAR_*` environment variables @@ -808,6 +861,40 @@ mod tests { ); } + #[test] + fn test_import_source_author_attribution() { + let provider = DetectedKanbanProvider { + provider_type: KanbanProviderType::Jira, + domain: "acme.atlassian.net".to_string(), + email: Some("dev@acme.com".to_string()), + env_vars_found: vec![], + status: ProviderStatus::Valid, + }; + // Without a project, attribution is provider name + workspace. + assert_eq!( + provider.author_attribution(""), + "Jira Cloud (acme.atlassian.net)" + ); + // With a project, the project/team is appended. + assert_eq!( + provider.author_attribution("PROJ"), + "Jira Cloud (acme.atlassian.net) / PROJ" + ); + } + + #[test] + fn test_import_collection_is_deferred() { + let provider = DetectedKanbanProvider { + provider_type: KanbanProviderType::Linear, + domain: "acme".to_string(), + email: None, + env_vars_found: vec![], + status: ProviderStatus::Valid, + }; + // Structural import is scaffolded but not yet implemented. + assert!(provider.import_collection("Team").is_err()); + } + #[test] fn test_detected_provider_has_required_env_vars_jira_complete() { let provider = DetectedKanbanProvider { diff --git a/src/app/keyboard.rs b/src/app/keyboard.rs index 9bc6ad89..f77b034a 100644 --- a/src/app/keyboard.rs +++ b/src/app/keyboard.rs @@ -35,12 +35,22 @@ impl App { SetupResult::Cancel => { self.should_quit = true; } - SetupResult::ExitUnimplemented(message) => { - self.exit_message = Some(message); - self.should_quit = true; - } SetupResult::Continue => { - // Moved to next step - stay in setup + // On entering the hosted picker, fetch the list (with + // embedded fallback) so the UI can render it. + if matches!( + setup.step, + crate::ui::setup::SetupStep::HostedCollectionFetch + ) && !setup.hosted_loaded + { + let templates = &self.config.templates; + let url = templates + .collections_fetch_enabled + .then(|| templates.collections_manifest_url.clone()) + .flatten(); + let timeout = templates.collections_fetch_timeout_secs; + setup.load_hosted_collections(url.as_deref(), timeout).await; + } } } } diff --git a/src/app/tickets.rs b/src/app/tickets.rs index fa3d1cd3..8a699f45 100644 --- a/src/app/tickets.rs +++ b/src/app/tickets.rs @@ -82,6 +82,38 @@ impl App { fs::write(&schema_filepath, filtered_schema)?; } + // If the user picked hosted collections, scaffold each into its own + // collection-scoped directory (manifest + verified issuetype files). The + // loader discovers every templates//collection.json; when exactly one + // was chosen it also becomes the active collection. + let hosted: Vec<_> = self + .setup_screen + .as_ref() + .map(|s| { + s.selected_hosted_collections() + .into_iter() + .map(|r| (r.manifest.clone(), r.files.clone())) + .collect() + }) + .unwrap_or_default(); + for (manifest, files) in &hosted { + let dir = tickets_path.join("templates").join(&manifest.id); + fs::create_dir_all(&dir)?; + fs::write( + dir.join("collection.json"), + format!("{}\n", manifest.to_json()?), + )?; + for (key, schema_json, template_md) in files { + fs::write(dir.join(format!("{key}.json")), schema_json)?; + if let Some(md) = template_md { + fs::write(dir.join(format!("{key}.md")), md)?; + } + } + } + if let [single] = hosted.as_slice() { + self.config.templates.active_collection = Some(single.0.id.clone()); + } + // Generate tmux configuration files self.generate_tmux_config()?; @@ -95,6 +127,19 @@ impl App { self.config.projects = discovered_projects.clone(); self.config.save()?; + // Reload the issue type registry so the chosen collection is active + // without requiring a restart (mirrors App::new's load path). + let mut registry = crate::issuetypes::IssueTypeRegistry::new(); + if let Err(e) = registry.load_all(&tickets_path) { + tracing::warn!("Failed to reload issue types after setup: {}", e); + } + if let Some(ref active) = self.config.templates.active_collection { + if let Err(e) = registry.activate_collection(active) { + tracing::warn!("Failed to activate collection '{}': {}", active, e); + } + } + self.issue_type_registry = registry; + // Update the create dialog with discovered projects self.create_dialog.set_projects(discovered_projects.clone()); diff --git a/src/bin/generate_types.rs b/src/bin/generate_types.rs index 6dfb4b76..c1e34848 100644 --- a/src/bin/generate_types.rs +++ b/src/bin/generate_types.rs @@ -42,7 +42,8 @@ use operator::rest::dto::{ CreateTicketResponse, DelegatorLaunchConfigDto, DelegatorResponse, DelegatorsResponse, FieldResponse, HealthResponse, IssueTypeResponse, IssueTypeSummary, KanbanProviderCatalogEntry, SectionDto, SectionRowDto, SkillEntry, SkillsResponse, StatusResponse, StepResponse, - UpdateIssueTypeRequest, UpdateStepRequest, WorkflowExportResponse, WorkflowPreviewResponse, + UpdateIssueTypeRequest, UpdateStepRequest, WorkflowExportResponse, WorkflowHintsDto, + WorkflowPreviewResponse, }; use operator::state::{AgentState, CompletedTicket, State}; use operator::types::{ @@ -141,6 +142,7 @@ fn generate_typescript() -> String { CreateStepRequest::decl(&cfg), UpdateStepRequest::decl(&cfg), CollectionResponse::decl(&cfg), + WorkflowHintsDto::decl(&cfg), HealthResponse::decl(&cfg), StatusResponse::decl(&cfg), SectionDto::decl(&cfg), diff --git a/src/collections/dev_kanban/collection.json b/src/collections/dev_kanban/collection.json new file mode 100644 index 00000000..407d6af7 --- /dev/null +++ b/src/collections/dev_kanban/collection.json @@ -0,0 +1,26 @@ +{ + "schema_version": 1, + "id": "dev_kanban", + "name": "Dev Kanban", + "description": "Developer kanban with TASK, FEAT, FIX", + "version": "1.0.0", + "publisher": "untra", + "author": "Operator!", + "url": "https://github.com/untra/operator", + "license": "MIT", + "tags": ["builtin", "kanban", "dev"], + "issue_types": [ + { "key": "TASK", "schema_path": "TASK.json", "template_path": "TASK.md" }, + { "key": "FEAT", "schema_path": "FEAT.json", "template_path": "FEAT.md" }, + { "key": "FIX", "schema_path": "FIX.json", "template_path": "FIX.md" } + ], + "workflow_hints": { + "loop_kind": "single_pass", + "memory_surfaces": ["ticket"], + "review_gates": ["test_suite"], + "external_tools": ["git"], + "stop_conditions": ["tests_green"], + "runner_semantics": "prompt_driven" + }, + "default_selected": ["TASK", "FEAT", "FIX"] +} diff --git a/src/collections/dev_kanban/collection.toml b/src/collections/dev_kanban/collection.toml deleted file mode 100644 index 551964ca..00000000 --- a/src/collections/dev_kanban/collection.toml +++ /dev/null @@ -1,5 +0,0 @@ -name = "dev_kanban" -description = "Developer kanban with TASK, FEAT, FIX" - -# Issue types in this collection (display order) -types = ["TASK", "FEAT", "FIX"] diff --git a/src/collections/devops_kanban/collection.json b/src/collections/devops_kanban/collection.json new file mode 100644 index 00000000..bbcd3e08 --- /dev/null +++ b/src/collections/devops_kanban/collection.json @@ -0,0 +1,28 @@ +{ + "schema_version": 1, + "id": "devops_kanban", + "name": "DevOps Kanban", + "description": "DevOps kanban with TASK, FEAT, FIX, SPIKE, INV", + "version": "1.0.0", + "publisher": "untra", + "author": "Operator!", + "url": "https://github.com/untra/operator", + "license": "MIT", + "tags": ["builtin", "kanban", "devops"], + "issue_types": [ + { "key": "TASK", "schema_path": "TASK.json", "template_path": "TASK.md" }, + { "key": "FEAT", "schema_path": "FEAT.json", "template_path": "FEAT.md" }, + { "key": "FIX", "schema_path": "FIX.json", "template_path": "FIX.md" }, + { "key": "SPIKE", "schema_path": "SPIKE.json", "template_path": "SPIKE.md" }, + { "key": "INV", "schema_path": "INV.json", "template_path": "INV.md" } + ], + "workflow_hints": { + "loop_kind": "review_loop", + "memory_surfaces": ["ticket", "scratchpad"], + "review_gates": ["human", "test_suite"], + "external_tools": ["git", "ci"], + "stop_conditions": ["tests_green", "review_approved"], + "runner_semantics": "prompt_driven" + }, + "default_selected": ["TASK", "FEAT", "FIX", "SPIKE", "INV"] +} diff --git a/src/collections/devops_kanban/collection.toml b/src/collections/devops_kanban/collection.toml deleted file mode 100644 index 303e3553..00000000 --- a/src/collections/devops_kanban/collection.toml +++ /dev/null @@ -1,5 +0,0 @@ -name = "devops_kanban" -description = "DevOps kanban with TASK, FEAT, FIX, SPIKE, INV" - -# Issue types in this collection (display order) -types = ["TASK", "FEAT", "FIX", "SPIKE", "INV"] diff --git a/src/collections/elves_overnight/ELVBATCH.json b/src/collections/elves_overnight/ELVBATCH.json new file mode 100644 index 00000000..e4185cfd --- /dev/null +++ b/src/collections/elves_overnight/ELVBATCH.json @@ -0,0 +1,149 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "ELVBATCH", + "name": "Elves Batch", + "description": "Execute one independently shippable Elves batch with validation, review, and checkpointing.", + "mode": "autonomous", + "glyph": "B", + "color": "green", + "project_required": true, + "agent": "elves-coder", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "summary", + "description": "Batch summary", + "type": "string", + "required": true, + "default": "", + "placeholder": "One batch from survival guide", + "max_length": 120, + "display_order": 1 + }, + { + "name": "batch_goal", + "description": "Exact goal for this batch", + "type": "text", + "required": true, + "default": "", + "display_order": 2 + }, + { + "name": "validation_commands", + "description": "Commands required for this batch", + "type": "text", + "required": false, + "default": "", + "display_order": 3 + }, + { + "name": "continue_after_batch", + "description": "Whether the agent may suggest another batch after this one", + "type": "bool", + "required": false, + "default": "true", + "display_order": 4 + } + ], + "steps": [ + { + "name": "reread", + "display_name": "Reread Memory", + "outputs": ["plan"], + "prompt": "Start the Elves batch by rereading durable memory.\n\nRead `docs/elves/survival-guide.md`, `docs/elves/execution-log.md`, `docs/elves/learnings.md`, `.elves-session.json`, active PR state if present, and the batch goal. Write a short batch plan to `docs/elves/batches/{{ id }}.md`.", + "allowed_tools": ["Read", "Glob", "Grep", "Write", "Bash"], + "artifact_patterns": ["docs/elves/batches/{{ id }}.md"], + "next_step": "tag" + }, + { + "name": "tag", + "display_name": "Checkpoint Start", + "outputs": ["documentation"], + "prompt": "Create a pre-batch checkpoint.\n\nRecord current git status, branch, commit, PR status, and intended batch scope in `docs/elves/batches/{{ id }}.md`. If using git tags or commits for checkpoints, create the appropriate lightweight checkpoint.", + "allowed_tools": ["Read", "Write", "Edit", "Bash"], + "next_step": "implement" + }, + { + "name": "implement", + "display_name": "Implement Batch", + "outputs": ["code"], + "prompt": "Implement exactly this Elves batch: {{ batch_goal }}\n\nStay inside the batch boundary. Update the execution log as decisions are made. If scope expands, stop and document rather than continuing silently.", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], + "next_step": "validate" + }, + { + "name": "validate", + "display_name": "Validate Batch", + "outputs": ["test"], + "prompt": "Validate this Elves batch.\n\nRun touched-surface proof first, then the validation commands from the ticket and survival guide. Fix failures only when the fix is within batch scope. Record exact commands and results in `docs/elves/batches/{{ id }}.md`.", + "allowed_tools": ["Read", "Write", "Edit", "Bash"], + "review_type": "plan", + "on_reject": { + "goto_step": "implement", + "prompt": "Batch validation was rejected: {{ rejection_reason }}\n\nRepair the batch within scope and rerun validation." + }, + "next_step": "review" + }, + { + "name": "review", + "display_name": "Fresh Review", + "outputs": ["review"], + "prompt": "Perform a fresh review of this batch.\n\nRead the diff, execution log, validation evidence, and current PR comments/checks if any. Focus on correctness, regression risk, scope creep, and missing validation. Write findings to `docs/elves/batches/{{ id }}.md`.", + "agent": "elves-reviewer", + "allowed_tools": ["Read", "Glob", "Grep", "Bash", "Write"], + "review_type": "plan", + "on_reject": { + "goto_step": "implement", + "prompt": "Fresh review requested changes: {{ rejection_reason }}\n\nFix the review findings and repeat validation." + }, + "next_step": "judge" + }, + { + "name": "judge", + "display_name": "Judge Verdict", + "outputs": ["review"], + "prompt": "Judge whether the batch is safe to checkpoint.\n\nRead any project constitution, invariants, survival guide, diff, tests, and review notes. Emit pass, warn, or fail in `docs/elves/batches/{{ id }}.md`. Fail means return to implementation. Warn means checkpoint but clearly document residual risk.", + "agent": "elves-judge", + "allowed_tools": ["Read", "Glob", "Grep", "Write"], + "permission_mode": "plan", + "review_type": "plan", + "on_reject": { + "goto_step": "implement", + "prompt": "Judge verdict failed or was rejected: {{ rejection_reason }}\n\nAddress the unsafe condition or stop with a blocker." + }, + "next_step": "document" + }, + { + "name": "document", + "display_name": "Persist Memory", + "outputs": ["documentation"], + "prompt": "Persist durable memory after this batch.\n\nUpdate `docs/elves/execution-log.md`, `docs/elves/learnings.md`, `docs/elves/survival-guide.md`, and `.elves-session.json` with batch result, validation, decisions, risks, and exact resume instructions.", + "allowed_tools": ["Read", "Write", "Edit"], + "next_step": "push" + }, + { + "name": "push", + "display_name": "Checkpoint Push", + "outputs": ["pr", "code"], + "prompt": "Checkpoint this Elves batch.\n\nCommit focused changes if appropriate, push the branch when configured, update the PR if one exists, and record commit/push status in the batch log. Do not merge.", + "allowed_tools": ["Read", "Write", "Bash"], + "next_step": "entropy" + }, + { + "name": "entropy", + "display_name": "Continue Or Stop", + "outputs": ["report", "ticket"], + "prompt": "Decide whether to continue after this Elves batch.\n\nConsider `continue_after_batch`, remaining plan, time budget, validation confidence, review status, and risk. If continuing is safe, create or recommend the next ELVBATCH. If not, stop with exact human next steps.", + "allowed_tools": ["Read", "Write", "Bash"], + "artifact_patterns": ["docs/elves/batches/{{ id }}.md"] + } + ] +} diff --git a/src/collections/elves_overnight/ELVBATCH.md b/src/collections/elves_overnight/ELVBATCH.md new file mode 100644 index 00000000..cedcbbda --- /dev/null +++ b/src/collections/elves_overnight/ELVBATCH.md @@ -0,0 +1,18 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +project: {{ project }} +continue_after_batch: {{ continue_after_batch }} +--- + +# Elves Batch: {{ summary }} + +## Batch Goal +{{ batch_goal }} + +{{#if validation_commands }} +## Validation Commands +{{ validation_commands }} +{{/if}} diff --git a/src/collections/elves_overnight/ELVRPT.json b/src/collections/elves_overnight/ELVRPT.json new file mode 100644 index 00000000..fbe4b95b --- /dev/null +++ b/src/collections/elves_overnight/ELVRPT.json @@ -0,0 +1,71 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "ELVRPT", + "name": "Elves Report", + "description": "Produce a morning-after report for an Elves run.", + "mode": "paired", + "glyph": "R", + "color": "yellow", + "project_required": true, + "agent": "elves-coordinator", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "summary", + "description": "Report summary", + "type": "string", + "required": true, + "default": "", + "max_length": 120, + "display_order": 1 + }, + { + "name": "report_format", + "description": "Report output format", + "type": "enum", + "required": false, + "default": "markdown", + "options": ["markdown", "html"], + "display_order": 2 + } + ], + "steps": [ + { + "name": "gather", + "display_name": "Gather Run State", + "outputs": ["report"], + "prompt": "Gather all Elves run state.\n\nRead execution log, survival guide, learnings, batch logs, git commits, PR/check status, and any blockers. Record source paths in `docs/elves/report-{{ id }}.md`.", + "allowed_tools": ["Read", "Glob", "Grep", "Bash", "Write"], + "next_step": "summarize" + }, + { + "name": "summarize", + "display_name": "Write Report", + "outputs": ["report", "documentation"], + "prompt": "Write the morning-after Elves report.\n\nInclude final status, timeline, completed batches, validation evidence, PR status, unresolved issues, decisions made, lessons learned, risks, and concrete human next steps. Use the requested format: {{ report_format }}.", + "allowed_tools": ["Read", "Write", "Edit"], + "artifact_patterns": ["docs/elves/report-{{ id }}.md"], + "next_step": "review" + }, + { + "name": "review", + "display_name": "Report Review", + "outputs": ["review"], + "prompt": "Review the Elves report for accuracy.\n\nCross-check claims against logs, commits, tests, PR status, and remaining blockers. Fix overstatements or missing risks before handing to the user.", + "allowed_tools": ["Read", "Edit", "Grep", "Bash"], + "review_type": "plan", + "on_reject": { + "goto_step": "summarize", + "prompt": "The report was rejected: {{ rejection_reason }}\n\nCorrect the report and review it again." + } + } + ] +} diff --git a/src/collections/elves_overnight/ELVRPT.md b/src/collections/elves_overnight/ELVRPT.md new file mode 100644 index 00000000..da8a3314 --- /dev/null +++ b/src/collections/elves_overnight/ELVRPT.md @@ -0,0 +1,10 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +project: {{ project }} +report_format: {{ report_format }} +--- + +# Elves Report: {{ summary }} diff --git a/src/collections/elves_overnight/ELVSTAGE.json b/src/collections/elves_overnight/ELVSTAGE.json new file mode 100644 index 00000000..8dc61c1b --- /dev/null +++ b/src/collections/elves_overnight/ELVSTAGE.json @@ -0,0 +1,96 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "ELVSTAGE", + "name": "Elves Stage", + "description": "Prepare a long-running Elves session without starting risky implementation work.", + "mode": "paired", + "glyph": "E", + "color": "blue", + "project_required": true, + "agent": "elves-coordinator", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "summary", + "description": "Run summary", + "type": "string", + "required": true, + "default": "", + "placeholder": "Prepare overnight work plan", + "max_length": 120, + "display_order": 1 + }, + { + "name": "run_goal", + "description": "Goal for the unattended run", + "type": "text", + "required": true, + "default": "", + "display_order": 2 + }, + { + "name": "validation_commands", + "description": "Commands that prove the run is safe", + "type": "text", + "required": false, + "default": "", + "display_order": 3 + }, + { + "name": "time_budget_hours", + "description": "Maximum intended runtime in hours", + "type": "integer", + "required": false, + "default": "8", + "display_order": 4 + } + ], + "steps": [ + { + "name": "orient", + "display_name": "Orient", + "outputs": ["plan"], + "prompt": "Orient for an Elves-style long-running session.\n\nRead the run goal, repo state, active branch, tests, PR state, existing docs, and risks. Identify exact batches that can be completed independently. Write the orientation to `docs/elves/execution-log.md` and `.elves-session.json`.", + "allowed_tools": ["Read", "Glob", "Grep", "Write", "Bash"], + "artifact_patterns": ["docs/elves/execution-log.md", ".elves-session.json"], + "next_step": "contract" + }, + { + "name": "contract", + "display_name": "Write Run Contract", + "outputs": ["documentation"], + "prompt": "Write the durable memory contract for the Elves run.\n\nCreate or refresh `docs/elves/survival-guide.md`, `docs/elves/learnings.md`, `docs/elves/execution-log.md`, and `.elves-session.json`. Include goal, non-goals, stop rules, validation commands, branch/PR details, batch list, current risks, and how to resume after sleep or context loss.", + "allowed_tools": ["Read", "Write", "Edit"], + "artifact_patterns": ["docs/elves/survival-guide.md", "docs/elves/learnings.md", "docs/elves/execution-log.md", ".elves-session.json"], + "next_step": "preflight" + }, + { + "name": "preflight", + "display_name": "Preflight", + "outputs": ["test", "report"], + "prompt": "Run Elves preflight.\n\nVerify clean or intentionally dirty git state, remote/auth, base branch, validation commands, PR status if any, sleep/session readiness, collision risks, and whether unattended work is appropriate. Record exact readiness and blockers in `docs/elves/execution-log.md`.", + "allowed_tools": ["Read", "Write", "Edit", "Bash"], + "next_step": "stage" + }, + { + "name": "stage", + "display_name": "Stage Review", + "outputs": ["review"], + "prompt": "Stop for staging review.\n\nSummarize the launch command, first batch, stop conditions, validation gates, and known risks. Do not begin implementation in this ticket. This ticket is successful when the run is ready for an ELVBATCH launch.", + "allowed_tools": ["Read", "Write"], + "review_type": "plan", + "on_reject": { + "goto_step": "contract", + "prompt": "The Elves stage review requested changes: {{ rejection_reason }}\n\nRevise the run contract and preflight before asking for launch approval again." + } + } + ] +} diff --git a/src/collections/elves_overnight/ELVSTAGE.md b/src/collections/elves_overnight/ELVSTAGE.md new file mode 100644 index 00000000..d9a4577a --- /dev/null +++ b/src/collections/elves_overnight/ELVSTAGE.md @@ -0,0 +1,18 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +project: {{ project }} +time_budget_hours: {{ time_budget_hours }} +--- + +# Elves Stage: {{ summary }} + +## Run Goal +{{ run_goal }} + +{{#if validation_commands }} +## Validation Commands +{{ validation_commands }} +{{/if}} diff --git a/src/collections/elves_overnight/LANDPR.json b/src/collections/elves_overnight/LANDPR.json new file mode 100644 index 00000000..b2c72cda --- /dev/null +++ b/src/collections/elves_overnight/LANDPR.json @@ -0,0 +1,109 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "LANDPR", + "name": "Land Pull Request", + "description": "Run the Elves PR landing loop: collect feedback, fix blockers, prove, wait, and land only when safe.", + "mode": "paired", + "glyph": "L", + "color": "magenta", + "project_required": true, + "agent": "elves-reviewer", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "summary", + "description": "PR landing summary", + "type": "string", + "required": true, + "default": "", + "max_length": 120, + "display_order": 1 + }, + { + "name": "pr_url", + "description": "Pull request URL or number", + "type": "string", + "required": true, + "default": "", + "display_order": 2 + }, + { + "name": "allow_merge", + "description": "Whether the agent may merge when all gates pass", + "type": "bool", + "required": false, + "default": "false", + "display_order": 3 + } + ], + "steps": [ + { + "name": "collect", + "display_name": "Collect PR State", + "outputs": ["report"], + "prompt": "Collect complete PR state for {{ pr_url }}.\n\nRead PR description, comments, reviews, checks, requested changes, branch state, and local diff. Write state to `docs/elves/pr-landing-{{ id }}.md`.", + "allowed_tools": ["Read", "Glob", "Grep", "Bash", "Write"], + "artifact_patterns": ["docs/elves/pr-landing-{{ id }}.md"], + "next_step": "fresh" + }, + { + "name": "fresh", + "display_name": "Fresh Review", + "outputs": ["review"], + "prompt": "Perform a fresh read-only review of the PR diff.\n\nCompare base to HEAD, check review comments against current code, and identify true blockers versus already-fixed comments. Update `docs/elves/pr-landing-{{ id }}.md`.", + "allowed_tools": ["Read", "Glob", "Grep", "Bash", "Write"], + "permission_mode": "plan", + "next_step": "fix" + }, + { + "name": "fix", + "display_name": "Fix Blockers", + "outputs": ["code"], + "prompt": "Fix only PR landing blockers.\n\nAddress unresolved requested changes, failing checks, and fresh-review blockers. Avoid new feature work. If a blocker is unsafe or ambiguous, stop and document the exact human decision needed.", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], + "next_step": "prove" + }, + { + "name": "prove", + "display_name": "Prove Ready", + "outputs": ["test"], + "prompt": "Prove the PR is ready to land.\n\nRun targeted tests and broader sensible checks. Re-read PR checks and review status. Update landing notes with exact evidence.", + "allowed_tools": ["Read", "Write", "Bash"], + "review_type": "plan", + "on_reject": { + "goto_step": "fix", + "prompt": "PR proof failed or was rejected: {{ rejection_reason }}\n\nFix blockers and prove again." + }, + "next_step": "wait" + }, + { + "name": "wait", + "display_name": "Wait And Re-read", + "outputs": ["review"], + "prompt": "Wait for and re-read asynchronous PR surfaces.\n\nRefresh checks, reviews, bot comments, and inline threads. If new blockers appear, reject back to fix. If no blockers remain, proceed to land gate.", + "allowed_tools": ["Read", "Bash", "Write"], + "review_type": "plan", + "on_reject": { + "goto_step": "fix", + "prompt": "New PR feedback appeared: {{ rejection_reason }}\n\nAddress it before attempting to land." + }, + "next_step": "land" + }, + { + "name": "land", + "display_name": "Land Gate", + "outputs": ["pr"], + "prompt": "Land the PR only if safe.\n\nIf `allow_merge` is false, stop with a final ready-to-merge report. If true, merge only when the worktree is clean, checks are green, approvals are sufficient, no requested changes remain, and the branch is current. Record the result.", + "allowed_tools": ["Read", "Bash", "Write"], + "review_type": "pr" + } + ] +} diff --git a/src/collections/elves_overnight/LANDPR.md b/src/collections/elves_overnight/LANDPR.md new file mode 100644 index 00000000..91f20307 --- /dev/null +++ b/src/collections/elves_overnight/LANDPR.md @@ -0,0 +1,11 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +project: {{ project }} +pr_url: {{ pr_url }} +allow_merge: {{ allow_merge }} +--- + +# Land PR: {{ summary }} diff --git a/src/collections/elves_overnight/collection.json b/src/collections/elves_overnight/collection.json new file mode 100644 index 00000000..ccde1304 --- /dev/null +++ b/src/collections/elves_overnight/collection.json @@ -0,0 +1,27 @@ +{ + "schema_version": 1, + "id": "elves_overnight", + "name": "Elves Overnight", + "description": "Long-running staged batch workflow with durable memory, validation, PR review, and reporting.", + "version": "1.0.0", + "publisher": "untra", + "author": "Aigora", + "url": "https://github.com/aigorahub/elves", + "license": "MIT", + "tags": ["agentic-loop", "overnight", "batch", "elves"], + "issue_types": [ + { "key": "ELVSTAGE", "schema_path": "ELVSTAGE.json", "template_path": "ELVSTAGE.md" }, + { "key": "ELVBATCH", "schema_path": "ELVBATCH.json", "template_path": "ELVBATCH.md" }, + { "key": "LANDPR", "schema_path": "LANDPR.json", "template_path": "LANDPR.md" }, + { "key": "ELVRPT", "schema_path": "ELVRPT.json", "template_path": "ELVRPT.md" } + ], + "workflow_hints": { + "loop_kind": "staged_long_running_batch_loop", + "memory_surfaces": ["docs/elves/survival-guide.md", "docs/elves/execution-log.md", "docs/elves/learnings.md", ".elves-session.json", "PR comments and checks"], + "review_gates": ["stage_review", "batch_validation", "fresh_review", "judge_verdict", "human_land_gate"], + "external_tools": ["git", "gh", "project test command", "optional browser or visual review command"], + "stop_conditions": ["batch complete and checkpointed", "validation cannot be repaired safely", "PR has unresolved requested changes", "time/risk budget exhausted"], + "runner_semantics": "prompt_driven" + }, + "default_selected": ["ELVSTAGE", "ELVBATCH", "LANDPR", "ELVRPT"] +} diff --git a/src/collections/fetch.rs b/src/collections/fetch.rs new file mode 100644 index 00000000..7052c43a --- /dev/null +++ b/src/collections/fetch.rs @@ -0,0 +1,455 @@ +//! Fetching and integrity verification for hosted collections. +//! +//! The pure helpers in this module ([`sha256_hex`], [`verify_files`], +//! [`derive_manifest_checksum`]) are HTTP-free so they can be unit tested and +//! reused by both the docs producer (which computes checksums) and the runtime +//! fetcher (which verifies them). The async fetch functions live alongside them. + +use std::collections::{HashMap, HashSet}; +use std::time::Duration; + +use anyhow::{anyhow, Result}; +use sha2::{Digest, Sha256}; + +use crate::collections::manifest::{ + CollectionIndex, CollectionIndexEntry, CollectionManifest, IssueTypeEntry, SCHEMA_VERSION, +}; +use crate::collections::{get_embedded_collection, EmbeddedCollection, EMBEDDED_COLLECTIONS}; + +/// Compute the lowercase-hex SHA-256 of `bytes`. +pub fn sha256_hex(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let mut out = String::with_capacity(64); + for b in digest { + use std::fmt::Write as _; + let _ = write!(out, "{b:02x}"); + } + out +} + +/// Derive a manifest-level checksum from the per-issuetype file checksums. +/// +/// SHA-256 over the issue-type checksums concatenated in `issue_types` order: +/// each entry contributes its `schema_checksum`, then its `template_checksum` +/// if present, every value on its own line. The docs producer and the runtime +/// verifier MUST compute this identically. +pub fn derive_manifest_checksum(entries: &[IssueTypeEntry]) -> String { + let mut parts: Vec<&str> = Vec::with_capacity(entries.len() * 2); + for e in entries { + parts.push(&e.schema_checksum); + if let Some(tc) = &e.template_checksum { + parts.push(tc); + } + } + sha256_hex(parts.join("\n").as_bytes()) +} + +/// Verify that every issue-type file's bytes match the checksum declared in its +/// manifest entry. `files` is keyed by the entry's relative path +/// (`schema_path` / `template_path`). +/// +/// Returns `Err` on the first mismatch or missing file. Callers treat any error +/// as a verification failure and fall back to the embedded copy; unverified +/// bytes are never persisted. +pub fn verify_files(entries: &[IssueTypeEntry], files: &HashMap>) -> Result<()> { + for e in entries { + let schema = files + .get(&e.schema_path) + .ok_or_else(|| anyhow!("missing file for {} ({})", e.key, e.schema_path))?; + let actual = sha256_hex(schema); + if actual != e.schema_checksum { + return Err(anyhow!( + "checksum mismatch for {} ({}): expected {}, got {}", + e.key, + e.schema_path, + e.schema_checksum, + actual + )); + } + if let (Some(path), Some(expected)) = (&e.template_path, &e.template_checksum) { + let template = files + .get(path) + .ok_or_else(|| anyhow!("missing template for {} ({})", e.key, path))?; + let actual = sha256_hex(template); + if &actual != expected { + return Err(anyhow!( + "template checksum mismatch for {} ({}): expected {expected}, got {actual}", + e.key, + path + )); + } + } + } + Ok(()) +} + +/// Resolve a path relative to `base_url` by replacing the last URL segment. +/// +/// `resolve_url("https://x/collections/index.json", "dev_kanban/collection.json")` +/// -> `https://x/collections/dev_kanban/collection.json`. +pub fn resolve_url(base_url: &str, relative: &str) -> String { + match base_url.rfind('/') { + Some(idx) => format!("{}/{}", &base_url[..idx], relative), + None => relative.to_string(), + } +} + +/// A fully fetched and verified collection: its manifest plus the verified +/// bytes of each issue-type schema (and optional template). +pub struct FetchedCollection { + pub manifest: CollectionManifest, + /// (key, `schema_json`, `template_md`) for each issue type, in manifest order. + pub files: Vec<(String, String, Option)>, +} + +fn http_client(timeout_secs: u64) -> Result { + Ok(reqwest::Client::builder() + .timeout(Duration::from_secs(timeout_secs)) + .build()?) +} + +async fn get_bytes(client: &reqwest::Client, url: &str) -> Result> { + let response = client.get(url).send().await?; + let status = response.status(); + if !status.is_success() { + return Err(anyhow!("non-success status {} for {url}", status.as_u16())); + } + Ok(response.bytes().await?.to_vec()) +} + +/// Fetch the collection index. Returns `None` on any network/parse error or an +/// unknown schema version, so callers fall back to the embedded collections. +pub async fn fetch_index(url: &str, timeout_secs: u64) -> Option { + let client = http_client(timeout_secs) + .map_err(|e| tracing::debug!(error = %e, "collection index client build failed")) + .ok()?; + let bytes = get_bytes(&client, url) + .await + .map_err(|e| tracing::debug!(error = %e, "collection index fetch failed")) + .ok()?; + let index: CollectionIndex = serde_json::from_slice(&bytes) + .map_err(|e| tracing::debug!(error = %e, "collection index parse failed")) + .ok()?; + if index.schema_version != SCHEMA_VERSION { + tracing::debug!( + version = index.schema_version, + "unsupported collection index schema version" + ); + return None; + } + Some(index) +} + +/// Fetch and verify a single collection referenced by `entry`. +/// +/// Verifies the manifest bytes against `entry.checksum`, rejects unknown schema +/// versions, and verifies every issue-type file against its declared checksum. +/// Any failure returns `Err`; callers fall back to the embedded copy and never +/// persist unverified bytes. +pub async fn fetch_collection( + index_url: &str, + entry: &CollectionIndexEntry, + timeout_secs: u64, +) -> Result { + let client = http_client(timeout_secs)?; + + // 1. Manifest, verified against the index entry checksum. + let manifest_url = resolve_url(index_url, &entry.manifest_path); + let manifest_bytes = get_bytes(&client, &manifest_url).await?; + let actual = sha256_hex(&manifest_bytes); + if actual != entry.checksum { + return Err(anyhow!( + "manifest checksum mismatch for {}: expected {}, got {actual}", + entry.id, + entry.checksum + )); + } + let manifest = CollectionManifest::from_json(&String::from_utf8(manifest_bytes)?)?; + if manifest.schema_version != SCHEMA_VERSION { + return Err(anyhow!( + "unsupported manifest schema version {} for {}", + manifest.schema_version, + entry.id + )); + } + + // 2. Fetch every issue-type file; collect bytes keyed by relative path. + let mut raw: HashMap> = HashMap::new(); + for it in &manifest.issue_types { + let schema_url = resolve_url(&manifest_url, &it.schema_path); + raw.insert( + it.schema_path.clone(), + get_bytes(&client, &schema_url).await?, + ); + if let Some(template_path) = &it.template_path { + let template_url = resolve_url(&manifest_url, template_path); + raw.insert( + template_path.clone(), + get_bytes(&client, &template_url).await?, + ); + } + } + + // 3. Verify all checksums before trusting any bytes. + verify_files(&manifest.issue_types, &raw)?; + + // 4. Project into UTF-8 file payloads in manifest order. + let mut files = Vec::with_capacity(manifest.issue_types.len()); + for it in &manifest.issue_types { + let schema_json = String::from_utf8( + raw.get(&it.schema_path) + .cloned() + .ok_or_else(|| anyhow!("missing fetched bytes for {}", it.schema_path))?, + )?; + let template_md = match &it.template_path { + Some(p) => Some(String::from_utf8( + raw.get(p) + .cloned() + .ok_or_else(|| anyhow!("missing fetched bytes for {p}"))?, + )?), + None => None, + }; + files.push((it.key.clone(), schema_json, template_md)); + } + + Ok(FetchedCollection { manifest, files }) +} + +/// Where a resolved collection's definition came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CollectionOrigin { + /// Fetched from the hosted manifest and checksum-verified. + Hosted, + /// Loaded from the embedded (offline) copy baked into the binary. + Embedded, +} + +/// A collection ready to present in the setup picker and scaffold from. +pub struct ResolvedCollection { + pub manifest: CollectionManifest, + /// (key, `schema_json`, `template_md`) in manifest order. + pub files: Vec<(String, String, Option)>, + pub origin: CollectionOrigin, + /// Why we fell back to embedded, if applicable (e.g. checksum failure). + pub note: Option, +} + +/// Build a [`FetchedCollection`] from an embedded collection, computing +/// checksums from the compiled-in bytes. Infallible in practice (embedded +/// manifests are validated by tests) but returns `Result` for symmetry. +pub fn embedded_fetched(embedded: &EmbeddedCollection) -> Result { + let mut manifest = embedded + .manifest_parsed() + .map_err(|e| anyhow!("parsing embedded manifest for {}: {e}", embedded.name))?; + let mut files = Vec::with_capacity(manifest.issue_types.len()); + for entry in &mut manifest.issue_types { + let it = embedded + .issuetypes + .iter() + .find(|it| it.key == entry.key) + .ok_or_else(|| anyhow!("embedded file missing for {}", entry.key))?; + entry.schema_checksum = sha256_hex(it.schema_json.as_bytes()); + let template = entry.template_path.as_ref().map(|_| { + entry.template_checksum = Some(sha256_hex(it.template_md.as_bytes())); + it.template_md.to_string() + }); + files.push((entry.key.clone(), it.schema_json.to_string(), template)); + } + manifest.checksum = Some(derive_manifest_checksum(&manifest.issue_types)); + Ok(FetchedCollection { manifest, files }) +} + +/// Resolve the collections to offer in the setup picker. +/// +/// Attempts to fetch the hosted index (when a URL is provided) and verifies each +/// collection; on any per-collection failure it falls back to the embedded copy. +/// Every embedded collection not covered by the index is appended, so the picker +/// is never empty even fully offline. +pub async fn resolve_for_setup( + manifest_url: Option<&str>, + timeout_secs: u64, +) -> Vec { + let mut out: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + + if let Some(url) = manifest_url { + if let Some(index) = fetch_index(url, timeout_secs).await { + for entry in &index.collections { + match fetch_collection(url, entry, timeout_secs).await { + Ok(fc) => { + seen.insert(fc.manifest.id.clone()); + out.push(ResolvedCollection { + manifest: fc.manifest, + files: fc.files, + origin: CollectionOrigin::Hosted, + note: None, + }); + } + Err(e) => { + // Verification/network failure: fall back to embedded if we have it. + if let Some(embedded) = get_embedded_collection(&entry.id) { + if let Ok(fc) = embedded_fetched(embedded) { + seen.insert(entry.id.clone()); + out.push(ResolvedCollection { + manifest: fc.manifest, + files: fc.files, + origin: CollectionOrigin::Embedded, + note: Some(e.to_string()), + }); + } + } + } + } + } + } + } + + // Append embedded collections not provided by the hosted index. + for embedded in EMBEDDED_COLLECTIONS { + if seen.contains(embedded.name) { + continue; + } + if let Ok(fc) = embedded_fetched(embedded) { + out.push(ResolvedCollection { + manifest: fc.manifest, + files: fc.files, + origin: CollectionOrigin::Embedded, + note: None, + }); + } + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(key: &str, schema_sum: &str) -> IssueTypeEntry { + IssueTypeEntry { + key: key.to_string(), + schema_path: format!("{key}.json"), + schema_checksum: schema_sum.to_string(), + template_path: None, + template_checksum: None, + } + } + + #[test] + fn test_resolve_url_replaces_last_segment() { + assert_eq!( + resolve_url( + "https://operator.untra.io/collections/index.json", + "dev_kanban/collection.json" + ), + "https://operator.untra.io/collections/dev_kanban/collection.json" + ); + assert_eq!( + resolve_url( + "https://operator.untra.io/collections/dev_kanban/collection.json", + "TASK.json" + ), + "https://operator.untra.io/collections/dev_kanban/TASK.json" + ); + } + + #[test] + fn test_sha256_hex_empty_input_known_vector() { + // SHA-256 of the empty string. + assert_eq!( + sha256_hex(b""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + } + + #[test] + fn test_sha256_hex_known_vector() { + assert_eq!( + sha256_hex(b"abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + + #[test] + fn test_verify_files_passes_when_checksums_match() { + let task_bytes = b"task schema".to_vec(); + let entries = vec![entry("TASK", &sha256_hex(&task_bytes))]; + let mut files = HashMap::new(); + files.insert("TASK.json".to_string(), task_bytes); + assert!(verify_files(&entries, &files).is_ok()); + } + + #[test] + fn test_verify_files_fails_on_tampered_bytes() { + let task_bytes = b"task schema".to_vec(); + let entries = vec![entry("TASK", &sha256_hex(&task_bytes))]; + let mut files = HashMap::new(); + // One byte flipped -> checksum no longer matches. + files.insert("TASK.json".to_string(), b"task schemb".to_vec()); + let err = verify_files(&entries, &files).unwrap_err(); + assert!(err.to_string().contains("checksum mismatch")); + } + + #[test] + fn test_verify_files_fails_on_missing_file() { + let entries = vec![entry("TASK", "deadbeef")]; + let files = HashMap::new(); + let err = verify_files(&entries, &files).unwrap_err(); + assert!(err.to_string().contains("missing file")); + } + + #[test] + fn test_verify_files_checks_template_checksum() { + let schema_bytes = b"schema".to_vec(); + let template_bytes = b"template".to_vec(); + let mut e = entry("TASK", &sha256_hex(&schema_bytes)); + e.template_path = Some("TASK.md".to_string()); + e.template_checksum = Some("wrong".to_string()); + let mut files = HashMap::new(); + files.insert("TASK.json".to_string(), schema_bytes); + files.insert("TASK.md".to_string(), template_bytes); + let err = verify_files(&[e], &files).unwrap_err(); + assert!(err.to_string().contains("template checksum mismatch")); + } + + #[test] + fn test_embedded_fetched_computes_checksums_and_verifies() { + let embedded = get_embedded_collection("dev_kanban").unwrap(); + let fc = embedded_fetched(embedded).unwrap(); + assert_eq!(fc.manifest.id, "dev_kanban"); + assert_eq!(fc.files.len(), 3); + // The computed checksums must verify against the produced bytes. + let mut map = HashMap::new(); + for (entry, (_, schema, template)) in fc.manifest.issue_types.iter().zip(&fc.files) { + map.insert(entry.schema_path.clone(), schema.clone().into_bytes()); + if let (Some(p), Some(t)) = (&entry.template_path, template) { + map.insert(p.clone(), t.clone().into_bytes()); + } + } + assert!(verify_files(&fc.manifest.issue_types, &map).is_ok()); + } + + #[tokio::test] + async fn test_resolve_for_setup_offline_returns_all_embedded() { + // No URL -> pure embedded fallback; picker is never empty. + let resolved = resolve_for_setup(None, 1).await; + let ids: Vec<&str> = resolved.iter().map(|r| r.manifest.id.as_str()).collect(); + assert!(ids.contains(&"dev_kanban")); + assert!(ids.contains(&"devops_kanban")); + assert!(resolved + .iter() + .all(|r| r.origin == CollectionOrigin::Embedded)); + } + + #[test] + fn test_derive_manifest_checksum_is_order_sensitive_and_stable() { + let a = vec![entry("TASK", "111"), entry("FEAT", "222")]; + let b = vec![entry("FEAT", "222"), entry("TASK", "111")]; + let sum_a = derive_manifest_checksum(&a); + // Stable across calls. + assert_eq!(sum_a, derive_manifest_checksum(&a)); + // Order matters. + assert_ne!(sum_a, derive_manifest_checksum(&b)); + } +} diff --git a/src/collections/full/collection.json b/src/collections/full/collection.json new file mode 100644 index 00000000..c9af00bc --- /dev/null +++ b/src/collections/full/collection.json @@ -0,0 +1,23 @@ +{ + "schema_version": 1, + "id": "full", + "name": "Full", + "description": "Full workflow: all issue types combined", + "version": "1.0.0", + "publisher": "untra", + "author": "Operator!", + "url": "https://github.com/untra/operator", + "license": "MIT", + "tags": ["builtin"], + "issue_types": [ + { "key": "TASK", "schema_path": "TASK.json", "template_path": "TASK.md" }, + { "key": "FEAT", "schema_path": "FEAT.json", "template_path": "FEAT.md" }, + { "key": "FIX", "schema_path": "FIX.json", "template_path": "FIX.md" }, + { "key": "SPIKE", "schema_path": "SPIKE.json", "template_path": "SPIKE.md" }, + { "key": "INV", "schema_path": "INV.json", "template_path": "INV.md" }, + { "key": "ASSESS", "schema_path": "ASSESS.json", "template_path": "ASSESS.md" }, + { "key": "SYNC", "schema_path": "SYNC.json", "template_path": "SYNC.md" }, + { "key": "INIT", "schema_path": "INIT.json", "template_path": "INIT.md" } + ], + "default_selected": ["TASK", "FEAT", "FIX", "SPIKE", "INV", "ASSESS", "SYNC", "INIT"] +} diff --git a/src/collections/full/collection.toml b/src/collections/full/collection.toml deleted file mode 100644 index c2c576a7..00000000 --- a/src/collections/full/collection.toml +++ /dev/null @@ -1,5 +0,0 @@ -name = "full" -description = "Full workflow: all issue types combined" - -# Issue types in this collection (display order) -types = ["TASK", "FEAT", "FIX", "SPIKE", "INV", "ASSESS", "SYNC", "INIT"] diff --git a/src/collections/jr_orchestration/JRFEAT.json b/src/collections/jr_orchestration/JRFEAT.json new file mode 100644 index 00000000..1d283fd5 --- /dev/null +++ b/src/collections/jr_orchestration/JRFEAT.json @@ -0,0 +1,104 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "JRFEAT", + "name": "JR Feature", + "description": "Coordinate one JR feature branch, its task chain, and final architect/human review.", + "mode": "paired", + "glyph": "F", + "color": "green", + "project_required": true, + "agent": "jr-architect-reviewer", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "summary", + "description": "Feature summary", + "type": "string", + "required": true, + "default": "", + "placeholder": "Feature branch outcome", + "max_length": 120, + "display_order": 1 + }, + { + "name": "parent_plan", + "description": "JRPLAN ticket or plan path", + "type": "string", + "required": false, + "default": "", + "display_order": 2 + }, + { + "name": "branch", + "description": "Feature branch name", + "type": "string", + "required": true, + "auto": "branch", + "display_order": 3, + "user_editable": false + }, + { + "name": "task_chain", + "description": "Ordered child tasks and dependencies", + "type": "text", + "required": true, + "default": "", + "placeholder": "JRTASK-1 -> JRTASK-2 -> JRTASK-3", + "display_order": 4 + } + ], + "steps": [ + { + "name": "branch", + "display_name": "Prepare Branch", + "outputs": ["code", "documentation"], + "prompt": "Prepare the JR feature workspace.\n\nVerify the intended base branch, current git state, and whether this feature should use an isolated worktree. Record branch/worktree information and child task order in `.tickets/jr/{{ id }}/feature.md`.", + "allowed_tools": ["Read", "Write", "Glob", "Grep", "Bash"], + "artifact_patterns": [".tickets/jr/{{ id }}/feature.md"], + "next_step": "coordinate" + }, + { + "name": "coordinate", + "display_name": "Coordinate Tasks", + "outputs": ["documentation", "ticket"], + "prompt": "Coordinate this feature's child task chain.\n\nRead the task chain and current ticket state. Identify the next ready JRTASK, required handoff context, and review expectations. Update `.tickets/jr/{{ id }}/handoff.md` with current feature state and next action.", + "allowed_tools": ["Read", "Write", "Edit", "Grep"], + "artifact_patterns": [".tickets/jr/{{ id }}/handoff.md"], + "next_step": "architect" + }, + { + "name": "architect", + "display_name": "Architect Review", + "outputs": ["review"], + "prompt": "Perform a JR architect review of the feature branch.\n\nReview the whole feature diff, task handoffs, acceptance criteria, tests, and integration coherence. Look for architectural drift, incomplete task sequencing, missing validation, and dependency mistakes. Write findings to `.tickets/jr/{{ id }}/architect-review.md`.", + "allowed_tools": ["Read", "Glob", "Grep", "Bash", "Write"], + "review_type": "pr", + "artifact_patterns": [".tickets/jr/{{ id }}/architect-review.md"], + "on_reject": { + "goto_step": "coordinate", + "prompt": "Architect review requested changes: {{ rejection_reason }}\n\nCoordinate the needed JRTASK or JRREBASE work, then return to architect review." + }, + "next_step": "human" + }, + { + "name": "human", + "display_name": "Human Gate", + "outputs": ["pr", "review"], + "prompt": "Prepare this JR feature for human review.\n\nEnsure the branch is pushed, PR description is accurate, task chain is summarized, validation evidence is included, and unresolved review feedback is documented. Stop for human approval rather than merging automatically.", + "allowed_tools": ["Read", "Write", "Bash"], + "review_type": "pr", + "on_reject": { + "goto_step": "architect", + "prompt": "Human review requested changes: {{ rejection_reason }}\n\nAddress the feedback through task/rebase work and return to architect review." + } + } + ] +} diff --git a/src/collections/jr_orchestration/JRFEAT.md b/src/collections/jr_orchestration/JRFEAT.md new file mode 100644 index 00000000..7eca4f58 --- /dev/null +++ b/src/collections/jr_orchestration/JRFEAT.md @@ -0,0 +1,14 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +project: {{ project }} +branch: {{ branch }} +{{#if parent_plan }}parent_plan: {{ parent_plan }} +{{/if}}--- + +# JR Feature: {{ summary }} + +## Task Chain +{{ task_chain }} diff --git a/src/collections/jr_orchestration/JRPLAN.json b/src/collections/jr_orchestration/JRPLAN.json new file mode 100644 index 00000000..822e6c1b --- /dev/null +++ b/src/collections/jr_orchestration/JRPLAN.json @@ -0,0 +1,99 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "JRPLAN", + "name": "JR Plan", + "description": "Decompose a plan into JR-style features, sequential tasks, and review handoffs.", + "mode": "paired", + "glyph": "J", + "color": "blue", + "project_required": true, + "agent": "jr-architect", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "priority", + "description": "Priority level", + "type": "enum", + "required": false, + "default": "P2-medium", + "options": ["P0-critical", "P1-high", "P2-medium", "P3-low"], + "display_order": 1 + }, + { + "name": "summary", + "description": "Plan summary", + "type": "string", + "required": true, + "default": "", + "placeholder": "What should be decomposed into features and tasks?", + "max_length": 120, + "display_order": 2 + }, + { + "name": "plan_source", + "description": "Existing plan, issue, spec, or notes", + "type": "text", + "required": true, + "default": "", + "placeholder": "Paste the plan or describe where to find it.", + "display_order": 3 + }, + { + "name": "review_policy", + "description": "Review expectations for generated tasks and features", + "type": "text", + "required": false, + "default": "Every task gets code review; every feature gets architect review before human PR review.", + "display_order": 4 + } + ], + "steps": [ + { + "name": "analyze", + "display_name": "Analyze Plan", + "outputs": ["plan"], + "prompt": "Analyze the supplied plan for JR-style orchestration.\n\nRead relevant repo files and identify feature boundaries, dependency order, likely worktrees or branches, risk areas, and validation commands. Write findings to `.tickets/jr/{{ id }}/analysis.md`.", + "allowed_tools": ["Read", "Glob", "Grep", "Write"], + "artifact_patterns": [".tickets/jr/{{ id }}/analysis.md"], + "next_step": "decompose" + }, + { + "name": "decompose", + "display_name": "Decompose Work", + "outputs": ["ticket", "documentation"], + "prompt": "Create a JR work decomposition in `.tickets/jr/{{ id }}/plan.md`.\n\nDefine JRFEAT entries for feature branches/worktrees and JRTASK entries ordered within each feature. Include parent feature ids, dependencies, expected files, validation commands, and reviewer role. Keep task scopes small enough for one focused implementation pass.", + "allowed_tools": ["Read", "Write", "Edit"], + "artifact_patterns": [".tickets/jr/{{ id }}/plan.md"], + "next_step": "verify" + }, + { + "name": "verify", + "display_name": "Verify Graph", + "outputs": ["review"], + "prompt": "Review `.tickets/jr/{{ id }}/plan.md` for JR orchestration quality.\n\nEnsure each feature has a clear branch/worktree target, tasks are sequential within a feature, cross-feature dependencies are explicit, and review gates are clear. Rewrite vague tasks. Do not leave hidden ordering assumptions.", + "allowed_tools": ["Read", "Edit", "Grep"], + "review_type": "plan", + "on_reject": { + "goto_step": "decompose", + "prompt": "The JR decomposition was rejected: {{ rejection_reason }}\n\nRevise feature boundaries, task order, dependencies, or review gates." + }, + "next_step": "queue" + }, + { + "name": "queue", + "display_name": "Queue Ready Work", + "outputs": ["ticket"], + "prompt": "Prepare the first ready JR work units.\n\nCreate or describe the initial JRFEAT and JRTASK tickets that have no unmet dependencies. Record the queue decision in `.tickets/jr/{{ id }}/queue.md` so future agents can resume deterministically.", + "allowed_tools": ["Read", "Write", "Bash"], + "artifact_patterns": [".tickets/jr/{{ id }}/queue.md"] + } + ] +} diff --git a/src/collections/jr_orchestration/JRPLAN.md b/src/collections/jr_orchestration/JRPLAN.md new file mode 100644 index 00000000..229870f4 --- /dev/null +++ b/src/collections/jr_orchestration/JRPLAN.md @@ -0,0 +1,16 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +project: {{ project }} +{{#if priority }}priority: {{ priority }} +{{/if}}--- + +# JR Plan: {{ summary }} + +## Plan Source +{{ plan_source }} + +## Review Policy +{{ review_policy }} diff --git a/src/collections/jr_orchestration/JRREBASE.json b/src/collections/jr_orchestration/JRREBASE.json new file mode 100644 index 00000000..91d19778 --- /dev/null +++ b/src/collections/jr_orchestration/JRREBASE.json @@ -0,0 +1,94 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "JRREBASE", + "name": "JR Rebase", + "description": "Repair a JR feature branch after base changes, conflicts, or upstream API drift.", + "mode": "autonomous", + "glyph": "B", + "color": "red", + "project_required": true, + "agent": "jr-coder", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "summary", + "description": "Rebase summary", + "type": "string", + "required": true, + "default": "", + "max_length": 120, + "display_order": 1 + }, + { + "name": "feature_id", + "description": "Parent JRFEAT ticket", + "type": "string", + "required": true, + "default": "", + "display_order": 2 + }, + { + "name": "base_ref", + "description": "Target base branch or commit", + "type": "string", + "required": false, + "default": "main", + "display_order": 3 + }, + { + "name": "reason", + "description": "Why rebase or repair is needed", + "type": "text", + "required": true, + "default": "", + "display_order": 4 + } + ], + "steps": [ + { + "name": "resolve", + "display_name": "Resolve Impact", + "outputs": ["report"], + "prompt": "Investigate why JR rebase/repair is needed.\n\nRead the parent feature handoff, current branch, target base `{{ base_ref }}`, conflicts, failing tests, and upstream API changes. Write an impact report to `.tickets/jr/{{ feature_id }}/rebase-{{ id }}.md`.", + "allowed_tools": ["Read", "Glob", "Grep", "Bash", "Write"], + "artifact_patterns": [".tickets/jr/{{ feature_id }}/rebase-{{ id }}.md"], + "next_step": "rebase" + }, + { + "name": "rebase", + "display_name": "Rebase Repair", + "outputs": ["code"], + "prompt": "Perform the minimal JR rebase or repair.\n\nRebase, merge, or apply targeted fixes needed to make the feature branch coherent with `{{ base_ref }}`. Preserve the feature's intended behavior. Avoid unrelated cleanup.", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], + "next_step": "repair" + }, + { + "name": "repair", + "display_name": "Repair Drift", + "outputs": ["code", "ticket"], + "prompt": "Repair downstream task or API drift caused by the rebase.\n\nRun targeted checks, update affected task notes, and create follow-up JRTASK tickets only when the fix is too large for this rebase ticket.", + "allowed_tools": ["Read", "Write", "Edit", "Bash"], + "next_step": "review" + }, + { + "name": "review", + "display_name": "Post-Rebase Review", + "outputs": ["review", "test"], + "prompt": "Review the rebased JR feature branch.\n\nRun relevant checks and inspect the diff for accidental changes. Update the parent feature handoff with validation evidence and any remaining risk.", + "allowed_tools": ["Read", "Write", "Edit", "Bash"], + "review_type": "plan", + "on_reject": { + "goto_step": "rebase", + "prompt": "Post-rebase review requested changes: {{ rejection_reason }}\n\nRepair the rebase and rerun validation." + } + } + ] +} diff --git a/src/collections/jr_orchestration/JRREBASE.md b/src/collections/jr_orchestration/JRREBASE.md new file mode 100644 index 00000000..c8a5b115 --- /dev/null +++ b/src/collections/jr_orchestration/JRREBASE.md @@ -0,0 +1,14 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +project: {{ project }} +feature_id: {{ feature_id }} +base_ref: {{ base_ref }} +--- + +# JR Rebase: {{ summary }} + +## Reason +{{ reason }} diff --git a/src/collections/jr_orchestration/JRREV.json b/src/collections/jr_orchestration/JRREV.json new file mode 100644 index 00000000..69f36fb8 --- /dev/null +++ b/src/collections/jr_orchestration/JRREV.json @@ -0,0 +1,88 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "JRREV", + "name": "JR Review", + "description": "Fresh review work unit for a JR task or feature branch.", + "mode": "paired", + "glyph": "V", + "color": "yellow", + "project_required": true, + "agent": "jr-code-reviewer", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "summary", + "description": "Review target summary", + "type": "string", + "required": true, + "default": "", + "max_length": 120, + "display_order": 1 + }, + { + "name": "target_ref", + "description": "Task id, feature id, branch, commit range, or PR", + "type": "string", + "required": true, + "default": "", + "display_order": 2 + }, + { + "name": "review_focus", + "description": "Specific concerns for this review", + "type": "text", + "required": false, + "default": "", + "display_order": 3 + } + ], + "steps": [ + { + "name": "diff", + "display_name": "Inspect Diff", + "outputs": ["report"], + "prompt": "Inspect the JR review target `{{ target_ref }}`.\n\nGather the relevant diff, task/feature notes, acceptance criteria, tests, and prior review comments. Do not edit code in this step. Write review context to `.tickets/jr/reviews/{{ id }}.md`.", + "allowed_tools": ["Read", "Glob", "Grep", "Bash", "Write"], + "permission_mode": "plan", + "artifact_patterns": [".tickets/jr/reviews/{{ id }}.md"], + "next_step": "quality" + }, + { + "name": "quality", + "display_name": "Quality Review", + "outputs": ["review"], + "prompt": "Review quality for `{{ target_ref }}`.\n\nCheck correctness, missing tests, scope creep, architectural fit, compatibility, error handling, docs, and likely regressions. Focus especially on: {{ review_focus }}. Add findings to `.tickets/jr/reviews/{{ id }}.md`.", + "allowed_tools": ["Read", "Glob", "Grep", "Bash", "Write"], + "permission_mode": "plan", + "next_step": "decision" + }, + { + "name": "decision", + "display_name": "Review Decision", + "outputs": ["review"], + "prompt": "Make a clear JR review decision for `{{ target_ref }}`.\n\nWrite one of: `approved`, `changes-requested`, or `escalate`. For requested changes, provide precise file-level tasks. For escalation, explain what a human or architect must decide.", + "allowed_tools": ["Read", "Write"], + "review_type": "plan", + "on_reject": { + "goto_step": "quality", + "prompt": "The review decision was not clear enough: {{ rejection_reason }}\n\nRe-read the target and produce a sharper decision." + }, + "next_step": "handoff" + }, + { + "name": "handoff", + "display_name": "Review Handoff", + "outputs": ["documentation"], + "prompt": "Write the review handoff.\n\nUpdate `.tickets/jr/reviews/{{ id }}.md` with the final decision, required next tickets if any, suggested assignee role, and exact validation to rerun after fixes.", + "allowed_tools": ["Read", "Write", "Edit"] + } + ] +} diff --git a/src/collections/jr_orchestration/JRREV.md b/src/collections/jr_orchestration/JRREV.md new file mode 100644 index 00000000..eb6a305e --- /dev/null +++ b/src/collections/jr_orchestration/JRREV.md @@ -0,0 +1,15 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +project: {{ project }} +target_ref: {{ target_ref }} +--- + +# JR Review: {{ summary }} + +{{#if review_focus }} +## Review Focus +{{ review_focus }} +{{/if}} diff --git a/src/collections/jr_orchestration/JRTASK.json b/src/collections/jr_orchestration/JRTASK.json new file mode 100644 index 00000000..0b8e594e --- /dev/null +++ b/src/collections/jr_orchestration/JRTASK.json @@ -0,0 +1,114 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "JRTASK", + "name": "JR Task", + "description": "Implement one focused task inside a JR feature branch, then hand off for review.", + "mode": "autonomous", + "glyph": "T", + "color": "cyan", + "project_required": true, + "agent": "jr-coder", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "priority", + "description": "Priority level", + "type": "enum", + "required": false, + "default": "P2-medium", + "options": ["P0-critical", "P1-high", "P2-medium", "P3-low"], + "display_order": 1 + }, + { + "name": "summary", + "description": "Task summary", + "type": "string", + "required": true, + "default": "", + "placeholder": "One scoped implementation task", + "max_length": 120, + "display_order": 2 + }, + { + "name": "feature_id", + "description": "Parent JRFEAT ticket", + "type": "string", + "required": true, + "default": "", + "display_order": 3 + }, + { + "name": "dependencies", + "description": "Prior tasks or feature dependencies", + "type": "text", + "required": false, + "default": "", + "display_order": 4 + }, + { + "name": "acceptance", + "description": "Task acceptance criteria", + "type": "text", + "required": true, + "default": "", + "display_order": 5 + } + ], + "steps": [ + { + "name": "assign", + "display_name": "Assign Scope", + "outputs": ["plan"], + "prompt": "Prepare to implement this JRTASK.\n\nRead the parent feature handoff, dependencies, acceptance criteria, and relevant repo files. Write a narrow task plan to `.tickets/jr/{{ feature_id }}/tasks/{{ id }}.md`. If dependencies are unmet, stop and document the blocker.", + "allowed_tools": ["Read", "Glob", "Grep", "Write"], + "artifact_patterns": [".tickets/jr/{{ feature_id }}/tasks/{{ id }}.md"], + "next_step": "code" + }, + { + "name": "code", + "display_name": "Code Task", + "outputs": ["code"], + "prompt": "Implement only this JRTASK.\n\nStay on the parent feature branch/worktree. Keep the change small and reviewable. Do not take adjacent tasks. Update the task note with changed files and decisions.", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], + "next_step": "test" + }, + { + "name": "test", + "display_name": "Test Task", + "outputs": ["test"], + "prompt": "Validate this JRTASK.\n\nRun targeted tests and the task's required quality commands. Add or update tests for changed behavior. Record evidence in `.tickets/jr/{{ feature_id }}/tasks/{{ id }}.md`.", + "allowed_tools": ["Read", "Write", "Edit", "Bash"], + "next_step": "review" + }, + { + "name": "review", + "display_name": "Code Review", + "outputs": ["review"], + "prompt": "Perform a fresh JR code review for this task.\n\nReview the diff for correctness, scope discipline, tests, style, hidden regressions, and whether acceptance criteria are met. Write actionable findings into the task note. Approve only if it is ready for the parent feature.", + "agent": "jr-code-reviewer", + "allowed_tools": ["Read", "Glob", "Grep", "Bash", "Write"], + "review_type": "plan", + "on_reject": { + "goto_step": "code", + "prompt": "JR code review requested changes: {{ rejection_reason }}\n\nReturn to task implementation, fix the issues, and rerun tests." + }, + "next_step": "close" + }, + { + "name": "close", + "display_name": "Close Task", + "outputs": ["documentation", "code"], + "prompt": "Close this JRTASK.\n\nUpdate the parent feature handoff with task result, changed files, validation evidence, and next task readiness. Commit focused changes if this workflow uses per-task commits.", + "allowed_tools": ["Read", "Write", "Edit", "Bash"], + "artifact_patterns": [".tickets/jr/{{ feature_id }}/handoff.md"] + } + ] +} diff --git a/src/collections/jr_orchestration/JRTASK.md b/src/collections/jr_orchestration/JRTASK.md new file mode 100644 index 00000000..7641242a --- /dev/null +++ b/src/collections/jr_orchestration/JRTASK.md @@ -0,0 +1,19 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +project: {{ project }} +feature_id: {{ feature_id }} +{{#if priority }}priority: {{ priority }} +{{/if}}--- + +# JR Task: {{ summary }} + +## Acceptance +{{ acceptance }} + +{{#if dependencies }} +## Dependencies +{{ dependencies }} +{{/if}} diff --git a/src/collections/jr_orchestration/collection.json b/src/collections/jr_orchestration/collection.json new file mode 100644 index 00000000..7516ca94 --- /dev/null +++ b/src/collections/jr_orchestration/collection.json @@ -0,0 +1,28 @@ +{ + "schema_version": 1, + "id": "jr_orchestration", + "name": "JR Orchestration", + "description": "Feature/task orchestration with coder, reviewer, architect, and rebase work units.", + "version": "1.0.0", + "publisher": "untra", + "author": "snapwich", + "url": "https://github.com/snapwich/jr", + "license": "MIT", + "tags": ["agentic-loop", "feature-graph", "review", "jr"], + "issue_types": [ + { "key": "JRPLAN", "schema_path": "JRPLAN.json", "template_path": "JRPLAN.md" }, + { "key": "JRFEAT", "schema_path": "JRFEAT.json", "template_path": "JRFEAT.md" }, + { "key": "JRTASK", "schema_path": "JRTASK.json", "template_path": "JRTASK.md" }, + { "key": "JRREV", "schema_path": "JRREV.json", "template_path": "JRREV.md" }, + { "key": "JRREBASE", "schema_path": "JRREBASE.json", "template_path": "JRREBASE.md" } + ], + "workflow_hints": { + "loop_kind": "feature_task_review_graph", + "memory_surfaces": [".tickets/jr/{{ id }}/plan.md", ".tickets/jr/{{ feature_id }}/handoff.md", "ticket parent/dependency notes"], + "review_gates": ["code_review", "architect_review", "human_pr_review"], + "external_tools": ["git", "gh", "project test command"], + "stop_conditions": ["feature PR ready for human review", "review changes requested", "blocked dependency documented", "review escalated to human after repeated changes (operator stops; no auto-handoff)"], + "runner_semantics": "prompt_driven" + }, + "default_selected": ["JRPLAN", "JRFEAT", "JRTASK", "JRREV", "JRREBASE"] +} diff --git a/src/collections/manifest.rs b/src/collections/manifest.rs new file mode 100644 index 00000000..24882f47 --- /dev/null +++ b/src/collections/manifest.rs @@ -0,0 +1,287 @@ +//! Hosted/embedded collection manifest format +//! +//! A collection is described by a [`CollectionManifest`] (`collection.json`) +//! that references per-issuetype JSON schema files (and optional markdown +//! templates) by relative path, each with a SHA-256 checksum. A +//! [`CollectionIndex`] (the file the configurable manifest URL points at) +//! lists the available collections. +//! +//! This is the single collection format used both for hosted collections +//! served from the docs site and for the offline-fallback collections +//! embedded in the binary. + +use serde::{Deserialize, Serialize}; + +/// Current manifest schema version. Manifests with an unknown version are +/// rejected by the fetcher and fall back to the embedded copy. +pub const SCHEMA_VERSION: u32 = 1; + +/// Top-level index listing the available collections. This is what the +/// configurable `collections_manifest_url` points at. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CollectionIndex { + /// Schema version of this index document. + pub schema_version: u32, + /// RFC3339 generation timestamp (informational). + #[serde(default)] + pub generated_at: Option, + /// Available collections. + #[serde(default)] + pub collections: Vec, +} + +/// A single entry in the [`CollectionIndex`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CollectionIndexEntry { + /// Stable collection id (e.g. `dev_kanban`). + pub id: String, + /// Display name. + pub name: String, + /// One-line description. + #[serde(default)] + pub description: String, + /// Collection semver. + #[serde(default)] + pub version: String, + /// Free-form tags. + #[serde(default)] + pub tags: Vec, + /// Path to this collection's `collection.json`, relative to the index URL. + pub manifest_path: String, + /// SHA-256 (lowercase hex) of the referenced `collection.json` bytes. + pub checksum: String, +} + +/// A single collection manifest (`collection.json`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CollectionManifest { + /// Schema version of this manifest. + pub schema_version: u32, + /// Stable collection id (e.g. `dev_kanban`). + pub id: String, + /// Display name. + pub name: String, + /// One-line description. + #[serde(default)] + pub description: String, + /// Collection semver. + #[serde(default)] + pub version: String, + /// Publisher identifier (e.g. `untra`). + #[serde(default)] + pub publisher: Option, + /// Human author/attribution shown in the setup picker. + /// + /// Built-in collections are authored by `Operator!`; a collection imported + /// from a kanban provider lists the provider name + workspace/project. + #[serde(default)] + pub author: Option, + /// Link to the collection's source (GitHub repo or project page). + #[serde(default)] + pub url: Option, + /// SPDX license id. + #[serde(default)] + pub license: Option, + /// Free-form tags. + #[serde(default)] + pub tags: Vec, + /// Compatibility constraints. + #[serde(default)] + pub compatibility: Option, + /// Issue types in this collection (display order). + pub issue_types: Vec, + /// Descriptive workflow hints (v1: metadata only, no execution behavior). + #[serde(default)] + pub workflow_hints: Option, + /// Subset of `issue_types[].key` selected by default in the setup picker. + #[serde(default)] + pub default_selected: Vec, + /// SHA-256 (lowercase hex) derived from the issue-type file checksums. + #[serde(default)] + pub checksum: Option, +} + +/// Compatibility constraints for a collection. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Compatibility { + /// Minimum operator version this collection targets (e.g. `>=0.2.0`). + #[serde(default)] + pub operator_version: Option, +} + +/// A reference to a single issue-type file within a collection. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IssueTypeEntry { + /// Issue type key (e.g. `TASK`). + pub key: String, + /// Path to the issuetype JSON, relative to the manifest. + pub schema_path: String, + /// SHA-256 (lowercase hex) of the issuetype JSON bytes. + /// + /// Embedded manifests omit this (the bytes are compiled in and trusted); + /// the docs producer fills it for hosted manifests, which the fetcher verifies. + #[serde(default)] + pub schema_checksum: String, + /// Optional path to the markdown template, relative to the manifest. + #[serde(default)] + pub template_path: Option, + /// SHA-256 (lowercase hex) of the markdown template bytes, if present. + #[serde(default)] + pub template_checksum: Option, +} + +/// Descriptive metadata about a collection's intended agentic loop shape. +/// +/// v1 is metadata only: these fields are stored and displayed but do not +/// drive any execution-engine behavior. `runner_semantics` is always +/// `prompt_driven` in v1. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkflowHints { + /// Loop shape (e.g. `single_pass`, `ralph`, `review_loop`). + #[serde(default)] + pub loop_kind: Option, + /// Memory surfaces the loop reads/writes (e.g. `scratchpad`, `notes.md`). + #[serde(default)] + pub memory_surfaces: Vec, + /// Review gates between iterations (e.g. `human`, `test_suite`). + #[serde(default)] + pub review_gates: Vec, + /// External tools the loop expects (e.g. `gh`, `playwright`). + #[serde(default)] + pub external_tools: Vec, + /// Conditions under which the loop stops (e.g. `tests_green`, `max_iters`). + #[serde(default)] + pub stop_conditions: Vec, + /// How the runner interprets the hints. v1 is always `prompt_driven`. + #[serde(default = "default_runner_semantics")] + pub runner_semantics: String, +} + +fn default_runner_semantics() -> String { + "prompt_driven".to_string() +} + +impl Default for WorkflowHints { + fn default() -> Self { + Self { + loop_kind: None, + memory_surfaces: Vec::new(), + review_gates: Vec::new(), + external_tools: Vec::new(), + stop_conditions: Vec::new(), + runner_semantics: default_runner_semantics(), + } + } +} + +impl CollectionManifest { + /// Parse a manifest from JSON. + pub fn from_json(json: &str) -> Result { + serde_json::from_str(json) + } + + /// Serialize the manifest to pretty JSON. + pub fn to_json(&self) -> Result { + serde_json::to_string_pretty(self) + } + + /// Issue type keys in display order. + pub fn type_keys(&self) -> Vec { + self.issue_types.iter().map(|e| e.key.clone()).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const DEV_KANBAN_JSON: &str = r#"{ + "schema_version": 1, + "id": "dev_kanban", + "name": "Dev Kanban", + "description": "Developer kanban with TASK, FEAT, FIX", + "version": "1.0.0", + "publisher": "untra", + "license": "MIT", + "tags": ["kanban", "dev"], + "issue_types": [ + {"key": "TASK", "schema_path": "TASK.json", "schema_checksum": "aaa", "template_path": "TASK.md", "template_checksum": "bbb"}, + {"key": "FEAT", "schema_path": "FEAT.json", "schema_checksum": "ccc"}, + {"key": "FIX", "schema_path": "FIX.json", "schema_checksum": "ddd"} + ], + "workflow_hints": { + "loop_kind": "single_pass", + "review_gates": ["test_suite"] + }, + "default_selected": ["TASK", "FEAT", "FIX"] + }"#; + + #[test] + fn test_manifest_parses_full_fields() { + let m = CollectionManifest::from_json(DEV_KANBAN_JSON).unwrap(); + assert_eq!(m.id, "dev_kanban"); + assert_eq!(m.name, "Dev Kanban"); + assert_eq!(m.version, "1.0.0"); + assert_eq!(m.publisher.as_deref(), Some("untra")); + assert_eq!(m.type_keys(), vec!["TASK", "FEAT", "FIX"]); + assert_eq!(m.default_selected, vec!["TASK", "FEAT", "FIX"]); + // First entry has a template, second does not. + assert_eq!(m.issue_types[0].template_path.as_deref(), Some("TASK.md")); + assert!(m.issue_types[1].template_path.is_none()); + } + + #[test] + fn test_workflow_hints_runner_semantics_defaults_to_prompt_driven() { + let m = CollectionManifest::from_json(DEV_KANBAN_JSON).unwrap(); + let hints = m.workflow_hints.expect("hints present"); + // runner_semantics omitted in the fixture -> defaults applied. + assert_eq!(hints.runner_semantics, "prompt_driven"); + assert_eq!(hints.loop_kind.as_deref(), Some("single_pass")); + assert_eq!(hints.review_gates, vec!["test_suite"]); + } + + #[test] + fn test_workflow_hints_default_impl() { + let hints = WorkflowHints::default(); + assert_eq!(hints.runner_semantics, "prompt_driven"); + assert!(hints.memory_surfaces.is_empty()); + } + + #[test] + fn test_manifest_author_and_url_round_trip() { + let json = r#"{ + "schema_version": 1, + "id": "simple", + "name": "Simple", + "author": "Operator!", + "url": "https://github.com/untra/operator", + "issue_types": [ + {"key": "TASK", "schema_path": "TASK.json"} + ] + }"#; + let m = CollectionManifest::from_json(json).unwrap(); + assert_eq!(m.author.as_deref(), Some("Operator!")); + assert_eq!(m.url.as_deref(), Some("https://github.com/untra/operator")); + // Survives a serialize/parse round trip. + let m2 = CollectionManifest::from_json(&m.to_json().unwrap()).unwrap(); + assert_eq!(m2.author, m.author); + assert_eq!(m2.url, m.url); + } + + #[test] + fn test_manifest_author_url_default_to_none() { + // Omitted fields default to None (older manifests stay valid). + let m = CollectionManifest::from_json(DEV_KANBAN_JSON).unwrap(); + assert!(m.author.is_none()); + assert!(m.url.is_none()); + } + + #[test] + fn test_manifest_round_trip() { + let m = CollectionManifest::from_json(DEV_KANBAN_JSON).unwrap(); + let json = m.to_json().unwrap(); + let m2 = CollectionManifest::from_json(&json).unwrap(); + assert_eq!(m.id, m2.id); + assert_eq!(m.type_keys(), m2.type_keys()); + } +} diff --git a/src/collections/mod.rs b/src/collections/mod.rs index 059eb907..65ecceaf 100644 --- a/src/collections/mod.rs +++ b/src/collections/mod.rs @@ -4,6 +4,9 @@ //! issuetype definitions. Each collection is self-contained with its own //! copies of JSON schemas and markdown templates. +pub mod fetch; +pub mod manifest; + /// A single embedded issuetype with JSON schema and markdown template #[derive(Debug, Clone)] pub struct EmbeddedIssueType { @@ -20,12 +23,19 @@ pub struct EmbeddedCollection { pub issuetypes: &'static [EmbeddedIssueType], } +impl EmbeddedCollection { + /// Parse the embedded `collection.json` manifest. + pub fn manifest_parsed(&self) -> Result { + manifest::CollectionManifest::from_json(self.manifest) + } +} + /// All embedded collections pub static EMBEDDED_COLLECTIONS: &[EmbeddedCollection] = &[ // Simple collection: TASK only EmbeddedCollection { name: "simple", - manifest: include_str!("simple/collection.toml"), + manifest: include_str!("simple/collection.json"), issuetypes: &[EmbeddedIssueType { key: "TASK", schema_json: include_str!("simple/TASK.json"), @@ -35,7 +45,7 @@ pub static EMBEDDED_COLLECTIONS: &[EmbeddedCollection] = &[ // Dev Kanban collection: TASK, FEAT, FIX EmbeddedCollection { name: "dev_kanban", - manifest: include_str!("dev_kanban/collection.toml"), + manifest: include_str!("dev_kanban/collection.json"), issuetypes: &[ EmbeddedIssueType { key: "TASK", @@ -57,7 +67,7 @@ pub static EMBEDDED_COLLECTIONS: &[EmbeddedCollection] = &[ // DevOps Kanban collection: TASK, FEAT, FIX, SPIKE, INV EmbeddedCollection { name: "devops_kanban", - manifest: include_str!("devops_kanban/collection.toml"), + manifest: include_str!("devops_kanban/collection.json"), issuetypes: &[ EmbeddedIssueType { key: "TASK", @@ -89,7 +99,7 @@ pub static EMBEDDED_COLLECTIONS: &[EmbeddedCollection] = &[ // Operator collection: ASSESS, SYNC, INIT, AGENT-SETUP, PROJECT-INIT EmbeddedCollection { name: "operator", - manifest: include_str!("operator/collection.toml"), + manifest: include_str!("operator/collection.json"), issuetypes: &[ EmbeddedIssueType { key: "ASSESS", @@ -121,7 +131,7 @@ pub static EMBEDDED_COLLECTIONS: &[EmbeddedCollection] = &[ // Full collection: All 8 issuetypes EmbeddedCollection { name: "full", - manifest: include_str!("full/collection.toml"), + manifest: include_str!("full/collection.json"), issuetypes: &[ EmbeddedIssueType { key: "TASK", @@ -165,6 +175,87 @@ pub static EMBEDDED_COLLECTIONS: &[EmbeddedCollection] = &[ }, ], }, + // Ralph Loop collection: PRD, STORY, RLOOP + EmbeddedCollection { + name: "ralph_loop", + manifest: include_str!("ralph_loop/collection.json"), + issuetypes: &[ + EmbeddedIssueType { + key: "PRD", + schema_json: include_str!("ralph_loop/PRD.json"), + template_md: include_str!("ralph_loop/PRD.md"), + }, + EmbeddedIssueType { + key: "STORY", + schema_json: include_str!("ralph_loop/STORY.json"), + template_md: include_str!("ralph_loop/STORY.md"), + }, + EmbeddedIssueType { + key: "RLOOP", + schema_json: include_str!("ralph_loop/RLOOP.json"), + template_md: include_str!("ralph_loop/RLOOP.md"), + }, + ], + }, + // JR Orchestration collection: feature/task/review/rebase workflows + EmbeddedCollection { + name: "jr_orchestration", + manifest: include_str!("jr_orchestration/collection.json"), + issuetypes: &[ + EmbeddedIssueType { + key: "JRPLAN", + schema_json: include_str!("jr_orchestration/JRPLAN.json"), + template_md: include_str!("jr_orchestration/JRPLAN.md"), + }, + EmbeddedIssueType { + key: "JRFEAT", + schema_json: include_str!("jr_orchestration/JRFEAT.json"), + template_md: include_str!("jr_orchestration/JRFEAT.md"), + }, + EmbeddedIssueType { + key: "JRTASK", + schema_json: include_str!("jr_orchestration/JRTASK.json"), + template_md: include_str!("jr_orchestration/JRTASK.md"), + }, + EmbeddedIssueType { + key: "JRREV", + schema_json: include_str!("jr_orchestration/JRREV.json"), + template_md: include_str!("jr_orchestration/JRREV.md"), + }, + EmbeddedIssueType { + key: "JRREBASE", + schema_json: include_str!("jr_orchestration/JRREBASE.json"), + template_md: include_str!("jr_orchestration/JRREBASE.md"), + }, + ], + }, + // Elves Overnight collection: staged batch, PR landing, reporting + EmbeddedCollection { + name: "elves_overnight", + manifest: include_str!("elves_overnight/collection.json"), + issuetypes: &[ + EmbeddedIssueType { + key: "ELVSTAGE", + schema_json: include_str!("elves_overnight/ELVSTAGE.json"), + template_md: include_str!("elves_overnight/ELVSTAGE.md"), + }, + EmbeddedIssueType { + key: "ELVBATCH", + schema_json: include_str!("elves_overnight/ELVBATCH.json"), + template_md: include_str!("elves_overnight/ELVBATCH.md"), + }, + EmbeddedIssueType { + key: "LANDPR", + schema_json: include_str!("elves_overnight/LANDPR.json"), + template_md: include_str!("elves_overnight/LANDPR.md"), + }, + EmbeddedIssueType { + key: "ELVRPT", + schema_json: include_str!("elves_overnight/ELVRPT.json"), + template_md: include_str!("elves_overnight/ELVRPT.md"), + }, + ], + }, ]; /// Embedded schema files for issue types that need structured output @@ -214,10 +305,11 @@ pub fn get_embedded_issuetype(key: &str) -> Option<&'static EmbeddedIssueType> { #[cfg(test)] mod tests { use super::*; + use crate::templates::schema::TemplateSchema; #[test] fn test_embedded_collections_count() { - assert_eq!(EMBEDDED_COLLECTIONS.len(), 5); + assert_eq!(EMBEDDED_COLLECTIONS.len(), 8); } #[test] @@ -241,6 +333,18 @@ mod tests { let full = get_embedded_collection("full").unwrap(); assert_eq!(full.name, "full"); assert_eq!(full.issuetypes.len(), 8); + + let ralph = get_embedded_collection("ralph_loop").unwrap(); + assert_eq!(ralph.name, "ralph_loop"); + assert_eq!(ralph.issuetypes.len(), 3); + + let jr = get_embedded_collection("jr_orchestration").unwrap(); + assert_eq!(jr.name, "jr_orchestration"); + assert_eq!(jr.issuetypes.len(), 5); + + let elves = get_embedded_collection("elves_overnight").unwrap(); + assert_eq!(elves.name, "elves_overnight"); + assert_eq!(elves.issuetypes.len(), 4); } #[test] @@ -258,6 +362,48 @@ mod tests { assert!(names.contains(&"full")); } + #[test] + fn test_embedded_manifests_parse_and_match_issuetypes() { + for collection in EMBEDDED_COLLECTIONS { + let manifest = collection + .manifest_parsed() + .unwrap_or_else(|e| panic!("{} manifest must parse: {e}", collection.name)); + assert_eq!(manifest.id, collection.name, "id == name"); + assert_eq!(manifest.schema_version, 1); + // The manifest's issue_types must list exactly the embedded files, + // in the same order, so the docs producer can emit them all. + let manifest_keys: Vec<&str> = manifest + .issue_types + .iter() + .map(|e| e.key.as_str()) + .collect(); + let embedded_keys: Vec<&str> = collection.issuetypes.iter().map(|it| it.key).collect(); + assert_eq!( + manifest_keys, embedded_keys, + "{} manifest issue_types must match embedded files", + collection.name + ); + // Embedded manifests omit checksums (bytes are compiled in/trusted). + for entry in &manifest.issue_types { + assert!(entry.schema_checksum.is_empty()); + } + } + } + + #[test] + fn test_agentic_loop_collections_have_valid_issuetypes() { + for name in ["ralph_loop", "jr_orchestration", "elves_overnight"] { + let collection = get_embedded_collection(name).unwrap(); + for issue_type in collection.issuetypes { + let schema = TemplateSchema::from_json(issue_type.schema_json) + .unwrap_or_else(|e| panic!("{name}/{} schema must parse: {e}", issue_type.key)); + schema.validate().unwrap_or_else(|errors| { + panic!("{name}/{} schema invalid: {errors:?}", issue_type.key) + }); + } + } + } + #[test] fn test_get_embedded_issuetype() { let task = get_embedded_issuetype("TASK").unwrap(); diff --git a/src/collections/operator/collection.json b/src/collections/operator/collection.json new file mode 100644 index 00000000..50a914d6 --- /dev/null +++ b/src/collections/operator/collection.json @@ -0,0 +1,20 @@ +{ + "schema_version": 1, + "id": "operator", + "name": "Operator", + "description": "Operator automation tasks: ASSESS, SYNC, INIT", + "version": "1.0.0", + "publisher": "untra", + "author": "Operator!", + "url": "https://github.com/untra/operator", + "license": "MIT", + "tags": ["builtin", "automation"], + "issue_types": [ + { "key": "ASSESS", "schema_path": "ASSESS.json", "template_path": "ASSESS.md" }, + { "key": "SYNC", "schema_path": "SYNC.json", "template_path": "SYNC.md" }, + { "key": "INIT", "schema_path": "INIT.json", "template_path": "INIT.md" }, + { "key": "AGENT-SETUP", "schema_path": "AGENT-SETUP.json", "template_path": "AGENT-SETUP.md" }, + { "key": "PROJECT-INIT", "schema_path": "PROJECT-INIT.json", "template_path": "PROJECT-INIT.md" } + ], + "default_selected": ["ASSESS", "SYNC", "INIT"] +} diff --git a/src/collections/operator/collection.toml b/src/collections/operator/collection.toml deleted file mode 100644 index 61dcc99f..00000000 --- a/src/collections/operator/collection.toml +++ /dev/null @@ -1,5 +0,0 @@ -name = "operator" -description = "Operator automation tasks: ASSESS, SYNC, INIT" - -# Issue types in this collection (display order) -types = ["ASSESS", "SYNC", "INIT"] diff --git a/src/collections/ralph_loop/PRD.json b/src/collections/ralph_loop/PRD.json new file mode 100644 index 00000000..e8943e2a --- /dev/null +++ b/src/collections/ralph_loop/PRD.json @@ -0,0 +1,105 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "PRD", + "name": "Product Requirements Document", + "description": "Draft, normalize, and slice a product requirements document into Ralph-style executable stories.", + "mode": "paired", + "glyph": "P", + "color": "blue", + "project_required": true, + "agent": "ralph-planner", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "priority", + "description": "Priority level", + "type": "enum", + "required": false, + "default": "P2-medium", + "options": ["P0-critical", "P1-high", "P2-medium", "P3-low"], + "display_order": 1 + }, + { + "name": "summary", + "description": "Product or feature summary", + "type": "string", + "required": true, + "default": "", + "placeholder": "What product capability should be specified?", + "max_length": 120, + "display_order": 2 + }, + { + "name": "source_notes", + "description": "Raw requirements, links, constraints, or notes", + "type": "text", + "required": false, + "default": "", + "placeholder": "Paste product notes, acceptance criteria, constraints, or relevant links.", + "display_order": 3 + }, + { + "name": "quality_commands", + "description": "Commands each story should pass before completion", + "type": "text", + "required": false, + "default": "", + "placeholder": "Example: npm test && npm run lint", + "display_order": 4 + } + ], + "steps": [ + { + "name": "draft", + "display_name": "Draft PRD", + "outputs": ["plan", "documentation"], + "prompt": "Draft a product requirements document for {{ summary }}.\n\nUse the ticket notes as source material, then inspect the project enough to make the requirements concrete. Write the human-readable PRD to `.tickets/workflows/{{ id }}/prd.md`.\n\nThe PRD must include goals, non-goals, users, constraints, risks, acceptance criteria, and a story list. Each story should be small enough for one fresh agent context.", + "allowed_tools": ["Read", "Glob", "Grep", "Write"], + "artifact_patterns": [".tickets/workflows/{{ id }}/prd.md"], + "review_type": "plan", + "on_reject": { + "goto_step": "draft", + "prompt": "The PRD draft was rejected: {{ rejection_reason }}\n\nRevise `.tickets/workflows/{{ id }}/prd.md` and tighten the story list before asking for review again." + }, + "next_step": "structure" + }, + { + "name": "structure", + "display_name": "Structure Stories", + "outputs": ["documentation"], + "prompt": "Convert `.tickets/workflows/{{ id }}/prd.md` into Ralph-style structured state at `.tickets/workflows/{{ id }}/prd.json`.\n\nThe JSON should include an ordered `stories` array. Each story needs a stable id, title, description, acceptance criteria, dependencies, and `passes: false`. Include a `quality_commands` field from the ticket when provided.\n\nDo not mark any story complete yet.", + "allowed_tools": ["Read", "Write", "Edit"], + "artifact_patterns": [".tickets/workflows/{{ id }}/prd.json"], + "next_step": "slice" + }, + { + "name": "slice", + "display_name": "Slice Check", + "outputs": ["review"], + "prompt": "Review `.tickets/workflows/{{ id }}/prd.json` for story size and execution order.\n\nA good Ralph story can be completed independently by a fresh agent using only the PRD, progress file, repo context, and current ticket. Split any story that is too broad. Make dependencies explicit. If a story is ambiguous, update the PRD and JSON rather than relying on future memory.", + "allowed_tools": ["Read", "Edit", "Grep"], + "review_type": "plan", + "on_reject": { + "goto_step": "draft", + "prompt": "The story slicing review found problems: {{ rejection_reason }}\n\nRevise the PRD and structured story list so each story is independently executable." + }, + "next_step": "ready" + }, + { + "name": "ready", + "display_name": "Initialize Progress", + "outputs": ["documentation", "ticket"], + "prompt": "Initialize Ralph loop state for this PRD.\n\nWrite `.tickets/workflows/{{ id }}/progress.txt` with: project context, quality commands, current branch, how to select the next story, and any constraints a fresh STORY agent must know. Add a short note explaining that each STORY ticket should complete exactly one story and update `prd.json` plus `progress.txt`.\n\nIf useful, create the first STORY ticket for the first incomplete story.", + "allowed_tools": ["Read", "Write", "Bash"], + "artifact_patterns": [".tickets/workflows/{{ id }}/progress.txt"] + } + ] +} diff --git a/src/collections/ralph_loop/PRD.md b/src/collections/ralph_loop/PRD.md new file mode 100644 index 00000000..8e8fd554 --- /dev/null +++ b/src/collections/ralph_loop/PRD.md @@ -0,0 +1,20 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +project: {{ project }} +{{#if priority }}priority: {{ priority }} +{{/if}}--- + +# PRD: {{ summary }} + +{{#if source_notes }} +## Source Notes +{{ source_notes }} +{{/if}} + +{{#if quality_commands }} +## Quality Commands +{{ quality_commands }} +{{/if}} diff --git a/src/collections/ralph_loop/RLOOP.json b/src/collections/ralph_loop/RLOOP.json new file mode 100644 index 00000000..40a5d9ee --- /dev/null +++ b/src/collections/ralph_loop/RLOOP.json @@ -0,0 +1,95 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "RLOOP", + "name": "Ralph Loop Coordinator", + "description": "Coordinate repeated single-story Ralph iterations until the PRD is complete.", + "mode": "paired", + "glyph": "R", + "color": "magenta", + "project_required": true, + "agent": "ralph-coordinator", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "summary", + "description": "Loop summary", + "type": "string", + "required": true, + "default": "", + "placeholder": "Complete the PRD story loop", + "max_length": 120, + "display_order": 1 + }, + { + "name": "prd_path", + "description": "Path to prd.json", + "type": "string", + "required": true, + "default": ".tickets/workflows/{{ id }}/prd.json", + "placeholder": ".tickets/workflows/PRD-1234/prd.json", + "display_order": 2 + }, + { + "name": "max_iterations", + "description": "Advisory cap on story iterations before stopping for review (Ralph default 10). The exported workflow's review loop is bounded independently.", + "type": "integer", + "required": false, + "default": "10", + "display_order": 3 + } + ], + "steps": [ + { + "name": "preflight", + "display_name": "Loop Preflight", + "outputs": ["plan"], + "prompt": "Preflight the Ralph loop.\n\nVerify `{{ prd_path }}` exists, find its sibling `progress.txt`, inspect git status, read quality commands, and count unfinished stories. Write a short loop plan to `.tickets/workflows/{{ id }}/loop.md`.\n\nIf state is missing, repair it or stop with exact instructions.", + "allowed_tools": ["Read", "Glob", "Grep", "Write", "Bash"], + "artifact_patterns": [".tickets/workflows/{{ id }}/loop.md"], + "review_type": "plan", + "on_reject": { + "goto_step": "preflight", + "prompt": "Loop preflight was rejected: {{ rejection_reason }}\n\nFix the loop state and update the plan." + }, + "next_step": "iterate" + }, + { + "name": "iterate", + "display_name": "Run One Story", + "outputs": ["ticket", "code"], + "prompt": "Run exactly one Ralph story iteration.\n\nSelect the next story with `passes: false` from `{{ prd_path }}`. Prefer creating or launching a STORY ticket that references this loop and story id. If operating directly, follow the STORY issue type discipline: implement one story, verify it, update progress, and mark only that story as passing.\n\nStop after one story iteration.", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], + "artifact_patterns": ["{{ prd_path }}"], + "next_step": "check" + }, + { + "name": "check", + "display_name": "Completion Check", + "outputs": ["review"], + "prompt": "Check Ralph loop completion.\n\nRead `{{ prd_path }}` and determine whether every story has `passes: true`. If all stories pass, summarize completion and proceed. If unfinished stories remain and `max_iterations` is not exhausted, request another iteration by rejecting this step with clear next-story instructions. If blocked, document the blocker and stop for human input.", + "allowed_tools": ["Read", "Grep", "Write"], + "review_type": "plan", + "on_reject": { + "goto_step": "iterate", + "prompt": "The PRD is not complete yet or the previous story needs correction: {{ rejection_reason }}\n\nRun one more STORY iteration, then return to completion check." + }, + "next_step": "complete" + }, + { + "name": "complete", + "display_name": "Complete Loop", + "outputs": ["report"], + "prompt": "Write the final Ralph loop report to `.tickets/workflows/{{ id }}/summary.md`.\n\nInclude completed stories, commits or changed files, validation evidence, lessons learned, and any deferred follow-up work. Do not create new broad work unless explicitly asked.", + "allowed_tools": ["Read", "Write", "Grep", "Bash"], + "artifact_patterns": [".tickets/workflows/{{ id }}/summary.md"] + } + ] +} diff --git a/src/collections/ralph_loop/RLOOP.md b/src/collections/ralph_loop/RLOOP.md new file mode 100644 index 00000000..d2af29a4 --- /dev/null +++ b/src/collections/ralph_loop/RLOOP.md @@ -0,0 +1,11 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +project: {{ project }} +prd_path: {{ prd_path }} +max_iterations: {{ max_iterations }} +--- + +# Ralph Loop: {{ summary }} diff --git a/src/collections/ralph_loop/STORY.json b/src/collections/ralph_loop/STORY.json new file mode 100644 index 00000000..f6e98213 --- /dev/null +++ b/src/collections/ralph_loop/STORY.json @@ -0,0 +1,116 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "STORY", + "name": "Ralph Story", + "description": "Execute one Ralph story in a fresh context, then persist progress for the next iteration.", + "mode": "autonomous", + "glyph": "S", + "color": "green", + "project_required": true, + "agent": "ralph-coder", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "priority", + "description": "Priority level", + "type": "enum", + "required": false, + "default": "P2-medium", + "options": ["P0-critical", "P1-high", "P2-medium", "P3-low"], + "display_order": 1 + }, + { + "name": "summary", + "description": "Story title", + "type": "string", + "required": true, + "default": "", + "placeholder": "One story from prd.json", + "max_length": 120, + "display_order": 2 + }, + { + "name": "workflow_id", + "description": "PRD/RLOOP workflow id whose prd.json owns this story", + "type": "string", + "required": true, + "default": "", + "placeholder": "PRD-1234 or RLOOP-1234", + "display_order": 3 + }, + { + "name": "story_id", + "description": "Story id inside prd.json", + "type": "string", + "required": false, + "default": "", + "placeholder": "story-001", + "display_order": 4 + }, + { + "name": "notes", + "description": "Additional constraints or human guidance", + "type": "text", + "required": false, + "default": "", + "display_order": 5 + } + ], + "steps": [ + { + "name": "select", + "display_name": "Select Story", + "outputs": ["ticket", "plan"], + "prompt": "Read `.tickets/workflows/{{ workflow_id }}/prd.json` and `.tickets/workflows/{{ workflow_id }}/progress.txt`.\n\nIf `story_id` is set, select that story. Otherwise select the first highest-priority story where `passes` is false and dependencies are satisfied. Write the selected story details and planned files to `.tickets/workflows/{{ workflow_id }}/stories/{{ id }}.md`.\n\nDo not start broad work. This ticket is responsible for exactly one story.", + "allowed_tools": ["Read", "Glob", "Grep", "Write"], + "artifact_patterns": [".tickets/workflows/{{ workflow_id }}/stories/{{ id }}.md"], + "next_step": "implement" + }, + { + "name": "implement", + "display_name": "Implement Story", + "outputs": ["code"], + "prompt": "Implement only the selected story recorded in `.tickets/workflows/{{ workflow_id }}/stories/{{ id }}.md`.\n\nUse fresh context discipline: read the PRD, progress file, selected story, and relevant repo files. Avoid opportunistic adjacent work. If the story is blocked or too large, stop and document the blocker instead of widening scope.", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], + "next_step": "verify" + }, + { + "name": "verify", + "display_name": "Verify Story", + "outputs": ["test"], + "prompt": "Verify the story is actually complete.\n\nRun the quality commands from `.tickets/workflows/{{ workflow_id }}/prd.json` and `.tickets/workflows/{{ workflow_id }}/progress.txt` when present. Add or update tests where the story changes behavior. Record evidence in `.tickets/workflows/{{ workflow_id }}/stories/{{ id }}.md`.\n\nFix failures before proceeding.", + "allowed_tools": ["Read", "Write", "Edit", "Bash"], + "review_type": "plan", + "on_reject": { + "goto_step": "implement", + "prompt": "Story verification was rejected: {{ rejection_reason }}\n\nReturn to the implementation, fix the issue, and rerun the quality checks." + }, + "next_step": "learn" + }, + { + "name": "learn", + "display_name": "Persist Learnings", + "outputs": ["documentation"], + "prompt": "Persist memory for the next fresh context.\n\nUpdate `.tickets/workflows/{{ workflow_id }}/progress.txt` with what changed, quality evidence, important files, surprises, and any reusable repo knowledge. If the repo has `AGENTS.md`, `CLAUDE.md`, or similar guidance files and you learned stable reusable instructions, update them conservatively.", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep"], + "artifact_patterns": [".tickets/workflows/{{ workflow_id }}/progress.txt"], + "next_step": "commit" + }, + { + "name": "commit", + "display_name": "Mark Passing", + "outputs": ["code", "documentation"], + "prompt": "Finalize this one-story iteration.\n\nUpdate `.tickets/workflows/{{ workflow_id }}/prd.json` so the selected story has `passes: true`, a concise result note, and validation evidence. Commit focused changes if this repo uses commits for agent progress. Do not mark unrelated stories complete.", + "allowed_tools": ["Read", "Write", "Edit", "Bash"], + "artifact_patterns": [".tickets/workflows/{{ workflow_id }}/prd.json"] + } + ] +} diff --git a/src/collections/ralph_loop/STORY.md b/src/collections/ralph_loop/STORY.md new file mode 100644 index 00000000..20a83066 --- /dev/null +++ b/src/collections/ralph_loop/STORY.md @@ -0,0 +1,16 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +project: {{ project }} +workflow_id: {{ workflow_id }} +{{#if story_id }}story_id: {{ story_id }} +{{/if}}--- + +# Ralph Story: {{ summary }} + +{{#if notes }} +## Notes +{{ notes }} +{{/if}} diff --git a/src/collections/ralph_loop/collection.json b/src/collections/ralph_loop/collection.json new file mode 100644 index 00000000..30b1b49c --- /dev/null +++ b/src/collections/ralph_loop/collection.json @@ -0,0 +1,26 @@ +{ + "schema_version": 1, + "id": "ralph_loop", + "name": "Ralph Loop", + "description": "PRD-to-story loop for completing one right-sized story per fresh agent context.", + "version": "1.0.0", + "publisher": "untra", + "author": "snarktank", + "url": "https://github.com/snarktank/ralph", + "license": "MIT", + "tags": ["agentic-loop", "prd", "stories", "ralph"], + "issue_types": [ + { "key": "PRD", "schema_path": "PRD.json", "template_path": "PRD.md" }, + { "key": "STORY", "schema_path": "STORY.json", "template_path": "STORY.md" }, + { "key": "RLOOP", "schema_path": "RLOOP.json", "template_path": "RLOOP.md" } + ], + "workflow_hints": { + "loop_kind": "fresh_context_story_loop", + "memory_surfaces": [".tickets/workflows/{{ id }}/prd.json", ".tickets/workflows/{{ id }}/progress.txt", "AGENTS.md"], + "review_gates": ["plan_review", "test_suite", "story_completion_check"], + "external_tools": ["git", "project test command"], + "stop_conditions": ["all stories have passes=true", "blocked story documented", "quality gates fail repeatedly", "max_iterations reached (advisory; outer story loop is operator-queue-driven)"], + "runner_semantics": "prompt_driven" + }, + "default_selected": ["PRD", "STORY", "RLOOP"] +} diff --git a/src/collections/simple/collection.json b/src/collections/simple/collection.json new file mode 100644 index 00000000..cfdb3ffc --- /dev/null +++ b/src/collections/simple/collection.json @@ -0,0 +1,16 @@ +{ + "schema_version": 1, + "id": "simple", + "name": "Simple", + "description": "Simple workflow with TASK only", + "version": "1.0.0", + "publisher": "untra", + "author": "Operator!", + "url": "https://github.com/untra/operator", + "license": "MIT", + "tags": ["builtin"], + "issue_types": [ + { "key": "TASK", "schema_path": "TASK.json", "template_path": "TASK.md" } + ], + "default_selected": ["TASK"] +} diff --git a/src/collections/simple/collection.toml b/src/collections/simple/collection.toml deleted file mode 100644 index fa82de8e..00000000 --- a/src/collections/simple/collection.toml +++ /dev/null @@ -1,5 +0,0 @@ -name = "simple" -description = "Simple workflow with TASK only" - -# Issue types in this collection (display order) -types = ["TASK"] diff --git a/src/config.rs b/src/config.rs index 347cdf42..04337f13 100644 --- a/src/config.rs +++ b/src/config.rs @@ -495,6 +495,28 @@ pub struct TemplatesConfig { /// Can be a builtin preset name or a user-defined collection #[serde(default)] pub active_collection: Option, + + /// Enable fetching hosted issuetype collections during setup. + /// When disabled, only the embedded (offline) collections are offered. + #[serde(default = "default_true")] + pub collections_fetch_enabled: bool, + + /// URL of the hosted collection index manifest, fetched during setup. + /// Points at a `CollectionIndex` JSON document listing available collections. + #[serde(default = "default_collections_manifest_url")] + pub collections_manifest_url: Option, + + /// Timeout in seconds for hosted collection fetch HTTP requests. + #[serde(default = "default_collections_fetch_timeout")] + pub collections_fetch_timeout_secs: u64, +} + +fn default_collections_manifest_url() -> Option { + Some("https://operator.untra.io/collections/index.json".to_string()) +} + +fn default_collections_fetch_timeout() -> u64 { + 5 } impl Default for TemplatesConfig { @@ -503,6 +525,9 @@ impl Default for TemplatesConfig { preset: CollectionPreset::DevKanban, collection: Vec::new(), active_collection: None, + collections_fetch_enabled: true, + collections_manifest_url: default_collections_manifest_url(), + collections_fetch_timeout_secs: 5, } } } diff --git a/src/docs_gen/collections_manifest.rs b/src/docs_gen/collections_manifest.rs new file mode 100644 index 00000000..6a722aa1 --- /dev/null +++ b/src/docs_gen/collections_manifest.rs @@ -0,0 +1,218 @@ +//! Hosted collection manifest generator. +//! +//! Emits the static collection bundle served from the docs site: +//! +//! ```text +//! docs/collections/ +//! ├── index.json (CollectionIndex of all collections) +//! └── / +//! ├── collection.json (CollectionManifest with checksums) +//! ├── .json (issuetype schema, byte-identical to embedded) +//! └── .md (issuetype template) +//! ``` +//! +//! The per-issuetype files are written byte-for-byte from the embedded +//! collections, and their SHA-256 checksums are computed and recorded in the +//! manifest. The runtime fetcher verifies these checksums, so the hosted bundle +//! is guaranteed identical to the offline fallback. + +use std::path::Path; + +use anyhow::{anyhow, Result}; + +use super::DocGenerator; +use crate::collections::fetch::{derive_manifest_checksum, sha256_hex}; +use crate::collections::manifest::{ + CollectionIndex, CollectionIndexEntry, CollectionManifest, SCHEMA_VERSION, +}; +use crate::collections::{EmbeddedCollection, EMBEDDED_COLLECTIONS}; + +/// Generates the hosted collection bundle under `docs/collections/`. +pub struct CollectionsManifestGenerator; + +/// A fully-resolved hosted manifest plus the byte payloads it references. +struct HostedCollection { + manifest: CollectionManifest, + /// (relative path, bytes) for each issuetype schema/template file. + files: Vec<(String, Vec)>, +} + +/// Build a hosted manifest for `embedded`: copy metadata from the embedded +/// `collection.json`, fill in per-file checksums from the embedded bytes, and +/// derive the manifest-level checksum. +fn build_hosted(embedded: &EmbeddedCollection) -> Result { + let mut manifest = embedded + .manifest_parsed() + .map_err(|e| anyhow!("parsing embedded manifest for {}: {e}", embedded.name))?; + let mut files = Vec::new(); + + for entry in &mut manifest.issue_types { + let it = embedded + .issuetypes + .iter() + .find(|it| it.key == entry.key) + .ok_or_else(|| { + anyhow!( + "collection {} manifest references {} but no embedded file exists", + embedded.name, + entry.key + ) + })?; + + let schema_bytes = it.schema_json.as_bytes().to_vec(); + entry.schema_checksum = sha256_hex(&schema_bytes); + files.push((entry.schema_path.clone(), schema_bytes)); + + if let Some(template_path) = entry.template_path.clone() { + let md_bytes = it.template_md.as_bytes().to_vec(); + entry.template_checksum = Some(sha256_hex(&md_bytes)); + files.push((template_path, md_bytes)); + } + } + + manifest.checksum = Some(derive_manifest_checksum(&manifest.issue_types)); + Ok(HostedCollection { manifest, files }) +} + +/// Serialize a hosted manifest to its canonical on-disk form (pretty JSON, +/// trailing newline). +fn manifest_json(manifest: &CollectionManifest) -> Result { + Ok(format!("{}\n", manifest.to_json()?)) +} + +/// Build the top-level index over all embedded collections. +fn build_index() -> Result { + let mut collections = Vec::new(); + for embedded in EMBEDDED_COLLECTIONS { + let hosted = build_hosted(embedded)?; + let json = manifest_json(&hosted.manifest)?; + collections.push(CollectionIndexEntry { + id: hosted.manifest.id.clone(), + name: hosted.manifest.name.clone(), + description: hosted.manifest.description.clone(), + version: hosted.manifest.version.clone(), + tags: hosted.manifest.tags.clone(), + manifest_path: format!("{}/collection.json", hosted.manifest.id), + checksum: sha256_hex(json.as_bytes()), + }); + } + Ok(CollectionIndex { + schema_version: SCHEMA_VERSION, + // Intentionally omitted: a timestamp would make generation non-deterministic. + generated_at: None, + collections, + }) +} + +impl DocGenerator for CollectionsManifestGenerator { + fn name(&self) -> &'static str { + "collections-manifest" + } + + fn source(&self) -> &'static str { + "src/collections/*/collection.json (EMBEDDED_COLLECTIONS)" + } + + fn output_path(&self) -> &'static str { + "collections/index.json" + } + + fn generate(&self) -> Result { + let index = build_index()?; + Ok(format!("{}\n", serde_json::to_string_pretty(&index)?)) + } + + fn write(&self, docs_dir: &Path) -> Result<()> { + let collections_dir = docs_dir.join("collections"); + + // Per-collection bundles. + for embedded in EMBEDDED_COLLECTIONS { + let hosted = build_hosted(embedded)?; + let dir = collections_dir.join(&hosted.manifest.id); + std::fs::create_dir_all(&dir)?; + std::fs::write( + dir.join("collection.json"), + manifest_json(&hosted.manifest)?, + )?; + for (rel_path, bytes) in &hosted.files { + std::fs::write(dir.join(rel_path), bytes)?; + } + } + + // Top-level index + the hosted manifest JSON Schema (served for validation). + std::fs::create_dir_all(&collections_dir)?; + std::fs::write(collections_dir.join("index.json"), self.generate()?)?; + std::fs::write( + collections_dir.join("schema.json"), + include_str!("../schemas/issuetype_collection_schema.json"), + )?; + + tracing::info!( + generator = self.name(), + output = %collections_dir.display(), + "Generated hosted collection bundle" + ); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::collections::get_embedded_collection; + + #[test] + fn test_index_lists_all_embedded_collections() { + let index = build_index().unwrap(); + let ids: Vec<&str> = index.collections.iter().map(|c| c.id.as_str()).collect(); + for name in crate::collections::embedded_collection_names() { + assert!(ids.contains(&name), "index missing {name}"); + } + assert_eq!(index.schema_version, SCHEMA_VERSION); + } + + #[test] + fn test_hosted_files_are_byte_identical_to_embedded() { + let embedded = get_embedded_collection("dev_kanban").unwrap(); + let hosted = build_hosted(embedded).unwrap(); + for entry in &hosted.manifest.issue_types { + let it = embedded + .issuetypes + .iter() + .find(|it| it.key == entry.key) + .unwrap(); + // schema_checksum matches a SHA-256 of the embedded bytes... + assert_eq!(entry.schema_checksum, sha256_hex(it.schema_json.as_bytes())); + // ...and the written file bytes equal the embedded bytes. + let (_, bytes) = hosted + .files + .iter() + .find(|(p, _)| p == &entry.schema_path) + .unwrap(); + assert_eq!(bytes.as_slice(), it.schema_json.as_bytes()); + } + } + + #[test] + fn test_manifest_checksum_matches_verifier_derivation() { + // The producer's manifest.checksum must equal the value the runtime + // verifier derives from the same entries. + for embedded in EMBEDDED_COLLECTIONS { + let hosted = build_hosted(embedded).unwrap(); + let derived = derive_manifest_checksum(&hosted.manifest.issue_types); + assert_eq!(hosted.manifest.checksum.as_deref(), Some(derived.as_str())); + } + } + + #[test] + fn test_generate_produces_parseable_index() { + let generator = CollectionsManifestGenerator; + let json = generator.generate().unwrap(); + let index: CollectionIndex = serde_json::from_str(&json).unwrap(); + assert!(!index.collections.is_empty()); + // Every index checksum must be a 64-char hex string. + for entry in &index.collections { + assert_eq!(entry.checksum.len(), 64); + } + } +} diff --git a/src/docs_gen/mod.rs b/src/docs_gen/mod.rs index 6c219978..8ab2e87f 100644 --- a/src/docs_gen/mod.rs +++ b/src/docs_gen/mod.rs @@ -14,6 +14,7 @@ //! Generated docs include a header warning and are written to `docs/`. pub mod cli; +pub mod collections_manifest; pub mod config; pub mod config_schema; pub mod issuetype; @@ -92,6 +93,7 @@ pub fn generate_all(docs_dir: &Path) -> Result<()> { Box::new(openapi::OpenApiDocGenerator), Box::new(llm_tools::LlmToolsDocGenerator), Box::new(startup::StartupDocGenerator), + Box::new(collections_manifest::CollectionsManifestGenerator), Box::new(config_schema::ConfigSchemaDocGenerator), Box::new(state_schema::StateSchemaDocGenerator), Box::new(schema_index::SchemaIndexDocGenerator), diff --git a/src/docs_gen/schema_index.rs b/src/docs_gen/schema_index.rs index fd64083a..655efe32 100644 --- a/src/docs_gen/schema_index.rs +++ b/src/docs_gen/schema_index.rs @@ -83,6 +83,16 @@ impl DocGenerator for SchemaIndexDocGenerator { "OpenAPI 3.0".to_string(), "REST API specification (generated via utoipa)".to_string(), ], + vec![ + "[collections/schema.json](../collections/schema.json)".to_string(), + "JSON Schema".to_string(), + "Hosted issuetype collection manifest format (collection.json)".to_string(), + ], + vec![ + "[collections/index.json](../collections/index.json)".to_string(), + "JSON".to_string(), + "Index of hosted issuetype collections (fetched during setup)".to_string(), + ], ]; output.push_str(&table(json_headers, &json_rows)); diff --git a/src/issuetypes/collection.rs b/src/issuetypes/collection.rs index 6dc1f3df..b3d016aa 100644 --- a/src/issuetypes/collection.rs +++ b/src/issuetypes/collection.rs @@ -32,6 +32,15 @@ pub struct IssueTypeCollection { /// Sync source metadata (if collection was synced from external provider) #[serde(default)] pub sync_source: Option, + /// Descriptive workflow hints (v1: metadata only, from a hosted manifest) + #[serde(default)] + pub workflow_hints: Option, + /// Collection semver (from a hosted manifest) + #[serde(default)] + pub version: Option, + /// Publisher identifier (from a hosted manifest) + #[serde(default)] + pub publisher: Option, } impl IssueTypeCollection { @@ -42,9 +51,25 @@ impl IssueTypeCollection { description: description.into(), types: vec![], sync_source: None, + workflow_hints: None, + version: None, + publisher: None, } } + /// Attach manifest metadata (workflow hints, version, publisher). + pub fn with_manifest_metadata( + mut self, + workflow_hints: Option, + version: Option, + publisher: Option, + ) -> Self { + self.workflow_hints = workflow_hints; + self.version = version; + self.publisher = publisher; + self + } + /// Set the sync source for this collection pub fn with_sync_source(mut self, source: CollectionSyncSource) -> Self { self.sync_source = Some(source); diff --git a/src/issuetypes/loader.rs b/src/issuetypes/loader.rs index c08d3fa8..8584a53b 100644 --- a/src/issuetypes/loader.rs +++ b/src/issuetypes/loader.rs @@ -22,6 +22,12 @@ pub struct LoadedCollection { pub types: HashMap, /// Ordered list of type keys (from collection.toml types field, or derived) pub type_order: Vec, + /// Descriptive workflow hints (from collection.json, if present) + pub workflow_hints: Option, + /// Collection semver (from collection.json, if present) + pub version: Option, + /// Publisher (from collection.json, if present) + pub publisher: Option, } /// Load all built-in issue types @@ -305,7 +311,7 @@ pub fn validate_collection_types( /// ```text /// templates/ /// ├── dev_kanban/ -/// │ ├── collection.toml (optional: description, types) +/// │ ├── collection.json (optional: description, issue_types order) /// │ ├── TASK.json /// │ ├── TASK.md /// │ ├── FEAT.json @@ -313,13 +319,14 @@ pub fn validate_collection_types( /// │ ├── FIX.json /// │ └── FIX.md /// ├── devops_kanban/ -/// │ ├── collection.toml +/// │ ├── collection.json /// │ └── ... /// ``` /// /// Each subdirectory of `templates_path` is treated as a collection. /// Issue types (*.json files) are loaded directly from the collection directory. -/// Optional `collection.toml` can specify description and types order. +/// Optional `collection.json` (or legacy `collection.toml`) specifies the +/// description and issue type order. pub fn load_collections_from_dir( templates_path: &Path, ) -> Result> { @@ -370,8 +377,8 @@ pub fn load_collections_from_dir( continue; } - // Try to load collection metadata from collection.toml - let (description, type_order) = load_collection_metadata(&path, &types); + // Try to load collection metadata from collection.json / collection.toml + let meta = load_collection_metadata(&path, &types); info!( "Loaded collection '{}' with {} issue types", @@ -383,9 +390,12 @@ pub fn load_collections_from_dir( collection_name.clone(), LoadedCollection { name: collection_name, - description, + description: meta.description, types, - type_order, + type_order: meta.type_order, + workflow_hints: meta.workflow_hints, + version: meta.version, + publisher: meta.publisher, }, ); } @@ -449,19 +459,52 @@ fn load_types_from_collection_dir( Ok(types) } -/// Load optional collection metadata from collection.toml -/// -/// Returns (description, `type_order`) where `type_order` is read from the `types` field -/// in collection.toml, or derived alphabetically from the loaded types. +/// Metadata for a loaded collection, sourced from `collection.json` (preferred) +/// or the legacy `collection.toml`. +struct CollectionMetadata { + description: String, + type_order: Vec, + workflow_hints: Option, + version: Option, + publisher: Option, +} + +/// Load optional collection metadata from `collection.json` (preferred) or the +/// legacy `collection.toml` (for workspaces scaffolded before the JSON migration). fn load_collection_metadata( collection_path: &Path, types: &HashMap, -) -> (String, Vec) { - let metadata_path = collection_path.join("collection.toml"); +) -> CollectionMetadata { + // Preferred: collection.json (current format). + let json_path = collection_path.join("collection.json"); + if json_path.exists() { + if let Ok(content) = fs::read_to_string(&json_path) { + if let Ok(manifest) = + crate::collections::manifest::CollectionManifest::from_json(&content) + { + let type_order = if manifest.issue_types.is_empty() { + derive_type_order(types) + } else { + manifest.type_keys() + }; + return CollectionMetadata { + description: manifest.description, + type_order, + workflow_hints: manifest.workflow_hints, + version: (!manifest.version.is_empty()).then_some(manifest.version), + publisher: manifest.publisher, + }; + } + } + } + // Back-compat: legacy collection.toml. + let metadata_path = collection_path.join("collection.toml"); if metadata_path.exists() { if let Ok(content) = fs::read_to_string(&metadata_path) { - if let Ok(toml_value) = content.parse::() { + // toml 1.0: parse the document as a Table (parsing into Value expects + // a bare value and rejects a document). + if let Ok(toml_value) = toml::from_str::(&content) { let description = toml_value .get("description") .and_then(|v| v.as_str()) @@ -479,7 +522,13 @@ fn load_collection_metadata( }) .unwrap_or_else(|| derive_type_order(types)); - return (description, type_order); + return CollectionMetadata { + description, + type_order, + workflow_hints: None, + version: None, + publisher: None, + }; } } } @@ -496,7 +545,13 @@ fn load_collection_metadata( types.len() ); - (description, derive_type_order(types)) + CollectionMetadata { + description, + type_order: derive_type_order(types), + workflow_hints: None, + version: None, + publisher: None, + } } /// Derive type order from issue types (alphabetical by key) @@ -532,6 +587,43 @@ mod tests { assert!(types.is_empty()); } + #[test] + fn test_load_collection_metadata_reads_collection_json() { + let temp_dir = TempDir::new().unwrap(); + let dir = temp_dir.path(); + fs::write( + dir.join("collection.json"), + r#"{ + "schema_version": 1, + "id": "demo", + "name": "Demo", + "description": "Demo collection", + "issue_types": [ + {"key": "TASK", "schema_path": "TASK.json"}, + {"key": "FEAT", "schema_path": "FEAT.json"} + ] + }"#, + ) + .unwrap(); + let meta = load_collection_metadata(dir, &HashMap::new()); + assert_eq!(meta.description, "Demo collection"); + assert_eq!(meta.type_order, vec!["TASK", "FEAT"]); + } + + #[test] + fn test_load_collection_metadata_falls_back_to_legacy_toml() { + let temp_dir = TempDir::new().unwrap(); + let dir = temp_dir.path(); + fs::write( + dir.join("collection.toml"), + "description = \"Legacy collection\"\ntypes = [\"FIX\", \"INV\"]\n", + ) + .unwrap(); + let meta = load_collection_metadata(dir, &HashMap::new()); + assert_eq!(meta.description, "Legacy collection"); + assert_eq!(meta.type_order, vec!["FIX", "INV"]); + } + #[test] fn test_load_user_types_nonexistent_dir() { let types = load_user_types(Path::new("/nonexistent/path")).unwrap(); diff --git a/src/issuetypes/mod.rs b/src/issuetypes/mod.rs index 668a5f8c..474f13a4 100644 --- a/src/issuetypes/mod.rs +++ b/src/issuetypes/mod.rs @@ -235,6 +235,11 @@ impl IssueTypeRegistry { .type_order .iter() .map(std::string::String::as_str), + ) + .with_manifest_metadata( + loaded_collection.workflow_hints, + loaded_collection.version, + loaded_collection.publisher, ); self.collections.insert(name, collection); diff --git a/src/lib.rs b/src/lib.rs index d77ec8d3..28c35f08 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ // Public modules for type generation pub mod agents; pub mod api; +pub mod collections; pub mod config; pub mod editors; pub mod git; @@ -17,7 +18,6 @@ pub mod state; pub mod types; // Internal modules required by public modules -mod collections; mod issuetypes; mod llm; mod notifications; diff --git a/src/main.rs b/src/main.rs index 66f3c587..b3c478ec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -808,9 +808,9 @@ fn cmd_workflow(config: &Config, action: WorkflowAction) -> Result<()> { fn cmd_docs(_config: &Config, output: Option, only: Option) -> Result<()> { use docs_gen::{ - cli, config, config_schema, issuetype, issuetype_json_schema, jira_api, llms, metadata, - openapi, operator_output_schema, project_analysis_schema, schema_index, shortcuts, startup, - state_schema, taxonomy, DocGenerator, + cli, collections_manifest, config, config_schema, issuetype, issuetype_json_schema, + jira_api, llms, metadata, openapi, operator_output_schema, project_analysis_schema, + schema_index, shortcuts, startup, state_schema, taxonomy, DocGenerator, }; use std::path::PathBuf; @@ -878,9 +878,12 @@ fn cmd_docs(_config: &Config, output: Option, only: Option) -> R Some("llms") => { vec![Box::new(llms::LlmsTxtDocGenerator)] } + Some("collections-manifest") => { + vec![Box::new(collections_manifest::CollectionsManifestGenerator)] + } Some(other) => { println!( - "Unknown generator: {other}. Available: taxonomy, issuetype, metadata, shortcuts, cli, config, openapi, startup, config-schema, state-schema, schema-index, jira-api, operator-output-schema, issuetype-json-schema, project-analysis-schema, llms" + "Unknown generator: {other}. Available: taxonomy, issuetype, metadata, shortcuts, cli, config, openapi, startup, config-schema, state-schema, schema-index, jira-api, operator-output-schema, issuetype-json-schema, project-analysis-schema, llms, collections-manifest" ); return Ok(()); } @@ -903,6 +906,7 @@ fn cmd_docs(_config: &Config, output: Option, only: Option) -> R Box::new(issuetype_json_schema::IssuetypeJsonSchemaDocGenerator), Box::new(project_analysis_schema::ProjectAnalysisSchemaDocGenerator), Box::new(llms::LlmsTxtDocGenerator), + Box::new(collections_manifest::CollectionsManifestGenerator), ] } }; diff --git a/src/rest/dto/issue_types.rs b/src/rest/dto/issue_types.rs index 49166775..9b6ecc83 100644 --- a/src/rest/dto/issue_types.rs +++ b/src/rest/dto/issue_types.rs @@ -437,6 +437,36 @@ pub struct UpdateStepRequest { // Collection DTOs // ============================================================================= +/// Descriptive workflow hints for a collection (v1: metadata only). +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct WorkflowHintsDto { + #[serde(default)] + pub loop_kind: Option, + #[serde(default)] + pub memory_surfaces: Vec, + #[serde(default)] + pub review_gates: Vec, + #[serde(default)] + pub external_tools: Vec, + #[serde(default)] + pub stop_conditions: Vec, + pub runner_semantics: String, +} + +impl From<&crate::collections::manifest::WorkflowHints> for WorkflowHintsDto { + fn from(h: &crate::collections::manifest::WorkflowHints) -> Self { + Self { + loop_kind: h.loop_kind.clone(), + memory_surfaces: h.memory_surfaces.clone(), + review_gates: h.review_gates.clone(), + external_tools: h.external_tools.clone(), + stop_conditions: h.stop_conditions.clone(), + runner_semantics: h.runner_semantics.clone(), + } + } +} + /// Response for a collection #[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] #[ts(export)] @@ -445,6 +475,15 @@ pub struct CollectionResponse { pub description: String, pub types: Vec, pub is_active: bool, + /// Collection semver (present for hosted collections). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, + /// Publisher identifier (present for hosted collections). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub publisher: Option, + /// Descriptive workflow hints (present for hosted collections). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workflow_hints: Option, } impl CollectionResponse { @@ -454,6 +493,9 @@ impl CollectionResponse { description: c.description.clone(), types: c.types.clone(), is_active, + version: c.version.clone(), + publisher: c.publisher.clone(), + workflow_hints: c.workflow_hints.as_ref().map(WorkflowHintsDto::from), } } } @@ -611,10 +653,36 @@ mod tests { description: "Default collection".to_string(), types: vec!["FEAT".to_string(), "FIX".to_string()], is_active: true, + version: None, + publisher: None, + workflow_hints: None, }; let json = serde_json::to_string(&resp).unwrap(); let parsed: CollectionResponse = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.types, vec!["FEAT".to_string(), "FIX".to_string()]); assert!(parsed.is_active); + // Optional hosted-only fields are omitted from the wire when absent. + assert!(!json.contains("workflow_hints")); + } + + #[test] + fn test_collection_response_includes_workflow_hints() { + let collection = IssueTypeCollection::new("dev_kanban", "Dev") + .with_types(["TASK"]) + .with_manifest_metadata( + Some(crate::collections::manifest::WorkflowHints { + loop_kind: Some("single_pass".to_string()), + review_gates: vec!["test_suite".to_string()], + ..Default::default() + }), + Some("1.0.0".to_string()), + Some("untra".to_string()), + ); + let resp = CollectionResponse::from_collection(&collection, true); + assert_eq!(resp.version.as_deref(), Some("1.0.0")); + assert_eq!(resp.publisher.as_deref(), Some("untra")); + let hints = resp.workflow_hints.expect("hints surfaced"); + assert_eq!(hints.loop_kind.as_deref(), Some("single_pass")); + assert_eq!(hints.runner_semantics, "prompt_driven"); } } diff --git a/src/rest/openapi.rs b/src/rest/openapi.rs index 9a58c212..6a142106 100644 --- a/src/rest/openapi.rs +++ b/src/rest/openapi.rs @@ -20,8 +20,8 @@ use crate::rest::dto::{ StepCompleteResponse, StepResponse, SyncKanbanIssueTypesResponse, TicketDetailResponse, UpdateIssueTypeRequest, UpdateModelServerRequest, UpdateStepRequest, UpdateTicketStatusRequest, UpdateTicketStatusResponse, ValidateKanbanCredentialsRequest, - ValidateKanbanCredentialsResponse, WorkflowExportResponse, WorkflowPreviewResponse, - WriteKanbanConfigRequest, WriteKanbanConfigResponse, + ValidateKanbanCredentialsResponse, WorkflowExportResponse, WorkflowHintsDto, + WorkflowPreviewResponse, WriteKanbanConfigRequest, WriteKanbanConfigResponse, }; // AgentProfile interchange types live in `crate::config`, not `rest::dto`. use crate::config::{AgentProfile, DelegatorLaunchConfig, RemoteAgentRef, XOperator}; @@ -59,6 +59,7 @@ use crate::rest::error::ErrorResponse; FieldResponse, StepResponse, CollectionResponse, + WorkflowHintsDto, LaunchTicketResponse, ErrorResponse, // Request types diff --git a/src/schemas/issuetype_collection_schema.json b/src/schemas/issuetype_collection_schema.json new file mode 100644 index 00000000..d889f9dc --- /dev/null +++ b/src/schemas/issuetype_collection_schema.json @@ -0,0 +1,77 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://operator.untra.io/collections/schema.json", + "title": "Operator Hosted Collection Manifest", + "description": "Format for a shareable, hostable issuetype collection (collection.json). A collection references per-issuetype schema files (issuetype_schema.json format) by relative path, each with a SHA-256 checksum. This same format is used for the offline collections embedded in the operator binary.", + "type": "object", + "required": ["schema_version", "id", "name", "issue_types"], + "additionalProperties": false, + "properties": { + "schema_version": { + "type": "integer", + "const": 1, + "description": "Manifest schema version. Unknown versions are rejected by the fetcher (offline fallback used)." + }, + "id": { "type": "string", "description": "Stable collection id (e.g. dev_kanban)." }, + "name": { "type": "string", "description": "Display name." }, + "description": { "type": "string", "description": "One-line description." }, + "version": { "type": "string", "description": "Collection semver." }, + "publisher": { "type": ["string", "null"], "description": "Publisher identifier (e.g. untra)." }, + "author": { "type": ["string", "null"], "description": "Human author/attribution shown in the setup picker. Built-ins are authored by 'Operator!'; a kanban-imported collection lists the provider name + workspace/project." }, + "url": { "type": ["string", "null"], "description": "Link to the collection's source (GitHub repo or project page)." }, + "license": { "type": ["string", "null"], "description": "SPDX license id." }, + "tags": { "type": "array", "items": { "type": "string" } }, + "compatibility": { + "type": ["object", "null"], + "additionalProperties": false, + "properties": { + "operator_version": { + "type": ["string", "null"], + "description": "Minimum operator version this collection targets (e.g. >=0.2.0)." + } + } + }, + "issue_types": { + "type": "array", + "description": "Issue types in this collection, in display order.", + "items": { + "type": "object", + "required": ["key", "schema_path"], + "additionalProperties": false, + "properties": { + "key": { "type": "string", "description": "Issue type key (e.g. TASK)." }, + "schema_path": { "type": "string", "description": "Path to the issuetype JSON, relative to the manifest." }, + "schema_checksum": { "type": "string", "description": "SHA-256 (lowercase hex) of the issuetype JSON bytes. Required for hosted manifests; omitted for embedded ones." }, + "template_path": { "type": ["string", "null"], "description": "Optional path to the markdown template, relative to the manifest." }, + "template_checksum": { "type": ["string", "null"], "description": "SHA-256 (lowercase hex) of the markdown template bytes, if present." } + } + } + }, + "workflow_hints": { + "type": ["object", "null"], + "description": "Descriptive metadata about the collection's intended agentic loop shape. v1 is metadata only: stored and displayed but not executed.", + "additionalProperties": false, + "properties": { + "loop_kind": { "type": ["string", "null"], "description": "Loop shape (e.g. single_pass, ralph, review_loop)." }, + "memory_surfaces": { "type": "array", "items": { "type": "string" } }, + "review_gates": { "type": "array", "items": { "type": "string" } }, + "external_tools": { "type": "array", "items": { "type": "string" } }, + "stop_conditions": { "type": "array", "items": { "type": "string" } }, + "runner_semantics": { + "type": "string", + "const": "prompt_driven", + "description": "How the runner interprets the hints. v1 is always prompt_driven." + } + } + }, + "default_selected": { + "type": "array", + "items": { "type": "string" }, + "description": "Subset of issue_types[].key selected by default in the setup picker." + }, + "checksum": { + "type": ["string", "null"], + "description": "SHA-256 (lowercase hex) derived from the issue-type file checksums (schema_checksum + template_checksum joined by newlines, in issue_types order)." + } + } +} diff --git a/src/startup/templates.rs b/src/startup/templates.rs index b45b1333..a3254a42 100644 --- a/src/startup/templates.rs +++ b/src/startup/templates.rs @@ -117,7 +117,7 @@ pub fn scaffold_collection(templates_path: &Path, collection: &EmbeddedCollectio })?; // Write collection manifest - fs::write(collection_path.join("collection.toml"), collection.manifest)?; + fs::write(collection_path.join("collection.json"), collection.manifest)?; // Write issuetype JSON and markdown files for issuetype in collection.issuetypes { @@ -213,8 +213,8 @@ mod tests { assert!(templates_path.join("devops_kanban/SPIKE.json").exists()); assert!(templates_path.join("devops_kanban/INV.json").exists()); - // Check collection.toml was created - assert!(templates_path.join("dev_kanban/collection.toml").exists()); + // Check collection.json was created + assert!(templates_path.join("dev_kanban/collection.json").exists()); } #[test] @@ -240,7 +240,7 @@ mod tests { scaffold_collection_by_name(&templates_path, "simple").unwrap(); - assert!(templates_path.join("simple/collection.toml").exists()); + assert!(templates_path.join("simple/collection.json").exists()); assert!(templates_path.join("simple/TASK.json").exists()); assert!(templates_path.join("simple/TASK.md").exists()); } diff --git a/src/templates/mod.rs b/src/templates/mod.rs index a85cd25a..06f1531a 100644 --- a/src/templates/mod.rs +++ b/src/templates/mod.rs @@ -238,3 +238,72 @@ impl std::fmt::Display for TemplateType { write!(f, "{}", self.display_name()) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// (key, glyph, color) source-of-truth, mirroring `src/collections/full/*.json`. + /// A drift between the embedded schemas and these expectations should fail. + const KEY_GLYPH_COLOR: &[(&str, &str, &str)] = &[ + ("FEAT", "*", "green"), + ("FIX", "#", "magenta"), + ("TASK", ">", "cyan"), + ("SPIKE", "?", "blue"), + ("INV", "!", "yellow"), + ("ASSESS", "~", "magenta"), + ("SYNC", "@", "blue"), + ("INIT", "%", "green"), + ]; + + #[test] + fn test_glyph_for_key_known_types_map_to_expected_glyphs() { + for (key, glyph, _) in KEY_GLYPH_COLOR { + assert_eq!( + glyph_for_key(key), + *glyph, + "glyph for {key} should be {glyph}" + ); + } + } + + #[test] + fn test_color_for_key_known_types_map_to_expected_colors() { + for (key, _, color) in KEY_GLYPH_COLOR { + assert_eq!( + color_for_key(key), + Some(*color), + "color for {key} should be {color}" + ); + } + } + + #[test] + fn test_glyph_for_key_unknown_returns_question_fallback() { + // Keys genuinely absent from the map (deliberately NOT "SPIKE", whose + // real glyph also happens to be "?"). + assert_eq!(glyph_for_key("NOPE"), "?"); + assert_eq!(glyph_for_key(""), "?"); + assert_eq!(glyph_for_key("feat"), "?", "lookup is case-sensitive"); + } + + #[test] + fn test_color_for_key_unknown_returns_none() { + assert_eq!(color_for_key("NOPE"), None); + assert_eq!(color_for_key(""), None); + assert_eq!(color_for_key("feat"), None, "lookup is case-sensitive"); + } + + #[test] + fn test_glyph_and_color_maps_cover_every_template_type() { + // Every builtin TemplateType must have an entry in both maps. Asserted + // against the maps directly (not via glyph_for_key) because SPIKE's real + // glyph is "?", indistinguishable from the lookup fallback. Guards + // against adding an enum variant whose schema omits glyph/color. + for tt in TemplateType::all() { + let key = tt.as_str(); + assert!(GLYPH_MAP.contains_key(key), "{key} missing from GLYPH_MAP"); + assert!(COLOR_MAP.contains_key(key), "{key} missing from COLOR_MAP"); + } + } +} diff --git a/src/ui/setup/mod.rs b/src/ui/setup/mod.rs index 51695470..d8cdaf78 100644 --- a/src/ui/setup/mod.rs +++ b/src/ui/setup/mod.rs @@ -30,14 +30,28 @@ pub struct SetupScreen { pub(crate) projects_by_tool: HashMap>, /// Selected collection preset pub selected_preset: CollectionPreset, - /// Custom issuetype collection (only used when preset is Custom) + /// Effective issuetype collection (used when preset is Custom, e.g. from the + /// hosted browser's merged selection) pub custom_collection: Vec, + /// Dynamic collection-source options (curated + per-provider imports), + /// rebuilt on entering the collection-source step + pub(crate) source_options: Vec, /// List state for collection source selection pub(crate) source_state: ListState, - /// List state for custom collection selection - pub(crate) collection_state: ListState, - /// Whether we came from custom selection (for back navigation) - pub(crate) from_custom: bool, + /// Transient notice shown on the collection-source step (e.g. a deferred + /// kanban import message) + pub(crate) import_notice: Option, + // ─── Hosted Collection State ─────────────────────────────────────────────── + /// Collections resolved for the hosted picker (hosted + embedded fallback) + pub hosted_resolved: Vec, + /// List state for the hosted collection picker (highlight cursor) + pub(crate) hosted_state: ListState, + /// Ids of collections checked in the multi-select hosted picker + pub(crate) hosted_selected_ids: Vec, + /// Whether the hosted collection list has been loaded (fetch attempted) + pub(crate) hosted_loaded: bool, + /// Id of the chosen hosted collection (set when a hosted collection is picked) + pub selected_hosted_id: Option, /// Selected optional fields to include in TASK (and other types) pub task_optional_fields: Vec, /// List state for field configuration selection @@ -90,9 +104,6 @@ impl SetupScreen { let mut source_state = ListState::default(); source_state.select(Some(0)); - let mut collection_state = ListState::default(); - collection_state.select(Some(0)); - let mut field_state = ListState::default(); field_state.select(Some(0)); @@ -105,6 +116,9 @@ impl SetupScreen { let mut worktree_state = ListState::default(); worktree_state.select(Some(0)); + let mut hosted_state = ListState::default(); + hosted_state.select(Some(0)); + Self { visible: true, step: SetupStep::Welcome, @@ -113,10 +127,15 @@ impl SetupScreen { detected_tools, projects_by_tool, selected_preset: CollectionPreset::DevopsKanban, - custom_collection: ALL_ISSUE_TYPES.iter().map(|s| (*s).to_string()).collect(), + custom_collection: Vec::new(), + source_options: CollectionSourceOption::curated(), source_state, - collection_state, - from_custom: false, + import_notice: None, + hosted_resolved: Vec::new(), + hosted_state, + hosted_selected_ids: Vec::new(), + hosted_loaded: false, + selected_hosted_id: None, // Default: all optional fields enabled task_optional_fields: TASK_OPTIONAL_FIELDS .iter() @@ -164,6 +183,45 @@ impl SetupScreen { self.task_optional_fields.clone() } + /// The resolved hosted collection currently highlighted in the picker. + pub(crate) fn highlighted_hosted( + &self, + ) -> Option<&crate::collections::fetch::ResolvedCollection> { + let i = self.hosted_state.selected()?; + self.hosted_resolved.get(i) + } + + /// The resolved hosted collection the user committed to (by id), for scaffolding. + pub fn selected_hosted_collections( + &self, + ) -> Vec<&crate::collections::fetch::ResolvedCollection> { + if !self.hosted_selected_ids.is_empty() { + self.hosted_resolved + .iter() + .filter(|r| self.hosted_selected_ids.contains(&r.manifest.id)) + .collect() + } else if let Some(id) = self.selected_hosted_id.as_deref() { + self.hosted_resolved + .iter() + .filter(|r| r.manifest.id == id) + .collect() + } else { + Vec::new() + } + } + + /// Load the hosted collection picker list (hosted manifest + embedded fallback). + /// + /// Always populates at least the embedded collections, so the picker is never + /// empty even offline. `manifest_url` should be `None` when fetching is disabled. + pub async fn load_hosted_collections(&mut self, manifest_url: Option<&str>, timeout_secs: u64) { + self.hosted_resolved = + crate::collections::fetch::resolve_for_setup(manifest_url, timeout_secs).await; + self.hosted_state + .select((!self.hosted_resolved.is_empty()).then_some(0)); + self.hosted_loaded = true; + } + /// Get the selected startup ticket types to create pub fn selected_startup_tickets(&self) -> Vec { self.startup_ticket_options @@ -177,23 +235,70 @@ impl SetupScreen { fn selected_source(&self) -> Option { self.source_state .selected() - .map(|i| CollectionSourceOption::all()[i]) + .and_then(|i| self.source_options.get(i).cloned()) + } + + /// Enter the collection-source step, rebuilding the dynamic option list from + /// the kanban providers detected/configured earlier in the wizard. + fn enter_collection_source(&mut self) { + self.source_options = + CollectionSourceOption::with_providers(&self.detected_kanban_providers); + self.source_state.select(Some(0)); + self.import_notice = None; + self.step = SetupStep::CollectionSource; + } + + /// Commit the hosted-picker selection: union the issue types of every checked + /// collection (or the highlighted one if none are checked), in first-seen + /// order, and advance to the field-config step. + fn commit_hosted_selection(&mut self) { + // Resolve the chosen collections by id (checked set, else highlighted). + let chosen: Vec<&crate::collections::fetch::ResolvedCollection> = + if self.hosted_selected_ids.is_empty() { + self.highlighted_hosted().into_iter().collect() + } else { + self.hosted_resolved + .iter() + .filter(|r| self.hosted_selected_ids.contains(&r.manifest.id)) + .collect() + }; + if chosen.is_empty() { + return; + } + + let mut merged: Vec = Vec::new(); + for r in &chosen { + let keys = if r.manifest.default_selected.is_empty() { + r.manifest.type_keys() + } else { + r.manifest.default_selected.clone() + }; + for k in keys { + if !merged.contains(&k) { + merged.push(k); + } + } + } + + // Record the single committed id when exactly one collection is chosen + // (drives back-navigation + scaffolding); None when several are merged. + self.selected_hosted_id = (chosen.len() == 1).then(|| chosen[0].manifest.id.clone()); + self.selected_preset = CollectionPreset::Custom; + self.custom_collection = merged; + self.step = SetupStep::TaskFieldConfig; } /// Toggle selection (Space key) pub fn toggle_selection(&mut self) { match self.step { - SetupStep::CustomCollection => { - // Toggle the currently highlighted collection item - if let Some(i) = self.collection_state.selected() { - let types = ALL_ISSUE_TYPES; - if i < types.len() { - let type_str = types[i].to_string(); - if self.custom_collection.contains(&type_str) { - self.custom_collection.retain(|t| t != &type_str); - } else { - self.custom_collection.push(type_str); - } + SetupStep::HostedCollectionFetch => { + // Toggle the highlighted collection in the multi-select picker. + if let Some(r) = self.highlighted_hosted() { + let id = r.manifest.id.clone(); + if let Some(pos) = self.hosted_selected_ids.iter().position(|x| x == &id) { + self.hosted_selected_ids.remove(pos); + } else { + self.hosted_selected_ids.push(id); } } } @@ -248,17 +353,18 @@ impl SetupScreen { pub fn select_next(&mut self) { match self.step { SetupStep::CollectionSource => { - let len = CollectionSourceOption::all().len(); - let i = self.source_state.selected().map_or(0, |i| (i + 1) % len); - self.source_state.select(Some(i)); + let len = self.source_options.len(); + if len > 0 { + let i = self.source_state.selected().map_or(0, |i| (i + 1) % len); + self.source_state.select(Some(i)); + } } - SetupStep::CustomCollection => { - let len = ALL_ISSUE_TYPES.len(); - let i = self - .collection_state - .selected() - .map_or(0, |i| (i + 1) % len); - self.collection_state.select(Some(i)); + SetupStep::HostedCollectionFetch => { + let len = self.hosted_resolved.len(); + if len > 0 { + let i = self.hosted_state.selected().map_or(0, |i| (i + 1) % len); + self.hosted_state.select(Some(i)); + } } SetupStep::TaskFieldConfig => { let len = TASK_OPTIONAL_FIELDS.len(); @@ -288,23 +394,30 @@ impl SetupScreen { pub fn select_prev(&mut self) { match self.step { SetupStep::CollectionSource => { - let len = CollectionSourceOption::all().len(); - let i = - self.source_state - .selected() - .map_or(0, |i| if i == 0 { len - 1 } else { i - 1 }); - self.source_state.select(Some(i)); + let len = self.source_options.len(); + if len > 0 { + let i = self.source_state.selected().map_or(0, |i| { + if i == 0 { + len - 1 + } else { + i - 1 + } + }); + self.source_state.select(Some(i)); + } } - SetupStep::CustomCollection => { - let len = ALL_ISSUE_TYPES.len(); - let i = self.collection_state.selected().map_or(0, |i| { - if i == 0 { - len - 1 - } else { - i - 1 - } - }); - self.collection_state.select(Some(i)); + SetupStep::HostedCollectionFetch => { + let len = self.hosted_resolved.len(); + if len > 0 { + let i = self.hosted_state.selected().map_or(0, |i| { + if i == 0 { + len - 1 + } else { + i - 1 + } + }); + self.hosted_state.select(Some(i)); + } } SetupStep::TaskFieldConfig => { let len = TASK_OPTIONAL_FIELDS.len(); @@ -346,7 +459,36 @@ impl SetupScreen { pub fn confirm(&mut self) -> SetupResult { match self.step { SetupStep::Welcome => { - self.step = SetupStep::CollectionSource; + // Kanban setup runs first so the collection step can offer + // "import from a configured provider" options. Detect providers + // from environment variables on the way into the kanban step. + if !self.kanban_detection_complete { + self.detected_kanban_providers = + crate::api::providers::kanban::detect_kanban_env_vars(); + self.kanban_detection_complete = true; + } + self.step = SetupStep::KanbanInfo; + SetupResult::Continue + } + SetupStep::KanbanInfo => { + // Configure valid providers, otherwise proceed to the collection step. + if self.valid_kanban_providers.is_empty() || self.kanban_skipped { + self.enter_collection_source(); + } else { + self.step = SetupStep::KanbanProviderSetup { provider_index: 0 }; + } + SetupResult::Continue + } + SetupStep::KanbanProviderSetup { provider_index } => { + // Move to the next provider or on to the collection step. + let next_index = provider_index + 1; + if next_index < self.valid_kanban_providers.len() { + self.step = SetupStep::KanbanProviderSetup { + provider_index: next_index, + }; + } else { + self.enter_collection_source(); + } SetupResult::Continue } SetupStep::CollectionSource => { @@ -354,32 +496,38 @@ impl SetupScreen { match source { CollectionSourceOption::Simple => { self.selected_preset = CollectionPreset::Simple; - self.from_custom = false; + self.selected_hosted_id = None; self.step = SetupStep::TaskFieldConfig; SetupResult::Continue } CollectionSourceOption::DevKanban => { self.selected_preset = CollectionPreset::DevKanban; - self.from_custom = false; + self.selected_hosted_id = None; self.step = SetupStep::TaskFieldConfig; SetupResult::Continue } CollectionSourceOption::DevopsKanban => { self.selected_preset = CollectionPreset::DevopsKanban; - self.from_custom = false; + self.selected_hosted_id = None; self.step = SetupStep::TaskFieldConfig; SetupResult::Continue } - CollectionSourceOption::ImportJira => SetupResult::ExitUnimplemented( - "Jira import is not yet implemented".to_string(), - ), - CollectionSourceOption::ImportNotion => SetupResult::ExitUnimplemented( - "Notion import is not yet implemented".to_string(), - ), - CollectionSourceOption::CustomSelection => { - self.selected_preset = CollectionPreset::Custom; - self.from_custom = true; - self.step = SetupStep::CustomCollection; + CollectionSourceOption::Browse => { + // The async list load is triggered by the key handler on + // entering this step (see handle_key); reset prior state. + self.hosted_loaded = false; + self.selected_hosted_id = None; + self.hosted_selected_ids.clear(); + self.step = SetupStep::HostedCollectionFetch; + SetupResult::Continue + } + CollectionSourceOption::ImportFromProvider(r) => { + // Import is scaffolded: structural conversion is deferred. + // Surface a provider-specific notice and stay on the step. + self.import_notice = Some(format!( + "Importing from {} is coming soon.", + r.author_attribution() + )); SetupResult::Continue } } @@ -387,10 +535,9 @@ impl SetupScreen { SetupResult::Continue } } - SetupStep::CustomCollection => { - if !self.custom_collection.is_empty() { - self.step = SetupStep::TaskFieldConfig; - } + SetupStep::HostedCollectionFetch => { + // Commit the checked collections (or the highlighted one). + self.commit_hosted_selection(); SetupResult::Continue } SetupStep::TaskFieldConfig => { @@ -439,68 +586,22 @@ impl SetupScreen { SetupStep::TmuxOnboarding => { // Only allow proceeding if tmux is available if matches!(self.tmux_status, TmuxDetectionStatus::Available { .. }) { - // Detect kanban providers if not already done - if !self.kanban_detection_complete { - self.detected_kanban_providers = - crate::api::providers::kanban::detect_kanban_env_vars(); - self.kanban_detection_complete = true; - } - self.step = SetupStep::KanbanInfo; + self.step = SetupStep::AcceptanceCriteria; } // If tmux not available, stay on this step (user must install or go back) SetupResult::Continue } SetupStep::VSCodeSetup => { // For now, allow proceeding (extension check will be added later) - // Detect kanban providers if not already done - if !self.kanban_detection_complete { - self.detected_kanban_providers = - crate::api::providers::kanban::detect_kanban_env_vars(); - self.kanban_detection_complete = true; - } - self.step = SetupStep::KanbanInfo; + self.step = SetupStep::AcceptanceCriteria; SetupResult::Continue } SetupStep::CmuxSetup => { - // Detect kanban providers if not already done - if !self.kanban_detection_complete { - self.detected_kanban_providers = - crate::api::providers::kanban::detect_kanban_env_vars(); - self.kanban_detection_complete = true; - } - self.step = SetupStep::KanbanInfo; + self.step = SetupStep::AcceptanceCriteria; SetupResult::Continue } SetupStep::ZellijSetup => { - // Detect kanban providers if not already done - if !self.kanban_detection_complete { - self.detected_kanban_providers = - crate::api::providers::kanban::detect_kanban_env_vars(); - self.kanban_detection_complete = true; - } - self.step = SetupStep::KanbanInfo; - SetupResult::Continue - } - SetupStep::KanbanInfo => { - // If no valid providers or skipped, go to acceptance criteria - if self.valid_kanban_providers.is_empty() || self.kanban_skipped { - self.step = SetupStep::AcceptanceCriteria; - } else { - // Start with first valid provider - self.step = SetupStep::KanbanProviderSetup { provider_index: 0 }; - } - SetupResult::Continue - } - SetupStep::KanbanProviderSetup { provider_index } => { - // Move to next provider or acceptance criteria - let next_index = provider_index + 1; - if next_index < self.valid_kanban_providers.len() { - self.step = SetupStep::KanbanProviderSetup { - provider_index: next_index, - }; - } else { - self.step = SetupStep::AcceptanceCriteria; - } + self.step = SetupStep::AcceptanceCriteria; SetupResult::Continue } SetupStep::AcceptanceCriteria => { @@ -525,19 +626,42 @@ impl SetupScreen { pub fn go_back(&mut self) -> SetupResult { match self.step { SetupStep::Welcome => SetupResult::Cancel, - SetupStep::CollectionSource => { + SetupStep::KanbanInfo => { self.step = SetupStep::Welcome; SetupResult::Continue } - SetupStep::CustomCollection => { - self.step = SetupStep::CollectionSource; + SetupStep::KanbanProviderSetup { provider_index } => { + if provider_index > 0 { + self.step = SetupStep::KanbanProviderSetup { + provider_index: provider_index - 1, + }; + } else { + self.step = SetupStep::KanbanInfo; + } + SetupResult::Continue + } + SetupStep::CollectionSource => { + // Return to the kanban step that preceded the collection step. + if !self.valid_kanban_providers.is_empty() && !self.kanban_skipped { + let last_index = self.valid_kanban_providers.len() - 1; + self.step = SetupStep::KanbanProviderSetup { + provider_index: last_index, + }; + } else { + self.step = SetupStep::KanbanInfo; + } + SetupResult::Continue + } + SetupStep::HostedCollectionFetch => { + self.enter_collection_source(); SetupResult::Continue } SetupStep::TaskFieldConfig => { - if self.from_custom { - self.step = SetupStep::CustomCollection; + // A Custom preset means the hosted browser produced the selection. + if matches!(self.selected_preset, CollectionPreset::Custom) { + self.step = SetupStep::HostedCollectionFetch; } else { - self.step = SetupStep::CollectionSource; + self.enter_collection_source(); } SetupResult::Continue } @@ -565,8 +689,8 @@ impl SetupScreen { self.step = SetupStep::WorktreePreference; SetupResult::Continue } - SetupStep::KanbanInfo => { - // Go back to the appropriate wrapper setup step + SetupStep::AcceptanceCriteria => { + // Go back to the wrapper setup step that preceded this one. match self.selected_wrapper { SessionWrapperType::Tmux => self.step = SetupStep::TmuxOnboarding, SessionWrapperType::Vscode => self.step = SetupStep::VSCodeSetup, @@ -575,28 +699,6 @@ impl SetupScreen { } SetupResult::Continue } - SetupStep::KanbanProviderSetup { provider_index } => { - if provider_index > 0 { - self.step = SetupStep::KanbanProviderSetup { - provider_index: provider_index - 1, - }; - } else { - self.step = SetupStep::KanbanInfo; - } - SetupResult::Continue - } - SetupStep::AcceptanceCriteria => { - // Go back to last kanban provider setup or kanban info - if !self.valid_kanban_providers.is_empty() && !self.kanban_skipped { - let last_index = self.valid_kanban_providers.len() - 1; - self.step = SetupStep::KanbanProviderSetup { - provider_index: last_index, - }; - } else { - self.step = SetupStep::KanbanInfo; - } - SetupResult::Continue - } SetupStep::StartupTickets => { self.step = SetupStep::AcceptanceCriteria; SetupResult::Continue @@ -617,7 +719,7 @@ impl SetupScreen { match self.step.clone() { SetupStep::Welcome => self.render_welcome_step(frame), SetupStep::CollectionSource => self.render_collection_source_step(frame), - SetupStep::CustomCollection => self.render_custom_collection_step(frame), + SetupStep::HostedCollectionFetch => self.render_hosted_collection_step(frame), SetupStep::TaskFieldConfig => self.render_task_field_config_step(frame), SetupStep::SessionWrapperChoice => self.render_session_wrapper_choice_step(frame), SetupStep::WorktreePreference => self.render_worktree_preference_step(frame), diff --git a/src/ui/setup/steps/collection.rs b/src/ui/setup/steps/collection.rs index d554378d..84340d31 100644 --- a/src/ui/setup/steps/collection.rs +++ b/src/ui/setup/steps/collection.rs @@ -1,7 +1,6 @@ -//! Collection source and custom collection step rendering +//! Collection source step rendering use crate::ui::dialogs::centered_rect; -use crate::ui::setup::types::{CollectionSourceOption, ALL_ISSUE_TYPES}; use crate::ui::setup::SetupScreen; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout}, @@ -30,7 +29,8 @@ impl SetupScreen { .constraints([ Constraint::Length(3), // Title Constraint::Length(2), // Instructions - Constraint::Min(8), // Options list + Constraint::Min(6), // Options list + Constraint::Length(2), // Notice (deferred import message) Constraint::Length(2), // Footer ]) .split(inner); @@ -52,20 +52,16 @@ impl SetupScreen { .style(Style::default().fg(Color::Gray)); frame.render_widget(instructions, chunks[1]); - // Options list - let items: Vec = CollectionSourceOption::all() + // Options list (curated collections, the hosted browser, then one import + // option per configured kanban provider). + let items: Vec = self + .source_options .iter() .map(|opt| { - let style = if opt.is_unimplemented() { - Style::default().fg(Color::DarkGray) - } else { - Style::default() - }; - ListItem::new(vec![ Line::from(vec![Span::styled( opt.label(), - style.add_modifier(Modifier::BOLD), + Style::default().add_modifier(Modifier::BOLD), )]), Line::from(vec![ Span::raw(" "), @@ -81,135 +77,24 @@ impl SetupScreen { frame.render_stateful_widget(list, chunks[2], &mut self.source_state); - // Footer - let footer = Paragraph::new(Line::from(vec![ - Span::styled("Enter", Style::default().fg(Color::Yellow)), - Span::raw(" select "), - Span::styled("Esc", Style::default().fg(Color::Yellow)), - Span::raw(" back"), - ])) - .alignment(Alignment::Center); - frame.render_widget(footer, chunks[3]); - } - - pub(crate) fn render_custom_collection_step(&mut self, frame: &mut Frame) { - let area = centered_rect(60, 60, frame.area()); - frame.render_widget(Clear, area); - - let block = Block::default() - .title(Line::from(vec![ - Span::raw(" "), - Span::styled( - "Operator!", - Style::default() - .fg(Color::LightRed) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" Setup - Issue Types "), - ])) - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)); - - let inner = block.inner(area); - frame.render_widget(block, area); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(2) - .constraints([ - Constraint::Length(3), // Title - Constraint::Length(2), // Instructions - Constraint::Min(8), // Collection list - Constraint::Length(2), // Footer - ]) - .split(inner); - - // Title - let title = Paragraph::new(Line::from(vec![Span::styled( - "Select Issue Types", - Style::default() - .fg(Color::LightRed) - .add_modifier(Modifier::BOLD), - )])) - .alignment(Alignment::Center); - frame.render_widget(title, chunks[0]); - - // Instructions - let instructions = Paragraph::new(vec![Line::from( - "Use arrows to navigate, Space to toggle, Enter to continue", - )]) - .alignment(Alignment::Center) - .style(Style::default().fg(Color::Gray)); - frame.render_widget(instructions, chunks[1]); - - // Collection list - let items: Vec = ALL_ISSUE_TYPES - .iter() - .map(|t| { - let is_selected = self.custom_collection.contains(&(*t).to_string()); - let checkbox = if is_selected { "[x]" } else { "[ ]" }; - let description = match *t { - "TASK" => "Focused task that executes one specific thing", - "FEAT" => "New feature or enhancement", - "FIX" => "Bug fix, follow-up work, tech debt", - "SPIKE" => "Research or exploration (paired mode)", - "INV" => "Incident investigation (paired mode)", - _ => "", - }; - ListItem::new(vec![ - Line::from(vec![ - Span::styled( - checkbox, - Style::default().fg(if is_selected { - Color::Green - } else { - Color::DarkGray - }), - ), - Span::raw(" "), - Span::styled( - *t, - Style::default() - .add_modifier(Modifier::BOLD) - .fg(if is_selected { - Color::White - } else { - Color::Gray - }), - ), - ]), - Line::from(vec![ - Span::raw(" "), - Span::styled(description, Style::default().fg(Color::DarkGray)), - ]), - ]) - }) - .collect(); - - let list = List::new(items) - .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) - .highlight_symbol("> "); - - frame.render_stateful_widget(list, chunks[2], &mut self.collection_state); + // Deferred-import notice (shown when an import option is selected). + if let Some(notice) = &self.import_notice { + let notice_para = Paragraph::new(Line::from(vec![Span::styled( + notice.clone(), + Style::default().fg(Color::Yellow), + )])) + .alignment(Alignment::Center); + frame.render_widget(notice_para, chunks[3]); + } // Footer - let selected_count = self.custom_collection.len(); let footer = Paragraph::new(Line::from(vec![ - Span::styled( - format!("{selected_count} selected"), - Style::default().fg(if selected_count > 0 { - Color::Green - } else { - Color::Red - }), - ), - Span::raw(" | "), Span::styled("Enter", Style::default().fg(Color::Yellow)), - Span::raw(" continue "), + Span::raw(" select "), Span::styled("Esc", Style::default().fg(Color::Yellow)), Span::raw(" back"), ])) .alignment(Alignment::Center); - frame.render_widget(footer, chunks[3]); + frame.render_widget(footer, chunks[4]); } } diff --git a/src/ui/setup/steps/hosted.rs b/src/ui/setup/steps/hosted.rs index bf2713c5..2ede3bc6 100644 --- a/src/ui/setup/steps/hosted.rs +++ b/src/ui/setup/steps/hosted.rs @@ -61,13 +61,15 @@ impl SetupScreen { return; } - let instructions = - Paragraph::new(vec![Line::from("Use arrows to navigate, Enter to select")]) - .alignment(Alignment::Center) - .style(Style::default().fg(Color::Gray)); + let instructions = Paragraph::new(vec![Line::from( + "Arrows to navigate, Space to toggle, Enter to confirm", + )]) + .alignment(Alignment::Center) + .style(Style::default().fg(Color::Gray)); frame.render_widget(instructions, chunks[1]); - // Collection list: name + version + verification status. + // Collection list: checkbox + name + version + workflow count + badge, + // with author + description on the second line. let items: Vec = self .hosted_resolved .iter() @@ -78,8 +80,21 @@ impl SetupScreen { ("ⓘ built-in", Style::default().fg(Color::Yellow)) } }; + let checked = self.hosted_selected_ids.contains(&r.manifest.id); + let checkbox = if checked { "[x]" } else { "[ ]" }; + let count = r.manifest.issue_types.len(); + let author = r.manifest.author.clone().unwrap_or_default(); ListItem::new(vec![ Line::from(vec![ + Span::styled( + checkbox, + Style::default().fg(if checked { + Color::Green + } else { + Color::DarkGray + }), + ), + Span::raw(" "), Span::styled( r.manifest.name.clone(), Style::default().add_modifier(Modifier::BOLD), @@ -90,14 +105,27 @@ impl SetupScreen { Style::default().fg(Color::DarkGray), ), Span::raw(" "), + Span::styled( + format!("{count} workflow{}", if count == 1 { "" } else { "s" }), + Style::default().fg(Color::Cyan), + ), + Span::raw(" "), Span::styled(badge, badge_style), ]), Line::from(vec![ - Span::raw(" "), + Span::raw(" "), Span::styled( r.manifest.description.clone(), Style::default().fg(Color::DarkGray), ), + Span::styled( + if author.is_empty() { + String::new() + } else { + format!(" — by {author}") + }, + Style::default().fg(Color::DarkGray), + ), ]), ]) }) @@ -125,6 +153,12 @@ impl SetupScreen { } lines.push(Line::from(hint_spans)); } + if let Some(url) = &r.manifest.url { + lines.push(Line::from(vec![ + Span::styled("URL: ", Style::default().fg(Color::Cyan)), + Span::raw(url.clone()), + ])); + } if let Some(note) = &r.note { lines.push(Line::from(vec![Span::styled( format!("⚠ {note}"), @@ -135,9 +169,21 @@ impl SetupScreen { frame.render_widget(details, chunks[3]); } + let selected = self.hosted_selected_ids.len(); let footer = Paragraph::new(Line::from(vec![ + Span::styled( + format!("{selected} selected"), + Style::default().fg(if selected > 0 { + Color::Green + } else { + Color::DarkGray + }), + ), + Span::raw(" | "), + Span::styled("Space", Style::default().fg(Color::Yellow)), + Span::raw(" toggle "), Span::styled("Enter", Style::default().fg(Color::Yellow)), - Span::raw(" select "), + Span::raw(" confirm "), Span::styled("Esc", Style::default().fg(Color::Yellow)), Span::raw(" back"), ])) diff --git a/src/ui/setup/tests.rs b/src/ui/setup/tests.rs index 84000024..ebaa28ae 100644 --- a/src/ui/setup/tests.rs +++ b/src/ui/setup/tests.rs @@ -96,6 +96,83 @@ fn test_setup_wrapper_navigation_flow() { assert_eq!(screen.step, SetupStep::SessionWrapperChoice); } +// ─── Hosted Collection Picker Tests ───────────────────────────────────────── + +#[test] +fn test_collection_source_browse_enters_fetch_step() { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.enter_collection_source(); + // Select the "Browse Hosted Collections" option. + let idx = screen + .source_options + .iter() + .position(|o| *o == CollectionSourceOption::Browse) + .unwrap(); + screen.source_state.select(Some(idx)); + + screen.confirm(); + assert_eq!(screen.step, SetupStep::HostedCollectionFetch); + assert!(!screen.hosted_loaded); +} + +#[tokio::test] +async fn test_hosted_picker_offline_fallback_and_commit() { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.step = SetupStep::HostedCollectionFetch; + + // Offline (no URL) -> embedded fallback; picker is never empty. + screen.load_hosted_collections(None, 1).await; + assert!(screen.hosted_loaded); + assert!(!screen.hosted_resolved.is_empty()); + + // Highlight dev_kanban and commit. + let idx = screen + .hosted_resolved + .iter() + .position(|r| r.manifest.id == "dev_kanban") + .expect("dev_kanban present in embedded fallback"); + screen.hosted_state.select(Some(idx)); + + screen.confirm(); + assert_eq!(screen.step, SetupStep::TaskFieldConfig); + assert_eq!(screen.selected_hosted_id.as_deref(), Some("dev_kanban")); + // default_selected seeds the custom collection. + assert_eq!(screen.collection(), vec!["TASK", "FEAT", "FIX"]); +} + +#[tokio::test] +async fn test_hosted_picker_multi_select_merges_issue_types() { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.step = SetupStep::HostedCollectionFetch; + screen.load_hosted_collections(None, 1).await; + + // Check both `simple` (TASK) and `dev_kanban` (TASK, FEAT, FIX). + for id in ["simple", "dev_kanban"] { + let idx = screen + .hosted_resolved + .iter() + .position(|r| r.manifest.id == id) + .expect("collection present in embedded fallback"); + screen.hosted_state.select(Some(idx)); + screen.toggle_selection(); + } + + screen.confirm(); + assert_eq!(screen.step, SetupStep::TaskFieldConfig); + // Several collections merged -> no single committed id. + assert!(screen.selected_hosted_id.is_none()); + // Union in first-seen order, de-duplicated. + assert_eq!(screen.collection(), vec!["TASK", "FEAT", "FIX"]); +} + +#[test] +fn test_hosted_fetch_go_back_returns_to_collection_source() { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.step = SetupStep::HostedCollectionFetch; + screen.go_back(); + assert_eq!(screen.step, SetupStep::CollectionSource); +} + #[test] fn test_setup_navigation_to_worktree_preference() { let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); @@ -195,26 +272,90 @@ fn test_tmux_onboarding_proceeds_if_available() { version: "3.3a".to_string(), }; - // Should proceed to KanbanInfo because tmux is available + // Wrapper setup now precedes acceptance criteria (kanban moved earlier). screen.confirm(); - assert_eq!(screen.step, SetupStep::KanbanInfo); + assert_eq!(screen.step, SetupStep::AcceptanceCriteria); } #[test] -fn test_kanban_info_go_back_respects_wrapper_choice() { +fn test_kanban_info_go_back_returns_to_welcome() { let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); - - // Test tmux path + // Kanban setup is now the first step after Welcome. screen.step = SetupStep::KanbanInfo; - screen.selected_wrapper = SessionWrapperType::Tmux; screen.go_back(); - assert_eq!(screen.step, SetupStep::TmuxOnboarding); + assert_eq!(screen.step, SetupStep::Welcome); +} + +#[test] +fn test_welcome_advances_to_kanban_info() { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + assert_eq!(screen.step, SetupStep::Welcome); + screen.confirm(); + assert_eq!(screen.step, SetupStep::KanbanInfo); + assert!(screen.kanban_detection_complete); +} - // Test vscode path +#[test] +fn test_kanban_info_no_providers_advances_to_collection_source() { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); screen.step = SetupStep::KanbanInfo; - screen.selected_wrapper = SessionWrapperType::Vscode; - screen.go_back(); - assert_eq!(screen.step, SetupStep::VSCodeSetup); + // No valid providers -> straight to the collection source step. + screen.confirm(); + assert_eq!(screen.step, SetupStep::CollectionSource); + // Curated options only (no per-provider import options). + assert_eq!(screen.source_options, CollectionSourceOption::curated()); +} + +#[test] +fn test_collection_source_lists_import_option_per_configured_provider() { + use crate::api::providers::kanban::{ + DetectedKanbanProvider, KanbanProviderType, ProviderStatus, + }; + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.detected_kanban_providers = vec![DetectedKanbanProvider { + provider_type: KanbanProviderType::Linear, + domain: "acme".to_string(), + env_vars_found: vec!["OPERATOR_LINEAR_API_KEY".to_string()], + email: None, + status: ProviderStatus::Valid, + }]; + screen.enter_collection_source(); + + let import = screen + .source_options + .iter() + .find(|o| matches!(o, CollectionSourceOption::ImportFromProvider(_))) + .expect("an import option for the configured provider"); + assert_eq!(import.label(), "Import from Linear (acme)"); + + // Selecting it stays on the step and surfaces a deferred notice. + let idx = screen + .source_options + .iter() + .position(|o| matches!(o, CollectionSourceOption::ImportFromProvider(_))) + .unwrap(); + screen.source_state.select(Some(idx)); + screen.confirm(); + assert_eq!(screen.step, SetupStep::CollectionSource); + assert!(screen.import_notice.is_some()); +} + +#[test] +fn test_collection_source_skips_provider_without_required_env_vars() { + use crate::api::providers::kanban::{ + DetectedKanbanProvider, KanbanProviderType, ProviderStatus, + }; + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + // Jira needs domain + email + key; only a domain here -> no import option. + screen.detected_kanban_providers = vec![DetectedKanbanProvider { + provider_type: KanbanProviderType::Jira, + domain: "acme.atlassian.net".to_string(), + env_vars_found: vec!["OPERATOR_JIRA_DOMAIN".to_string()], + email: None, + status: ProviderStatus::Untested, + }]; + screen.enter_collection_source(); + assert_eq!(screen.source_options, CollectionSourceOption::curated()); } // ─── Worktree Preference Tests ──────────────────────────────────────────────── diff --git a/src/ui/setup/types.rs b/src/ui/setup/types.rs index 12720a6c..0ffeb3bd 100644 --- a/src/ui/setup/types.rs +++ b/src/ui/setup/types.rs @@ -1,5 +1,6 @@ //! Type definitions for the setup wizard +use crate::api::providers::kanban::{DetectedKanbanProvider, KanbanProviderType}; use crate::config::{CollectionPreset, SessionWrapperType}; /// Simplified tool info for display on the welcome screen @@ -10,9 +11,6 @@ pub struct DetectedToolInfo { pub model_count: usize, } -/// Available issuetype collections (all known types) -pub const ALL_ISSUE_TYPES: &[&str] = &["TASK", "FEAT", "FIX", "SPIKE", "INV"]; - /// Optional fields that can be configured for TASK (and propagated to other types) /// Note: 'summary' and 'description' remain required, 'id' is auto-generated pub const TASK_OPTIONAL_FIELDS: &[(&str, &str)] = &[ @@ -21,56 +19,128 @@ pub const TASK_OPTIONAL_FIELDS: &[(&str, &str)] = &[ ("user_story", "User story or background context"), ]; -/// Collection source options shown in setup -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// A configured kanban provider that issuetypes can be imported from. +/// +/// Built from a provider detected during setup. The author attribution on a +/// collection imported from this provider lists the provider name + workspace +/// (and, at import time, the chosen project/team). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImportProviderRef { + /// Which kanban provider (Jira / Linear / GitHub). + pub provider: KanbanProviderType, + /// Domain / workspace slug / owner login (the provider's workspace key). + pub workspace_key: String, + /// Base URL of the provider instance (for author attribution + display). + pub base_url: String, +} + +impl ImportProviderRef { + /// Author attribution for a collection imported from this provider, before a + /// specific project/team is chosen (e.g. `Jira Cloud (acme.atlassian.net)`). + pub fn author_attribution(&self) -> String { + format!("{} ({})", self.provider.display_name(), self.workspace_key) + } +} + +/// Collection source options shown in setup. +/// +/// The curated options ([`Simple`](Self::Simple), [`DevKanban`](Self::DevKanban), +/// [`DevopsKanban`](Self::DevopsKanban)) ship with operator. [`Browse`](Self::Browse) +/// opens the hosted-manifest picker. [`ImportFromProvider`](Self::ImportFromProvider) +/// entries are generated dynamically, one per configured kanban provider. +#[derive(Debug, Clone, PartialEq, Eq)] pub enum CollectionSourceOption { Simple, DevKanban, DevopsKanban, - ImportJira, - ImportNotion, - CustomSelection, + /// Browse the operator-hosted manifest (multi-select collections). + Browse, + /// Import issuetypes from a configured kanban provider. + ImportFromProvider(ImportProviderRef), } impl CollectionSourceOption { - pub fn all() -> &'static [CollectionSourceOption] { - &[ + /// The curated, install-bundled options plus the hosted browser, in display + /// order. Per-provider import options are appended by [`with_providers`](Self::with_providers). + pub fn curated() -> Vec { + vec![ CollectionSourceOption::Simple, CollectionSourceOption::DevKanban, CollectionSourceOption::DevopsKanban, - CollectionSourceOption::ImportJira, - CollectionSourceOption::ImportNotion, - CollectionSourceOption::CustomSelection, + CollectionSourceOption::Browse, ] } - pub fn label(&self) -> &'static str { + /// Build the full option list: the curated options followed by one import + /// option per configured kanban provider. `providers` is the set detected + /// during setup; a provider missing required env vars is skipped, so no + /// import options appear on a fresh install with nothing configured. + pub fn with_providers(providers: &[DetectedKanbanProvider]) -> Vec { + let mut options = Self::curated(); + for p in providers { + if !p.has_required_env_vars() { + continue; + } + options.push(CollectionSourceOption::ImportFromProvider( + ImportProviderRef { + provider: p.provider_type, + workspace_key: p.domain.clone(), + base_url: provider_base_url(p), + }, + )); + } + options + } + + pub fn label(&self) -> String { match self { - CollectionSourceOption::Simple => "Simple", - CollectionSourceOption::DevKanban => "Dev Kanban", - CollectionSourceOption::DevopsKanban => "DevOps Kanban", - CollectionSourceOption::ImportJira => "Import from Jira", - CollectionSourceOption::ImportNotion => "Import from Notion", - CollectionSourceOption::CustomSelection => "Custom Selection", + CollectionSourceOption::Simple => "Simple".to_string(), + CollectionSourceOption::DevKanban => "Dev Kanban".to_string(), + CollectionSourceOption::DevopsKanban => "DevOps Kanban".to_string(), + CollectionSourceOption::Browse => "Browse Hosted Collections".to_string(), + CollectionSourceOption::ImportFromProvider(r) => { + format!( + "Import from {} ({})", + r.provider.display_name(), + r.workspace_key + ) + } } } - pub fn description(&self) -> &'static str { + pub fn description(&self) -> String { match self { - CollectionSourceOption::Simple => "Just TASK - minimal setup for general work", - CollectionSourceOption::DevKanban => "3 issue types: TASK, FEAT, FIX", - CollectionSourceOption::DevopsKanban => "5 issue types: TASK, SPIKE, INV, FEAT, FIX", - CollectionSourceOption::ImportJira => "(Coming soon)", - CollectionSourceOption::ImportNotion => "(Coming soon)", - CollectionSourceOption::CustomSelection => "Choose individual issue types", + CollectionSourceOption::Simple => { + "Just TASK - minimal setup for general work".to_string() + } + CollectionSourceOption::DevKanban => "3 issue types: TASK, FEAT, FIX".to_string(), + CollectionSourceOption::DevopsKanban => { + "5 issue types: TASK, SPIKE, INV, FEAT, FIX".to_string() + } + CollectionSourceOption::Browse => { + "Pick curated collections from operator.untra.io".to_string() + } + CollectionSourceOption::ImportFromProvider(r) => { + format!("Import issuetypes from {}", r.base_url) + } } } +} - pub fn is_unimplemented(&self) -> bool { - matches!( - self, - CollectionSourceOption::ImportJira | CollectionSourceOption::ImportNotion - ) +/// Derive a base URL for author attribution from a detected provider. +fn provider_base_url(p: &DetectedKanbanProvider) -> String { + match p.provider_type { + KanbanProviderType::Jira => { + if p.domain.is_empty() { + p.provider_type.setup_url().to_string() + } else if p.domain.starts_with("http") { + p.domain.clone() + } else { + format!("https://{}", p.domain) + } + } + KanbanProviderType::Linear => "https://linear.app".to_string(), + KanbanProviderType::Github => "https://github.com".to_string(), } } @@ -81,8 +151,6 @@ pub enum SetupResult { Continue, /// Cancel/quit setup Cancel, - /// Exit with unimplemented message - ExitUnimplemented(String), /// Setup complete, initialize Initialize, } @@ -262,8 +330,8 @@ pub enum SetupStep { Welcome, /// Select template collection source CollectionSource, - /// Custom issuetype selection (optional) - CustomCollection, + /// Browse and multi-select hosted collections (fetched from the manifest URL) + HostedCollectionFetch, /// Configure TASK optional fields TaskFieldConfig, /// Select session wrapper (tmux or vscode) From 58349eff229861a604e566902553317d1b0631a9 Mon Sep 17 00:00:00 2001 From: untra Date: Tue, 16 Jun 2026 11:37:47 -0600 Subject: [PATCH 03/11] integration maturity and measurement, feature alignment --- bindings/IntegrationCatalogEntryDto.ts | 36 +++ bindings/SupportStatus.ts | 6 + docs/maturity/index.md | 84 +++++ docs/schemas/openapi.json | 80 +++++ shared/types.ts | 32 ++ src/bin/generate_types.rs | 11 +- src/config/sessions.rs | 14 + src/docs_gen/integrations.rs | 126 ++++++++ src/docs_gen/mod.rs | 2 + src/integrations/catalog.rs | 410 +++++++++++++++++++++++++ src/integrations/mod.rs | 4 + src/integrations/support_status.rs | 130 ++++++++ src/main.rs | 17 +- src/rest/dto/integrations.rs | 77 +++++ src/rest/dto/mod.rs | 2 + src/rest/mod.rs | 2 + src/rest/openapi.rs | 8 +- src/rest/routes/integrations.rs | 39 +++ src/rest/routes/mod.rs | 1 + src/types/pr.rs | 20 ++ tests/feature_parity_test.rs | 138 +++++++-- tests/vertical_parity.rs | 258 ++++++++++++++++ 22 files changed, 1468 insertions(+), 29 deletions(-) create mode 100644 bindings/IntegrationCatalogEntryDto.ts create mode 100644 bindings/SupportStatus.ts create mode 100644 docs/maturity/index.md create mode 100644 src/docs_gen/integrations.rs create mode 100644 src/integrations/catalog.rs create mode 100644 src/integrations/support_status.rs create mode 100644 src/rest/dto/integrations.rs create mode 100644 src/rest/routes/integrations.rs create mode 100644 tests/vertical_parity.rs diff --git a/bindings/IntegrationCatalogEntryDto.ts b/bindings/IntegrationCatalogEntryDto.ts new file mode 100644 index 00000000..daa34d50 --- /dev/null +++ b/bindings/IntegrationCatalogEntryDto.ts @@ -0,0 +1,36 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { SupportStatus } from "./SupportStatus"; + +/** + * One advertised integration: its vertical, identity, docs link, and support + * status. + */ +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, }; diff --git a/bindings/SupportStatus.ts b/bindings/SupportStatus.ts new file mode 100644 index 00000000..7200fcde --- /dev/null +++ b/bindings/SupportStatus.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Official support / maturity level of an advertised integration. + */ +export type SupportStatus = "proto" | "alpha" | "beta" | "ga"; diff --git a/docs/maturity/index.md b/docs/maturity/index.md new file mode 100644 index 00000000..e1a41cc3 --- /dev/null +++ b/docs/maturity/index.md @@ -0,0 +1,84 @@ +--- +title: "Feature Maturity" +layout: doc +--- + + + + +# Feature Maturity + +Operator integrates with many providers and tools across several **verticals**. Each integration carries an official **support status** so you know what to expect before you depend on it. This page is generated from the same source of truth that drives the README badges and the `/api/v1/integrations` API, so it always reflects the current state. + +## Support levels + +- ![GA](https://img.shields.io/badge/GA-1BB91F) — Generally available and supported. +- ![Beta](https://img.shields.io/badge/Beta-E8A33D) — Stable-ish and hardening toward general availability. +- ![Alpha](https://img.shields.io/badge/Alpha-6495ED) — Usable, but expect breaking changes. Advertised with caveats. +- ![Proto](https://img.shields.io/badge/Proto-6B7280) — Experimental — present in code with no guarantees. Not advertised yet. + +## Kanban Provider + +| Integration | Status | Docs | +|---|---|---| +| 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/) | + +## Model Provider + +| Integration | Status | Docs | +|---|---|---| +| Anthropic | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | [Anthropic](https://operator.untra.io/getting-started/model-servers/anthropic/) | +| OpenAI | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | [OpenAI](https://operator.untra.io/getting-started/model-servers/openai/) | +| Google | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | [Google](https://operator.untra.io/getting-started/model-servers/google/) | +| Ollama | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | [Ollama](https://operator.untra.io/getting-started/model-servers/ollama/) | +| OpenRouter | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | [OpenRouter](https://operator.untra.io/getting-started/model-servers/openrouter/) | +| OpenAI-compatible | ![Proto](https://img.shields.io/badge/Proto-6B7280) | — | +| LM Studio | ![Proto](https://img.shields.io/badge/Proto-6B7280) | — | + +## Git Version Control + +| Integration | Status | Docs | +|---|---|---| +| GitHub | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | [GitHub](https://operator.untra.io/getting-started/git/github/) | +| GitLab | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | [GitLab](https://operator.untra.io/getting-started/git/gitlab/) | +| Bitbucket | ![Proto](https://img.shields.io/badge/Proto-6B7280) | — | +| Azure DevOps | ![Proto](https://img.shields.io/badge/Proto-6B7280) | — | + +## Session + +| Integration | Status | Docs | +|---|---|---| +| tmux | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | [tmux](https://operator.untra.io/getting-started/sessions/tmux/) | +| cmux | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | [cmux](https://operator.untra.io/getting-started/sessions/cmux/) | +| Zellij | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | [Zellij](https://operator.untra.io/getting-started/sessions/zellij/) | + +## Editor + +| Integration | Status | Docs | +|---|---|---| +| VS Code | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | [VS Code](https://operator.untra.io/getting-started/sessions/vscode/) | +| Zed | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | [Zed](https://operator.untra.io/getting-started/sessions/zed/) | +| Cursor | ![Proto](https://img.shields.io/badge/Proto-6B7280) | [Cursor](https://operator.untra.io/getting-started/sessions/cursor/) | + +## LLM Tool + +| Integration | Status | Docs | +|---|---|---| +| Claude | ![GA](https://img.shields.io/badge/GA-1BB91F) | [Claude](https://operator.untra.io/getting-started/agents/claude/) | +| Codex | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | [Codex](https://operator.untra.io/getting-started/agents/codex/) | +| Gemini CLI | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | [Gemini CLI](https://operator.untra.io/getting-started/agents/gemini-cli/) | + +## Platform + +| Integration | Status | Docs | +|---|---|---| +| Docker | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | [Docker](https://operator.untra.io/getting-started/platforms/docker/) | +| Coder | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | [Coder](https://operator.untra.io/getting-started/platforms/coder/) | + +## Integration + +| Integration | Status | Docs | +|---|---|---| +| AGNT | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | [AGNT](https://operator.untra.io/getting-started/integrations/agnt/) | diff --git a/docs/schemas/openapi.json b/docs/schemas/openapi.json index 2a2347dc..b62883ee 100644 --- a/docs/schemas/openapi.json +++ b/docs/schemas/openapi.json @@ -708,6 +708,31 @@ } } }, + "/api/v1/integrations": { + "get": { + "tags": [ + "Status" + ], + "summary": "GET `/api/v1/integrations`", + "description": "Returns the catalog of advertised integrations across every vertical, each\nwith its docs link and official support status (`proto` | `alpha` | `beta` |\n`ga`).", + "operationId": "integrations_catalog", + "responses": { + "200": { + "description": "Vertical integration catalog with support status", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IntegrationCatalogEntryDto" + } + } + } + } + } + } + } + }, "/api/v1/issuetypes": { "get": { "tags": [ @@ -3664,6 +3689,51 @@ } } }, + "IntegrationCatalogEntryDto": { + "type": "object", + "description": "One advertised integration: its vertical, identity, docs link, and support\nstatus.", + "required": [ + "vertical", + "vertical_label", + "slug", + "label", + "readme_badge", + "status" + ], + "properties": { + "docs_url": { + "type": [ + "string", + "null" + ], + "description": "Absolute docs URL, or `null` if undocumented." + }, + "label": { + "type": "string", + "description": "Display label for the entry (e.g. \"Jira\", \"Anthropic\")." + }, + "readme_badge": { + "type": "boolean", + "description": "Whether this entry carries a curated README badge." + }, + "slug": { + "type": "string", + "description": "Stable entry slug within the vertical (e.g. \"jira\", \"anthropic-api\")." + }, + "status": { + "$ref": "#/components/schemas/SupportStatus", + "description": "Official support / maturity status." + }, + "vertical": { + "type": "string", + "description": "Vertical slug (e.g. \"kanban\", \"model\", \"git\", \"session\", \"editor\")." + }, + "vertical_label": { + "type": "string", + "description": "Human label for the vertical (e.g. \"Kanban Provider\")." + } + } + }, "IssueTypeResponse": { "type": "object", "description": "Response for a single issue type", @@ -5526,6 +5596,16 @@ } } }, + "SupportStatus": { + "type": "string", + "description": "Official support / maturity level of an advertised integration.", + "enum": [ + "proto", + "alpha", + "beta", + "ga" + ] + }, "SyncKanbanIssueTypesResponse": { "type": "object", "description": "Response from syncing kanban issue types from a provider.", diff --git a/shared/types.ts b/shared/types.ts index 36d9d393..d339917e 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -999,6 +999,38 @@ health: string, */ 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"). diff --git a/src/bin/generate_types.rs b/src/bin/generate_types.rs index c1e34848..9cab4798 100644 --- a/src/bin/generate_types.rs +++ b/src/bin/generate_types.rs @@ -40,10 +40,10 @@ use operator::rest::dto::{ CollectionResponse, CreateAlertRequest, CreateAlertResponse, CreateDelegatorRequest, CreateFieldRequest, CreateIssueTypeRequest, CreateStepRequest, CreateTicketRequest, CreateTicketResponse, DelegatorLaunchConfigDto, DelegatorResponse, DelegatorsResponse, - FieldResponse, HealthResponse, IssueTypeResponse, IssueTypeSummary, KanbanProviderCatalogEntry, - SectionDto, SectionRowDto, SkillEntry, SkillsResponse, StatusResponse, StepResponse, - UpdateIssueTypeRequest, UpdateStepRequest, WorkflowExportResponse, WorkflowHintsDto, - WorkflowPreviewResponse, + FieldResponse, HealthResponse, IntegrationCatalogEntryDto, IssueTypeResponse, IssueTypeSummary, + KanbanProviderCatalogEntry, SectionDto, SectionRowDto, SkillEntry, SkillsResponse, + StatusResponse, StepResponse, UpdateIssueTypeRequest, UpdateStepRequest, + WorkflowExportResponse, WorkflowHintsDto, WorkflowPreviewResponse, }; use operator::state::{AgentState, CompletedTicket, State}; use operator::types::{ @@ -147,6 +147,9 @@ fn generate_typescript() -> String { StatusResponse::decl(&cfg), SectionDto::decl(&cfg), SectionRowDto::decl(&cfg), + // Integration catalog + support status DTO + operator::integrations::SupportStatus::decl(&cfg), + IntegrationCatalogEntryDto::decl(&cfg), // Kanban provider catalog DTO KanbanProviderCatalogEntry::decl(&cfg), // Workflow export DTOs diff --git a/src/config/sessions.rs b/src/config/sessions.rs index 32243336..e67b9996 100644 --- a/src/config/sessions.rs +++ b/src/config/sessions.rs @@ -19,6 +19,20 @@ pub enum SessionWrapperType { } impl SessionWrapperType { + /// The canonical list of session wrappers, in display order. Single source of + /// truth mirrored by the vertical catalog (`crate::integrations::catalog`); + /// `vscode` is advertised under the Editor vertical. + /// + /// Consumed by `tests/vertical_parity.rs` to enforce catalog coverage; reads + /// as unused in the bin crate, which has no blanket dead-code allowance here. + #[allow(dead_code)] + pub const ALL: [SessionWrapperType; 4] = [ + SessionWrapperType::Tmux, + SessionWrapperType::Vscode, + SessionWrapperType::Cmux, + SessionWrapperType::Zellij, + ]; + /// Short display name for the wrapper (used in header bar, logs) pub fn display_name(&self) -> &'static str { match self { diff --git a/src/docs_gen/integrations.rs b/src/docs_gen/integrations.rs new file mode 100644 index 00000000..37636392 --- /dev/null +++ b/src/docs_gen/integrations.rs @@ -0,0 +1,126 @@ +//! Feature-maturity documentation generator. +//! +//! Emits `docs/maturity/index.md` from the vertical catalog +//! ([`crate::integrations::catalog::all_integrations`]) — a human-facing +//! companion to the machine-checked `tests/vertical_parity.rs`. Because it is +//! derived from the same source of truth as the REST `/api/v1/integrations` +//! endpoint and the README badges, the page can never drift from reality. + +use anyhow::Result; + +use super::{format_header, DocGenerator}; +use crate::integrations::{all_integrations, SupportStatus, Vertical}; + +/// Generator for the feature-maturity page. +pub struct MaturityDocGenerator; + +/// A shields.io badge for a support status, e.g. +/// `![Beta](https://img.shields.io/badge/Beta-E8A33D)`. +fn status_badge(status: SupportStatus) -> String { + format!( + "![{label}](https://img.shields.io/badge/{label}-{color})", + label = status.label(), + color = status.badge_color(), + ) +} + +impl DocGenerator for MaturityDocGenerator { + fn name(&self) -> &'static str { + "maturity" + } + + fn source(&self) -> &'static str { + "src/integrations/catalog.rs" + } + + fn output_path(&self) -> &'static str { + "maturity/index.md" + } + + fn generate(&self) -> Result { + let mut content = format_header("Feature Maturity", self.source()); + + content.push_str( + "# Feature Maturity\n\n\ + Operator integrates with many providers and tools across several **verticals**. \ + Each integration carries an official **support status** so you know what to expect \ + before you depend on it. This page is generated from the same source of truth that \ + drives the README badges and the `/api/v1/integrations` API, so it always reflects \ + the current state.\n\n\ + ## Support levels\n\n", + ); + + // Legend — one colored badge + blurb per level, most→least mature. + for status in [ + SupportStatus::Ga, + SupportStatus::Beta, + SupportStatus::Alpha, + SupportStatus::Proto, + ] { + content.push_str(&format!( + "- {badge} — {blurb}\n", + badge = status_badge(status), + blurb = status.blurb(), + )); + } + + // One table per vertical, in README order. + let entries = all_integrations(); + for vertical in Vertical::ALL { + let rows: Vec<_> = entries.iter().filter(|e| e.vertical == vertical).collect(); + if rows.is_empty() { + continue; + } + content.push_str(&format!("\n## {}\n\n", vertical.label())); + content.push_str("| Integration | Status | Docs |\n|---|---|---|\n"); + for e in rows { + let docs = match e.docs_url() { + Some(url) => format!("[{}]({})", e.label, url), + None => "—".to_string(), + }; + content.push_str(&format!( + "| {label} | {badge} | {docs} |\n", + label = e.label, + badge = status_badge(e.status), + )); + } + } + + Ok(content) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_maturity_generator_metadata() { + let gen = MaturityDocGenerator; + assert_eq!(gen.name(), "maturity"); + assert_eq!(gen.output_path(), "maturity/index.md"); + assert!(gen.source().contains("catalog.rs")); + } + + #[test] + fn test_maturity_content_has_legend_and_tables() { + let content = MaturityDocGenerator.generate().unwrap(); + assert!(content.contains("# Feature Maturity")); + assert!(content.contains("## Support levels")); + // Legend badges for all four levels. + for status in SupportStatus::ALL { + assert!( + content.contains(&format!("badge/{}-", status.label())), + "legend should contain a {} badge", + status.label() + ); + } + // Per-vertical tables. + assert!(content.contains("## Kanban Provider")); + assert!(content.contains("## Model Provider")); + // A known row with a docs link. + assert!(content.contains("[Jira](https://operator.untra.io/getting-started/kanban/jira/)")); + // AUTO-GENERATED header present. + assert!(content.contains("AUTO-GENERATED FROM")); + } +} diff --git a/src/docs_gen/mod.rs b/src/docs_gen/mod.rs index 8ab2e87f..baa77747 100644 --- a/src/docs_gen/mod.rs +++ b/src/docs_gen/mod.rs @@ -17,6 +17,7 @@ pub mod cli; pub mod collections_manifest; pub mod config; pub mod config_schema; +pub mod integrations; pub mod issuetype; pub mod issuetype_json_schema; pub mod jira_api; @@ -94,6 +95,7 @@ pub fn generate_all(docs_dir: &Path) -> Result<()> { Box::new(llm_tools::LlmToolsDocGenerator), Box::new(startup::StartupDocGenerator), Box::new(collections_manifest::CollectionsManifestGenerator), + Box::new(integrations::MaturityDocGenerator), Box::new(config_schema::ConfigSchemaDocGenerator), Box::new(state_schema::StateSchemaDocGenerator), Box::new(schema_index::SchemaIndexDocGenerator), diff --git a/src/integrations/catalog.rs b/src/integrations/catalog.rs new file mode 100644 index 00000000..7321171d --- /dev/null +++ b/src/integrations/catalog.rs @@ -0,0 +1,410 @@ +//! The vertical integration catalog — single source of truth for every +//! advertised integration and its [`SupportStatus`]. +//! +//! Operator advertises integrations across several **verticals** (kanban +//! providers, model providers, git providers, session wrappers, editors, LLM +//! tools, platforms, integrations). Each [`CatalogEntry`] names one entry, where +//! its docs live, whether it carries a README badge, and its official support +//! status. Every downstream surface derives from this one list: +//! +//! - REST `/api/v1/integrations` ([`crate::rest::dto::integration_catalog`]) +//! - the generated `docs/maturity/` page ([`crate::docs_gen::integrations`]) +//! - the `tests/vertical_parity.rs` soup-to-nuts alignment test, which also +//! cross-checks that every provider-enum variant (`KanbanProviderType::ALL`, +//! `ModelServerKind::ALL`, `GitProvider::ALL`, `SessionWrapperType::ALL`) has a +//! catalog entry — so a new variant can't ship without docs/badges/UI. +//! +//! Adding a new vertical entry here, plus its docs page (and README badge for +//! `Alpha`+), is all that is required to keep the surfaces aligned. + +use crate::integrations::SupportStatus; + +/// A top-level advertised vertical. The [`label`](Self::label) matches the +/// bolded category in `README.md`'s badge list. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Vertical { + Kanban, + Model, + Git, + Session, + Editor, + LlmTool, + Platform, + Integration, +} + +impl Vertical { + /// All verticals, in README display order. + pub const ALL: [Vertical; 8] = [ + Vertical::Kanban, + Vertical::Model, + Vertical::Git, + Vertical::Session, + Vertical::Editor, + Vertical::LlmTool, + Vertical::Platform, + Vertical::Integration, + ]; + + /// Stable lowercase slug (wire id for the REST DTO). + pub fn slug(&self) -> &'static str { + match self { + Vertical::Kanban => "kanban", + Vertical::Model => "model", + Vertical::Git => "git", + Vertical::Session => "session", + Vertical::Editor => "editor", + Vertical::LlmTool => "llm-tool", + Vertical::Platform => "platform", + Vertical::Integration => "integration", + } + } + + /// Human label — matches the bold category in the README badge list. + pub fn label(&self) -> &'static str { + match self { + Vertical::Kanban => "Kanban Provider", + Vertical::Model => "Model Provider", + Vertical::Git => "Git Version Control", + Vertical::Session => "Session", + Vertical::Editor => "Editor", + Vertical::LlmTool => "LLM Tool", + Vertical::Platform => "Platform", + Vertical::Integration => "Integration", + } + } +} + +/// One advertised integration within a [`Vertical`]. +#[derive(Debug, Clone)] +pub struct CatalogEntry { + /// Which vertical this entry belongs to. + pub vertical: Vertical, + /// Stable slug. For verticals with a provider enum this equals that enum's + /// `slug()` (so the parity test can cross-check coverage). + pub slug: &'static str, + /// Display / README-badge label. + pub label: &'static str, + /// Docs path relative to the site root (e.g. + /// `getting-started/kanban/jira`), or `None` if undocumented. Drives both + /// the docs link and the expected README badge URL. + pub docs_path: Option<&'static str>, + /// Whether this entry carries a curated README badge. + pub readme_badge: bool, + /// Official support / maturity status. + pub status: SupportStatus, +} + +impl CatalogEntry { + /// The absolute docs URL this entry resolves to, if documented. + pub fn docs_url(&self) -> Option { + self.docs_path + .map(|p| format!("https://operator.untra.io/{p}/")) + } +} + +/// The canonical list of advertised integrations. **Single source of truth.** +/// +/// Support statuses reflect the current maturity of each integration. `Proto` +/// entries are intentionally not advertised (no README badge); `Alpha`+ entries +/// require a docs page (enforced by `tests/vertical_parity.rs`). +pub fn all_integrations() -> Vec { + use SupportStatus::{Alpha, Beta, Ga, Proto}; + use Vertical::{Editor, Git, Integration, Kanban, LlmTool, Model, Platform, Session}; + vec![ + // --- Kanban providers (mirror KanbanProviderType::ALL) --- + entry( + Kanban, + "jira", + "Jira", + Some("getting-started/kanban/jira"), + true, + Beta, + ), + entry( + Kanban, + "linear", + "Linear", + Some("getting-started/kanban/linear"), + true, + Beta, + ), + entry( + Kanban, + "github", + "GitHub Projects", + Some("getting-started/kanban/github"), + true, + Beta, + ), + // --- Model providers (mirror ModelServerKind::ALL; slug == kind slug) --- + entry( + Model, + "anthropic-api", + "Anthropic", + Some("getting-started/model-servers/anthropic"), + true, + Beta, + ), + entry( + Model, + "openai-api", + "OpenAI", + Some("getting-started/model-servers/openai"), + true, + Beta, + ), + entry( + Model, + "google-api", + "Google", + Some("getting-started/model-servers/google"), + true, + Alpha, + ), + entry( + Model, + "ollama", + "Ollama", + Some("getting-started/model-servers/ollama"), + true, + Beta, + ), + entry( + Model, + "openrouter", + "OpenRouter", + Some("getting-started/model-servers/openrouter"), + true, + Beta, + ), + entry( + Model, + "openai-compat", + "OpenAI-compatible", + None, + false, + Proto, + ), + entry(Model, "lmstudio", "LM Studio", None, false, Proto), + // --- Git providers (mirror GitProvider::ALL) --- + entry( + Git, + "github", + "GitHub", + Some("getting-started/git/github"), + true, + Beta, + ), + entry( + Git, + "gitlab", + "GitLab", + Some("getting-started/git/gitlab"), + true, + Alpha, + ), + entry(Git, "bitbucket", "Bitbucket", None, false, Proto), + entry(Git, "azure", "Azure DevOps", None, false, Proto), + // --- Session wrappers (mirror SessionWrapperType::ALL; vscode lives under Editor) --- + entry( + Session, + "tmux", + "tmux", + Some("getting-started/sessions/tmux"), + true, + Beta, + ), + entry( + Session, + "cmux", + "cmux", + Some("getting-started/sessions/cmux"), + true, + Beta, + ), + entry( + Session, + "zellij", + "Zellij", + Some("getting-started/sessions/zellij"), + true, + Beta, + ), + // --- Editors --- + entry( + Editor, + "vscode", + "VS Code", + Some("getting-started/sessions/vscode"), + true, + Beta, + ), + entry( + Editor, + "zed", + "Zed", + Some("getting-started/sessions/zed"), + true, + Alpha, + ), + entry( + Editor, + "cursor", + "Cursor", + Some("getting-started/sessions/cursor"), + false, + Proto, + ), + // --- LLM tools --- + entry( + LlmTool, + "claude", + "Claude", + Some("getting-started/agents/claude"), + true, + Ga, + ), + entry( + LlmTool, + "codex", + "Codex", + Some("getting-started/agents/codex"), + true, + Beta, + ), + entry( + LlmTool, + "gemini-cli", + "Gemini CLI", + Some("getting-started/agents/gemini-cli"), + true, + Alpha, + ), + // --- Platforms --- + entry( + Platform, + "docker", + "Docker", + Some("getting-started/platforms/docker"), + true, + Beta, + ), + entry( + Platform, + "coder", + "Coder", + Some("getting-started/platforms/coder"), + true, + Alpha, + ), + // --- Integrations (documented, no README badge row) --- + entry( + Integration, + "agnt", + "AGNT", + Some("getting-started/integrations/agnt"), + false, + Alpha, + ), + ] +} + +/// Find the catalog entry for a `(vertical, slug)` pair, if present. +pub fn entry_for(vertical: Vertical, slug: &str) -> Option { + all_integrations() + .into_iter() + .find(|e| e.vertical == vertical && e.slug == slug) +} + +/// Terse constructor keeping [`all_integrations`] readable. +fn entry( + vertical: Vertical, + slug: &'static str, + label: &'static str, + docs_path: Option<&'static str>, + readme_badge: bool, + status: SupportStatus, +) -> CatalogEntry { + CatalogEntry { + vertical, + slug, + label, + docs_path, + readme_badge, + status, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_catalog_non_empty() { + assert!(!all_integrations().is_empty()); + } + + #[test] + fn test_proto_entries_are_not_badged() { + for e in all_integrations() { + if e.status == SupportStatus::Proto { + assert!( + !e.readme_badge, + "Proto entry '{}/{}' must not be advertised with a README badge", + e.vertical.slug(), + e.slug + ); + } + } + } + + #[test] + fn test_alpha_plus_entries_are_documented() { + for e in all_integrations() { + if e.status >= SupportStatus::Alpha { + assert!( + e.docs_path.is_some(), + "Alpha+ entry '{}/{}' must have a docs page", + e.vertical.slug(), + e.slug + ); + } + } + } + + #[test] + fn test_badged_entries_have_docs() { + for e in all_integrations() { + if e.readme_badge { + assert!( + e.docs_path.is_some(), + "Badged entry '{}/{}' must link to a docs page", + e.vertical.slug(), + e.slug + ); + } + } + } + + #[test] + fn test_vertical_slug_per_entry_is_unique() { + let mut seen = std::collections::HashSet::new(); + for e in all_integrations() { + let key = (e.vertical, e.slug); + assert!( + seen.insert(key), + "Duplicate catalog entry for {}/{}", + e.vertical.slug(), + e.slug + ); + } + } + + #[test] + fn test_entry_for_resolves_known_pair() { + let jira = entry_for(Vertical::Kanban, "jira").expect("jira entry"); + assert_eq!(jira.status, SupportStatus::Beta); + assert!(entry_for(Vertical::Kanban, "nope").is_none()); + } +} diff --git a/src/integrations/mod.rs b/src/integrations/mod.rs index 0164a92c..c5bea149 100644 --- a/src/integrations/mod.rs +++ b/src/integrations/mod.rs @@ -4,6 +4,10 @@ //! to ensure slash commands, MCP tools, REST routes, and TUI actions //! stay aligned. +pub mod catalog; pub mod inventory; +pub mod support_status; +pub use catalog::{all_integrations, entry_for, CatalogEntry, Vertical}; pub use inventory::{all_capabilities, Capability}; +pub use support_status::SupportStatus; diff --git a/src/integrations/support_status.rs b/src/integrations/support_status.rs new file mode 100644 index 00000000..a557e8a9 --- /dev/null +++ b/src/integrations/support_status.rs @@ -0,0 +1,130 @@ +//! Official support / maturity level for advertised integrations. +//! +//! [`SupportStatus`] is the single, low-level designation attached to every +//! entry in the vertical catalog ([`crate::integrations::catalog`]). It is the +//! canonical DTO for "how supported is X" — every surface (the REST +//! `/api/v1/integrations` endpoint, the generated TypeScript bindings, the +//! JSON-Schema, and the generated `docs/maturity/` page) derives its notion of +//! maturity from here, so the four surfaces can't drift. +//! +//! Variants are ordered `Proto < Alpha < Beta < Ga` so callers can gate on +//! maturity: today that drives docs surfacing and the README-badge parity test; +//! later it is the hook for entitlement control. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use utoipa::ToSchema; + +/// Official support / maturity level of an advertised integration. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + Serialize, + Deserialize, + JsonSchema, + ToSchema, + TS, +)] +#[serde(rename_all = "lowercase")] +#[ts(export)] +pub enum SupportStatus { + /// Experimental — wired in code with no guarantees. Not publicly advertised + /// (no README badge); docs optional. + Proto, + /// Usable, but expect breaking change. Advertised with caveats. + Alpha, + /// Stable-ish and hardening toward general availability. + Beta, + /// Generally available. + Ga, +} + +impl SupportStatus { + /// All levels, ascending by maturity. + pub const ALL: [SupportStatus; 4] = [ + SupportStatus::Proto, + SupportStatus::Alpha, + SupportStatus::Beta, + SupportStatus::Ga, + ]; + + /// Display label used in docs and badges (`GA`, not `Ga`). + pub fn label(&self) -> &'static str { + match self { + SupportStatus::Proto => "Proto", + SupportStatus::Alpha => "Alpha", + SupportStatus::Beta => "Beta", + SupportStatus::Ga => "GA", + } + } + + /// Lowercase wire slug (matches the serde representation). + pub fn slug(&self) -> &'static str { + match self { + SupportStatus::Proto => "proto", + SupportStatus::Alpha => "alpha", + SupportStatus::Beta => "beta", + SupportStatus::Ga => "ga", + } + } + + /// Hex color (no `#`) for the shields.io status badge on the maturity page. + /// Chosen to read as a maturity ramp: neutral gray → cornflower → amber → + /// green, consistent with the brand tokens in `docs/assets/css/tokens.css`. + pub fn badge_color(&self) -> &'static str { + match self { + SupportStatus::Proto => "6B7280", // neutral gray + SupportStatus::Alpha => "6495ED", // cornflower + SupportStatus::Beta => "E8A33D", // amber + SupportStatus::Ga => "1BB91F", // green + } + } + + /// One-line explanation shown in the maturity-page legend. + pub fn blurb(&self) -> &'static str { + match self { + SupportStatus::Proto => { + "Experimental — present in code with no guarantees. Not advertised yet." + } + SupportStatus::Alpha => "Usable, but expect breaking changes. Advertised with caveats.", + SupportStatus::Beta => "Stable-ish and hardening toward general availability.", + SupportStatus::Ga => "Generally available and supported.", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_support_status_ordering() { + assert!(SupportStatus::Proto < SupportStatus::Alpha); + assert!(SupportStatus::Alpha < SupportStatus::Beta); + assert!(SupportStatus::Beta < SupportStatus::Ga); + } + + #[test] + fn test_support_status_serde_lowercase() { + let json = serde_json::to_string(&SupportStatus::Ga).unwrap(); + assert_eq!(json, "\"ga\""); + let parsed: SupportStatus = serde_json::from_str("\"beta\"").unwrap(); + assert_eq!(parsed, SupportStatus::Beta); + } + + #[test] + fn test_support_status_all_covers_four_levels() { + assert_eq!(SupportStatus::ALL.len(), 4); + } + + #[test] + fn test_support_status_label_ga_uppercase() { + assert_eq!(SupportStatus::Ga.label(), "GA"); + } +} diff --git a/src/main.rs b/src/main.rs index b3c478ec..f2ca00c5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,10 @@ mod config; mod editors; mod git; mod issuetypes; +// Vertical catalog + capability inventory: consumed by the lib's REST/docs +// layers and the external parity tests; several items read as unused in the bin. +#[allow(dead_code, unused_imports)] +mod integrations; mod llm; mod logging; mod permissions; @@ -808,9 +812,10 @@ fn cmd_workflow(config: &Config, action: WorkflowAction) -> Result<()> { fn cmd_docs(_config: &Config, output: Option, only: Option) -> Result<()> { use docs_gen::{ - cli, collections_manifest, config, config_schema, issuetype, issuetype_json_schema, - jira_api, llms, metadata, openapi, operator_output_schema, project_analysis_schema, - schema_index, shortcuts, startup, state_schema, taxonomy, DocGenerator, + cli, collections_manifest, config, config_schema, integrations, issuetype, + issuetype_json_schema, jira_api, llms, metadata, openapi, operator_output_schema, + project_analysis_schema, schema_index, shortcuts, startup, state_schema, taxonomy, + DocGenerator, }; use std::path::PathBuf; @@ -881,9 +886,12 @@ fn cmd_docs(_config: &Config, output: Option, only: Option) -> R Some("collections-manifest") => { vec![Box::new(collections_manifest::CollectionsManifestGenerator)] } + Some("maturity") => { + vec![Box::new(integrations::MaturityDocGenerator)] + } Some(other) => { println!( - "Unknown generator: {other}. Available: taxonomy, issuetype, metadata, shortcuts, cli, config, openapi, startup, config-schema, state-schema, schema-index, jira-api, operator-output-schema, issuetype-json-schema, project-analysis-schema, llms, collections-manifest" + "Unknown generator: {other}. Available: taxonomy, issuetype, metadata, shortcuts, cli, config, openapi, startup, config-schema, state-schema, schema-index, jira-api, operator-output-schema, issuetype-json-schema, project-analysis-schema, llms, collections-manifest, maturity" ); return Ok(()); } @@ -907,6 +915,7 @@ fn cmd_docs(_config: &Config, output: Option, only: Option) -> R Box::new(project_analysis_schema::ProjectAnalysisSchemaDocGenerator), Box::new(llms::LlmsTxtDocGenerator), Box::new(collections_manifest::CollectionsManifestGenerator), + Box::new(integrations::MaturityDocGenerator), ] } }; diff --git a/src/rest/dto/integrations.rs b/src/rest/dto/integrations.rs new file mode 100644 index 00000000..ec6285c4 --- /dev/null +++ b/src/rest/dto/integrations.rs @@ -0,0 +1,77 @@ +//! Vertical integration catalog DTO for `GET /api/v1/integrations`. +//! +//! A thin projection of [`crate::integrations::catalog::all_integrations`] — +//! the single source of truth — exposing each advertised integration with its +//! [`SupportStatus`]. Consumed by the docs site and reserved for future +//! entitlement control. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use utoipa::ToSchema; + +use crate::integrations::{all_integrations, SupportStatus}; + +/// One advertised integration: its vertical, identity, docs link, and support +/// status. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct IntegrationCatalogEntryDto { + /// Vertical slug (e.g. "kanban", "model", "git", "session", "editor"). + pub vertical: String, + /// Human label for the vertical (e.g. "Kanban Provider"). + pub vertical_label: String, + /// Stable entry slug within the vertical (e.g. "jira", "anthropic-api"). + pub slug: String, + /// Display label for the entry (e.g. "Jira", "Anthropic"). + pub label: String, + /// Absolute docs URL, or `null` if undocumented. + #[serde(skip_serializing_if = "Option::is_none")] + pub docs_url: Option, + /// Whether this entry carries a curated README badge. + pub readme_badge: bool, + /// Official support / maturity status. + pub status: SupportStatus, +} + +/// Project the catalog source-of-truth into wire DTOs. +pub fn integration_catalog() -> Vec { + all_integrations() + .into_iter() + .map(|e| IntegrationCatalogEntryDto { + vertical: e.vertical.slug().to_string(), + vertical_label: e.vertical.label().to_string(), + slug: e.slug.to_string(), + label: e.label.to_string(), + docs_url: e.docs_url(), + readme_badge: e.readme_badge, + status: e.status, + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_integration_catalog_projects_all_entries() { + let dtos = integration_catalog(); + assert_eq!(dtos.len(), all_integrations().len()); + let jira = dtos.iter().find(|d| d.slug == "jira").unwrap(); + assert_eq!(jira.vertical, "kanban"); + assert_eq!(jira.status, SupportStatus::Beta); + assert_eq!( + jira.docs_url.as_deref(), + Some("https://operator.untra.io/getting-started/kanban/jira/") + ); + } + + #[test] + fn test_proto_entry_has_no_docs_url() { + let dtos = integration_catalog(); + let lmstudio = dtos.iter().find(|d| d.slug == "lmstudio").unwrap(); + assert!(lmstudio.docs_url.is_none()); + assert!(!lmstudio.readme_badge); + } +} diff --git a/src/rest/dto/mod.rs b/src/rest/dto/mod.rs index 26b6ccc1..1afc7151 100644 --- a/src/rest/dto/mod.rs +++ b/src/rest/dto/mod.rs @@ -8,6 +8,7 @@ pub mod agents; pub mod configuration; +pub mod integrations; pub mod issue_types; pub mod kanban; pub mod sections; @@ -16,6 +17,7 @@ pub mod workflow; pub use agents::*; pub use configuration::*; +pub use integrations::*; pub use issue_types::*; pub use kanban::*; pub use sections::*; diff --git a/src/rest/mod.rs b/src/rest/mod.rs index 3d47be46..f4359537 100644 --- a/src/rest/mod.rs +++ b/src/rest/mod.rs @@ -76,6 +76,8 @@ fn documented_router() -> OpenApiRouter { .routes(routes!(routes::health::status)) // Canonical status sections (shared with the TUI / VS Code extension) .routes(routes!(routes::sections::list)) + // Vertical integration catalog + support status + .routes(routes!(routes::integrations::catalog)) // Issue type endpoints .routes(routes!( routes::issuetypes::list, diff --git a/src/rest/openapi.rs b/src/rest/openapi.rs index 6a142106..25dbbd0e 100644 --- a/src/rest/openapi.rs +++ b/src/rest/openapi.rs @@ -9,9 +9,9 @@ use crate::rest::dto::{ CreateDelegatorRequest, CreateFieldRequest, CreateIssueTypeRequest, CreateModelServerRequest, CreateStepRequest, CreateTicketRequest, CreateTicketResponse, DefaultLlmResponse, DelegatorLaunchConfigDto, DelegatorResponse, DelegatorsResponse, ExternalIssueTypeSummary, - FieldResponse, HealthResponse, IssueTypeResponse, IssueTypeSummary, KanbanBoardResponse, - KanbanIssueTypeResponse, KanbanProviderCatalogEntry, KanbanSyncResponse, KanbanTicketCard, - LaunchTicketRequest, LaunchTicketResponse, ListKanbanProjectsRequest, + FieldResponse, HealthResponse, IntegrationCatalogEntryDto, IssueTypeResponse, IssueTypeSummary, + KanbanBoardResponse, KanbanIssueTypeResponse, KanbanProviderCatalogEntry, KanbanSyncResponse, + KanbanTicketCard, LaunchTicketRequest, LaunchTicketResponse, ListKanbanProjectsRequest, ListKanbanProjectsResponse, ModelEntry, ModelServerKindEntry, ModelServerModelsResponse, ModelServerResponse, ModelServersResponse, NextStepInfo, OperatorOutput, ProjectSummary, QueueByType, QueueControlResponse, QueueStatusResponse, RejectReviewRequest, ReviewResponse, @@ -54,6 +54,8 @@ use crate::rest::error::ErrorResponse; StatusResponse, SectionDto, SectionRowDto, + IntegrationCatalogEntryDto, + crate::integrations::SupportStatus, IssueTypeResponse, IssueTypeSummary, FieldResponse, diff --git a/src/rest/routes/integrations.rs b/src/rest/routes/integrations.rs new file mode 100644 index 00000000..b5a89acd --- /dev/null +++ b/src/rest/routes/integrations.rs @@ -0,0 +1,39 @@ +//! Vertical integration catalog endpoint. +//! +//! Serves [`crate::integrations::catalog`] — the single source of truth for +//! advertised integrations and their support status — to the docs site and any +//! future entitlement layer. Static (config-independent), so it needs no state. + +use axum::Json; + +use crate::rest::dto::{integration_catalog, IntegrationCatalogEntryDto}; + +/// GET `/api/v1/integrations` +/// +/// Returns the catalog of advertised integrations across every vertical, each +/// with its docs link and official support status (`proto` | `alpha` | `beta` | +/// `ga`). +#[utoipa::path( + get, + path = "/api/v1/integrations", + tag = "Status", + operation_id = "integrations_catalog", + responses( + (status = 200, description = "Vertical integration catalog with support status", body = Vec) + ) +)] +pub async fn catalog() -> Json> { + Json(integration_catalog()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_integrations_catalog_returns_entries() { + let resp = catalog().await; + assert!(!resp.0.is_empty()); + assert!(resp.0.iter().any(|e| e.slug == "claude")); + } +} diff --git a/src/rest/routes/mod.rs b/src/rest/routes/mod.rs index 01306b12..859ede32 100644 --- a/src/rest/routes/mod.rs +++ b/src/rest/routes/mod.rs @@ -5,6 +5,7 @@ pub mod collections; pub mod configuration; pub mod delegators; pub mod health; +pub mod integrations; pub mod issuetypes; pub mod kanban; pub mod kanban_onboarding; diff --git a/src/types/pr.rs b/src/types/pr.rs index 1a49d825..dc954531 100644 --- a/src/types/pr.rs +++ b/src/types/pr.rs @@ -39,6 +39,26 @@ impl fmt::Display for GitProvider { } impl GitProvider { + /// The canonical list of git providers, in display order. Single source of + /// truth mirrored by the vertical catalog (`crate::integrations::catalog`). + pub const ALL: [GitProvider; 4] = [ + GitProvider::GitHub, + GitProvider::GitLab, + GitProvider::Bitbucket, + GitProvider::AzureDevOps, + ]; + + /// Stable lowercase slug (matches the [`Display`](std::fmt::Display) form and + /// the catalog entry slug). + pub fn slug(&self) -> &'static str { + match self { + GitProvider::GitHub => "github", + GitProvider::GitLab => "gitlab", + GitProvider::Bitbucket => "bitbucket", + GitProvider::AzureDevOps => "azure", + } + } + /// Detect provider from a remote URL pub fn from_remote_url(remote_url: &str) -> Option { let url_lower = remote_url.to_lowercase(); diff --git a/tests/feature_parity_test.rs b/tests/feature_parity_test.rs index c1074b61..db0bfc96 100644 --- a/tests/feature_parity_test.rs +++ b/tests/feature_parity_test.rs @@ -184,16 +184,53 @@ const CANONICAL_VIEWS: &[(&str, &str, &str)] = &[ ("Completed", "CompletedPanel", "operator-completed"), ]; -/// Status panel sections that must exist in both TUI and `VSCode`. -/// Each tuple: (section name, TUI `SectionId` variant, `VSCode` `sectionId` string) -const STATUS_SECTIONS: &[(&str, &str, &str)] = &[ - ("Configuration", "Configuration", "config"), - ("Connections", "Connections", "connections"), - ("Kanban", "Kanban", "kanban"), - ("LLM Tools", "LlmTools", "llm"), - ("Delegators", "Delegators", "delegators"), - ("Git", "Git", "git"), -]; +/// The canonical status-section ids, derived from the ts-rs-generated +/// `SectionId.ts` — which is itself generated from the `SectionId` enum in +/// `src/ui/status_panel.rs` (the single source of truth). Parsing the generated +/// file means this list can never go stale: add a `SectionId` variant and it +/// flows here automatically, so the per-surface checks below catch any surface +/// that forgot to add it. +fn canonical_section_ids() -> Vec { + let src = include_str!("../vscode-extension/src/generated/SectionId.ts"); + // `export type SectionId = "config" | "connections" | ...;` + let body = src.split_once('=').map(|(_, b)| b).unwrap_or(src); + let mut ids = Vec::new(); + let mut rest = body; + while let Some(i) = rest.find('"') { + let after = &rest[i + 1..]; + match after.find('"') { + Some(end) => { + ids.push(after[..end].to_string()); + rest = &after[end + 1..]; + } + None => break, + } + } + ids +} + +/// Extract the ordered ids listed in `ui/src/concepts.ts`'s `STATUS_KEYS` array. +fn concepts_status_keys() -> Vec { + let src = include_str!("../ui/src/concepts.ts"); + let start = src + .find("STATUS_KEYS") + .and_then(|i| src[i..].find('[').map(|j| i + j + 1)) + .expect("concepts.ts should declare STATUS_KEYS = [ ... ]"); + let end = start + src[start..].find(']').expect("STATUS_KEYS array end"); + let mut ids = Vec::new(); + let mut rest = &src[start..end]; + while let Some(i) = rest.find('\'') { + let after = &rest[i + 1..]; + match after.find('\'') { + Some(e) => { + ids.push(after[..e].to_string()); + rest = &after[e + 1..]; + } + None => break, + } + } + ids +} /// Verify TUI has all 4 canonical view panels #[test] @@ -219,30 +256,95 @@ fn test_vscode_has_all_canonical_views() { } } -/// Verify TUI status panel has all canonical sections +/// The canonical section list must be non-trivial (guards a parsing regression +/// that would silently make the coverage checks vacuous). +#[test] +fn test_canonical_section_ids_present() { + let ids = canonical_section_ids(); + assert!( + ids.len() >= 9, + "expected at least 9 canonical sections, parsed {ids:?}" + ); + assert!(ids.contains(&"config".to_string())); + assert!(ids.contains(&"projects".to_string())); +} + +/// Every canonical section id must be referenced by the TUI status panel (as a +/// serde rename) so the TUI can't drop a section the source of truth declares. #[test] fn test_tui_has_all_status_sections() { let status_panel_src = include_str!("../src/ui/status_panel.rs"); - for (name, tui_variant, _) in STATUS_SECTIONS { + for id in canonical_section_ids() { assert!( - status_panel_src.contains(tui_variant), - "TUI StatusPanel should have SectionId::{tui_variant} for '{name}'" + status_panel_src.contains(&format!("\"{id}\"")), + "TUI status_panel.rs is missing the serde rename for section '{id}'" ); } } -/// Verify `VSCode` extension has all status sections +/// Every canonical section id must be referenced by the VS Code status provider. #[test] fn test_vscode_has_all_status_sections() { let status_provider_src = include_str!("../vscode-extension/src/status-provider.ts"); - for (name, _, vscode_section) in STATUS_SECTIONS { + for id in canonical_section_ids() { assert!( - status_provider_src.contains(vscode_section), - "VSCode StatusTreeProvider should have sectionId '{vscode_section}' for '{name}'" + status_provider_src.contains(&id), + "VSCode status-provider.ts is missing sectionId '{id}'" ); } } +/// The web UI sidebar (`STATUS_KEYS` in concepts.ts) must list exactly the +/// canonical sections, in the same order, as the TUI / VS Code surfaces. +#[test] +fn test_web_ui_status_keys_match_canonical_order() { + assert_eq!( + concepts_status_keys(), + canonical_section_ids(), + "ui/src/concepts.ts STATUS_KEYS must match the canonical SectionId order" + ); +} + +/// Every `docsUrl` the web UI links to must resolve to a real docs page, so a +/// concept page never sends a reader to a 404. +#[test] +fn test_concepts_docs_urls_resolve() { + const BASE: &str = "${DOCS_BASE}"; + let src = include_str!("../ui/src/concepts.ts"); + let docs_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("docs"); + let mut checked = 0; + for line in src.lines() { + let Some(i) = line.find("docsUrl:") else { + continue; + }; + // e.g. ``docsUrl: `${DOCS_BASE}/getting-started/git/`,`` + let after = &line[i..]; + let Some(b) = after.find(BASE) else { + continue; + }; + let rest = &after[b + BASE.len()..]; + let Some(end) = rest.find('`') else { + continue; + }; + let path = rest[..end].trim_matches('/'); + let resolves = if path.is_empty() { + docs_dir.join("index.md").exists() + } else { + docs_dir.join(format!("{path}.md")).exists() + || docs_dir.join(path).join("index.md").exists() + }; + assert!( + resolves, + "concepts.ts docsUrl '/{path}/' does not resolve to a docs page on disk" + ); + checked += 1; + } + assert!( + checked >= 9, + "expected to verify all concept docsUrls; only matched {checked}" + ); +} + /// View structure parity summary #[test] fn test_view_structure_parity_summary() { diff --git a/tests/vertical_parity.rs b/tests/vertical_parity.rs new file mode 100644 index 00000000..29217bc0 --- /dev/null +++ b/tests/vertical_parity.rs @@ -0,0 +1,258 @@ +//! Soup-to-nuts structural alignment for the advertised **verticals**. +//! +//! The vertical catalog (`operator::integrations::catalog::all_integrations`) is +//! the single source of truth for every advertised integration and its +//! [`SupportStatus`]. This suite asserts that source stays aligned across all +//! the surfaces that advertise it: +//! +//! - **Rust data** — every provider-enum variant (`KanbanProviderType::ALL`, +//! `ModelServerKind::ALL`, `GitProvider::ALL`, `SessionWrapperType::ALL`) has a +//! catalog entry, so a new variant can't ship without docs/badge coverage. +//! - **README badges** — every badged entry has a shields.io badge whose link +//! points at the entry's docs URL, and no badge advertises an unknown entry. +//! - **Docs** — every `Alpha`+ entry (and the generated `docs/maturity/` page) +//! resolves to a real docs page on disk. +//! - **Support-status guardrails** — `Proto` is never advertised; `Beta`+ +//! providers always are. +//! +//! Adding a new vertical entry therefore *fails the build* until its docs page +//! and (for `Alpha`+) its README badge exist — which is the whole point. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use operator::api::providers::kanban::KanbanProviderType; +use operator::api::providers::model_server::ModelServerKind; +use operator::config::SessionWrapperType; +use operator::integrations::{all_integrations, CatalogEntry, SupportStatus, Vertical}; +use operator::types::pr::GitProvider; + +/// Path relative to the crate root, regardless of the test's working directory. +fn repo_path(rel: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join(rel) +} + +fn read_readme() -> String { + std::fs::read_to_string(repo_path("README.md")).expect("README.md should be readable") +} + +/// A docs path resolves if it's a `docs/.md` file or a +/// `docs//index.md` directory page (both serve the same Jekyll URL). +fn docs_exists(docs_path: &str) -> bool { + let docs = repo_path("docs"); + docs.join(format!("{docs_path}.md")).exists() || docs.join(docs_path).join("index.md").exists() +} + +/// Extract every `](https://operator.untra.io/...)` link target on a line. +fn extract_operator_links(line: &str) -> Vec { + const NEEDLE: &str = "](https://operator.untra.io/"; + let mut out = Vec::new(); + let mut rest = line; + while let Some(i) = rest.find(NEEDLE) { + let after = &rest[i + 2..]; // skip the leading `](` + match after.find(')') { + Some(end) => { + out.push(after[..end].to_string()); + rest = &after[end..]; + } + None => break, + } + } + out +} + +/// Every provider-enum variant must have exactly one catalog entry, so a new +/// variant can't ship without docs/badge coverage. (`vscode` is advertised under +/// the Editor vertical rather than Session.) +#[test] +fn test_every_provider_enum_variant_has_catalog_entry() { + let entries = all_integrations(); + let has = |vertical: Vertical, slug: &str| { + entries + .iter() + .any(|e| e.vertical == vertical && e.slug == slug) + }; + + for p in KanbanProviderType::ALL { + assert!(has(Vertical::Kanban, p.slug()), "kanban '{}'", p.slug()); + } + for k in ModelServerKind::ALL { + assert!(has(Vertical::Model, k.slug()), "model '{}'", k.slug()); + } + for g in GitProvider::ALL { + assert!(has(Vertical::Git, g.slug()), "git '{}'", g.slug()); + } + for w in SessionWrapperType::ALL { + let slug = w.display_name(); + assert!( + has(Vertical::Session, slug) || has(Vertical::Editor, slug), + "session/editor '{slug}'" + ); + } +} + +/// Every badged entry has a README badge linking to its docs URL, and that docs +/// page exists on disk. +#[test] +fn test_badged_entries_have_readme_badge_and_docs() { + let readme = read_readme(); + for e in all_integrations() { + if !e.readme_badge { + continue; + } + let url = e.docs_url().unwrap_or_else(|| { + panic!( + "badged entry {}/{} must have a docs URL", + e.vertical.slug(), + e.slug + ) + }); + assert!( + readme.contains(&format!("]({url})")), + "README is missing a badge linking to {url} for {}/{}", + e.vertical.slug(), + e.slug + ); + let docs_path = e.docs_path.expect("badged entry has docs_path"); + assert!( + docs_exists(docs_path), + "docs page missing for {}/{} (expected docs/{docs_path}.md or .../index.md)", + e.vertical.slug(), + e.slug + ); + } +} + +/// No README badge may advertise an integration the catalog doesn't know about — +/// the reverse direction of the coverage check. +#[test] +fn test_no_stray_vertical_badges_in_readme() { + let readme = read_readme(); + let advertised: HashSet = all_integrations() + .iter() + .filter(|e| e.readme_badge) + .filter_map(CatalogEntry::docs_url) + .collect(); + + for line in readme.lines() { + if !line.contains("img.shields.io") { + continue; // only inspect shields.io badge lines + } + for url in extract_operator_links(line) { + assert!( + advertised.contains(&url), + "README badge links to {url}, but no catalog entry advertises it \ + (add a CatalogEntry or remove the badge)" + ); + } + } +} + +/// Support-status guardrails: `Proto` is never advertised; `Beta`+ providers +/// always are (the Integration vertical has no README badge row, so it's exempt). +#[test] +fn test_support_status_guardrails() { + for e in all_integrations() { + if e.status == SupportStatus::Proto { + assert!( + !e.readme_badge, + "Proto entry {}/{} must not carry a README badge", + e.vertical.slug(), + e.slug + ); + } + if e.status >= SupportStatus::Beta && e.vertical != Vertical::Integration { + assert!( + e.readme_badge, + "Beta+ entry {}/{} must be advertised with a README badge", + e.vertical.slug(), + e.slug + ); + } + } +} + +/// Every `Alpha`+ entry must resolve to a real docs page on disk. +#[test] +fn test_alpha_plus_entries_documented_on_disk() { + for e in all_integrations() { + if e.status < SupportStatus::Alpha { + continue; + } + let docs_path = e.docs_path.unwrap_or_else(|| { + panic!( + "Alpha+ entry {}/{} needs a docs_path", + e.vertical.slug(), + e.slug + ) + }); + assert!( + docs_exists(docs_path), + "Alpha+ entry {}/{} has no docs page at docs/{docs_path}", + e.vertical.slug(), + e.slug + ); + } +} + +/// The generated maturity page (`docs/maturity/index.md`) lists every badged +/// entry — ties the docs surface into the same source of truth. +#[test] +fn test_maturity_page_lists_badged_entries() { + let page = std::fs::read_to_string(repo_path("docs/maturity/index.md")) + .expect("docs/maturity/index.md should exist — run `cargo run -- docs --only maturity`"); + for e in all_integrations() { + if let Some(url) = e.docs_url().filter(|_| e.readme_badge) { + assert!( + page.contains(&url), + "maturity page is missing the docs link for {}/{} ({url})", + e.vertical.slug(), + e.slug + ); + } + } +} + +/// Human-readable matrix of every entry × surface × status. Always passes; +/// run with `--nocapture` to inspect alignment at a glance. +#[test] +fn test_vertical_parity_summary() { + let readme = read_readme(); + println!("\n=== Vertical Parity ===\n"); + println!( + "{:<14} | {:<18} | {:<6} | {:<5} | {:<5} | URL", + "Vertical", "Entry", "Status", "Badge", "Docs" + ); + println!( + "{:-<14}-+-{:-<18}-+-{:-<6}-+-{:-<5}-+-{:-<5}-+----", + "", "", "", "", "" + ); + for e in all_integrations() { + let badge_ok = if e.readme_badge { + if e.docs_url() + .is_some_and(|u| readme.contains(&format!("]({u})"))) + { + "✓" + } else { + "✗" + } + } else { + "—" + }; + let docs_ok = match e.docs_path { + Some(p) if docs_exists(p) => "✓", + Some(_) => "✗", + None => "—", + }; + println!( + "{:<14} | {:<18} | {:<6} | {:<5} | {:<5} | {}", + e.vertical.label(), + e.label, + e.status.label(), + badge_ok, + docs_ok, + e.docs_url().unwrap_or_else(|| "—".to_string()), + ); + } + println!(); +} From 5066cbd922e1965c53992957cba3023c30338927 Mon Sep 17 00:00:00 2001 From: untra Date: Tue, 16 Jun 2026 14:31:11 -0600 Subject: [PATCH 04/11] agnt plugin fixes, workflow refinement and adjustment --- README.md | 2 + agnt-plugin/alert.js | 3 + agnt-plugin/create-ticket.js | 3 + agnt-plugin/export.js | 3 + agnt-plugin/launch.js | 3 + agnt-plugin/queue.js | 3 + agnt-plugin/run-step.js | 3 + bindings/Delegator.ts | 4 +- bindings/RemoteAgentRef.ts | 2 +- bindings/SectionId.ts | 2 +- bindings/WorkflowFormatDto.ts | 31 +++ docs/delegators/index.md | 4 +- docs/getting-started/workflows/agnt.md | 47 +++++ docs/getting-started/workflows/claude.md | 44 ++++ docs/getting-started/workflows/index.md | 54 +++++ docs/maturity/index.md | 7 + docs/schemas/config.json | 4 +- docs/schemas/config.md | 4 +- docs/schemas/openapi.json | 62 +++++- shared/types.ts | 28 ++- src/bin/generate_types.rs | 3 +- src/config/llm_tools.rs | 6 +- src/integrations/catalog.rs | 27 ++- src/rest/dto/workflow.rs | 66 +++++- src/rest/mod.rs | 2 + src/rest/openapi.rs | 3 +- src/rest/routes/workflow.rs | 31 ++- src/ui/sections/mod.rs | 2 + src/ui/sections/workflows_section.rs | 114 +++++++++++ src/ui/status_panel.rs | 27 ++- src/workflow_gen/agnt.rs | 188 ++++++++++++------ src/workflow_gen/command.rs | 4 +- src/workflow_gen/format.rs | 59 ++++++ tests/agnt_plugin_tool_names.rs | 69 +++++++ tests/vertical_parity.rs | 8 + ui/src/concepts.ts | 9 + ui/src/main.tsx | 1 + vscode-extension/package.json | 4 + vscode-extension/src/extension.ts | 2 + vscode-extension/src/open-operator-ui.ts | 4 +- .../src/sections/workflows-section.ts | 71 +++++++ vscode-extension/src/status-provider.ts | 6 +- vscode-extension/webview-ui/types/defaults.ts | 3 + 43 files changed, 932 insertions(+), 90 deletions(-) create mode 100644 bindings/WorkflowFormatDto.ts create mode 100644 docs/getting-started/workflows/agnt.md create mode 100644 docs/getting-started/workflows/claude.md create mode 100644 docs/getting-started/workflows/index.md create mode 100644 src/ui/sections/workflows_section.rs create mode 100644 tests/agnt_plugin_tool_names.rs create mode 100644 vscode-extension/src/sections/workflows-section.ts diff --git a/README.md b/README.md index ec537c3f..017a38f2 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ * **Platform** [![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=white)](https://operator.untra.io/getting-started/platforms/docker/) [![Coder](https://img.shields.io/badge/Coder-7C71FF?logo=coder&logoColor=white)](https://operator.untra.io/getting-started/platforms/coder/) +* **Workflow Format** [![Claude Workflow](https://img.shields.io/badge/Claude_Workflow-D97757?logo=claude&logoColor=white)](https://operator.untra.io/getting-started/workflows/claude/) [![AGNT Workflow](https://img.shields.io/badge/AGNT_Workflow-6E56CF)](https://operator.untra.io/getting-started/workflows/agnt/) + An orchestration tool for [**AI-assisted**](https://operator.untra.io/getting-started/agents/) [_kanban-shaped_](https://operator.untra.io/getting-started/kanban/) [git-versioned](https://operator.untra.io/getting-started/git/) software development. Install Operator! Terminals extension from Visual Studio Code Marketplace diff --git a/agnt-plugin/alert.js b/agnt-plugin/alert.js index 9100df0a..674a9e5d 100644 --- a/agnt-plugin/alert.js +++ b/agnt-plugin/alert.js @@ -2,6 +2,9 @@ import { callOperator } from "./lib/operator-client.js"; class AlertTool { + constructor() { + this.name = "operator-alert"; + } async execute(params, _inputData, _workflowEngine) { if (!params || !params.message) { return { success: false, result: null, error: "missing required param: message" }; diff --git a/agnt-plugin/create-ticket.js b/agnt-plugin/create-ticket.js index 85aa26ca..cbf9cf25 100644 --- a/agnt-plugin/create-ticket.js +++ b/agnt-plugin/create-ticket.js @@ -2,6 +2,9 @@ import { callOperator } from "./lib/operator-client.js"; class CreateTicketTool { + constructor() { + this.name = "operator-create-ticket"; + } async execute(params, _inputData, _workflowEngine) { if (!params || !params.template) { return { success: false, result: null, error: "missing required param: template" }; diff --git a/agnt-plugin/export.js b/agnt-plugin/export.js index 7425a2a3..5d86a253 100644 --- a/agnt-plugin/export.js +++ b/agnt-plugin/export.js @@ -2,6 +2,9 @@ import { callOperator } from "./lib/operator-client.js"; class ExportWorkflowTool { + constructor() { + this.name = "operator-export-workflow"; + } async execute(params, _inputData, _workflowEngine) { if (!params || !params.id) { return { success: false, result: null, error: "missing required param: id" }; diff --git a/agnt-plugin/launch.js b/agnt-plugin/launch.js index fc8ee406..05a5f846 100644 --- a/agnt-plugin/launch.js +++ b/agnt-plugin/launch.js @@ -2,6 +2,9 @@ import { callOperator } from "./lib/operator-client.js"; class LaunchAgentTool { + constructor() { + this.name = "operator-launch-agent"; + } async execute(params, _inputData, _workflowEngine) { if (!params || !params.id) { return { success: false, result: null, error: "missing required param: id" }; diff --git a/agnt-plugin/queue.js b/agnt-plugin/queue.js index 38c37047..e1778b91 100644 --- a/agnt-plugin/queue.js +++ b/agnt-plugin/queue.js @@ -2,6 +2,9 @@ import { callOperator } from "./lib/operator-client.js"; class QueueStatusTool { + constructor() { + this.name = "operator-queue-status"; + } async execute(params, _inputData, _workflowEngine) { return callOperator({ params, path: "/api/v1/queue/status" }); } diff --git a/agnt-plugin/run-step.js b/agnt-plugin/run-step.js index 885cad6e..084d6e65 100644 --- a/agnt-plugin/run-step.js +++ b/agnt-plugin/run-step.js @@ -9,6 +9,9 @@ import { callOperator } from "./lib/operator-client.js"; class RunStepTool { + constructor() { + this.name = "operator-run-step"; + } async execute(params, _inputData, _workflowEngine) { const ticket = params && (params.ticket || params.id); if (!ticket) { diff --git a/bindings/Delegator.ts b/bindings/Delegator.ts index 160896af..cf2f9782 100644 --- a/bindings/Delegator.ts +++ b/bindings/Delegator.ts @@ -48,7 +48,9 @@ model_server: string | null, * 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 an `agnt-agent` node. `None` = ordinary, + * `--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, diff --git a/bindings/RemoteAgentRef.ts b/bindings/RemoteAgentRef.ts index bd55facd..683399f5 100644 --- a/bindings/RemoteAgentRef.ts +++ b/bindings/RemoteAgentRef.ts @@ -15,6 +15,6 @@ export type RemoteAgentRef = { */ platform: string, /** - * Platform-native agent identifier (e.g. an AGNT agent name, an `OpenAI` `asst_…` id). + * Platform-native agent identifier (e.g. an AGNT agent UUID, an `OpenAI` `asst_…` id). */ id: string, }; diff --git a/bindings/SectionId.ts b/bindings/SectionId.ts index 6b0d98a4..666e9f3e 100644 --- a/bindings/SectionId.ts +++ b/bindings/SectionId.ts @@ -5,4 +5,4 @@ * * String values match the `sectionId` used in the `VSCode` extension tree routing. */ -export type SectionId = "config" | "connections" | "kanban" | "llm" | "model-servers" | "git" | "issuetypes" | "delegators" | "projects"; +export type SectionId = "config" | "connections" | "kanban" | "llm" | "model-servers" | "git" | "issuetypes" | "delegators" | "projects" | "workflows"; diff --git a/bindings/WorkflowFormatDto.ts b/bindings/WorkflowFormatDto.ts new file mode 100644 index 00000000..b642e196 --- /dev/null +++ b/bindings/WorkflowFormatDto.ts @@ -0,0 +1,31 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { SupportStatus } from "./SupportStatus"; + +/** + * One workflow export format operator can emit, for `GET /api/v1/workflow-formats`. + * + * A projection of [`WorkflowFormat`] joined to its `Workflows` catalog entry — + * the single source of truth for the format's [`SupportStatus`] and docs. Lets + * the UIs render a format picker without hardcoding the list. + */ +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, }; diff --git a/docs/delegators/index.md b/docs/delegators/index.md index 07e03506..44273778 100644 --- a/docs/delegators/index.md +++ b/docs/delegators/index.md @@ -145,13 +145,13 @@ name = "agnt-researcher" # the agent lives on AGNT; Operator references it but never runs it [delegators.remote_agent] platform = "agnt" # or "openai" -id = "Research Assistant" # AGNT agent name, or an OpenAI asst_… id +id = "a1b2c3d4-…" # AGNT agent UUID, or an OpenAI asst_… id ``` Remote agents are **export-only**: Operator has no runtime client for those platforms, so a delegator carrying a `remote_agent` cannot be launched locally — resolution returns a `RemoteOnlyDelegator` error on every launch path. When the platform is `agnt`, the reference is -surfaced in the [`--format agnt` workflow export](/docs/) as an `agnt-agent` node; other platforms +surfaced in the [`--format agnt` workflow export](/docs/) as a native AGNT `agnt-agent` node; other platforms ride opaquely in the profile. > **Caveat:** a non-AGNT remote delegator (e.g. `platform = "openai"`) used as a step agent in an diff --git a/docs/getting-started/workflows/agnt.md b/docs/getting-started/workflows/agnt.md new file mode 100644 index 00000000..f27b4a7b --- /dev/null +++ b/docs/getting-started/workflows/agnt.md @@ -0,0 +1,47 @@ +--- +title: "AGNT Workflow" +description: "Export an Operator ticket + issue type into an AGNT.gg workflow graph (.json)." +layout: doc +--- + +# AGNT Workflow + +Renders a `ticket + issue type` into an [AGNT.gg](https://agnt.gg) **workflow +graph** — a `{ name, description, nodes, edges }` JSON document AGNT can import +and run. + +```bash +operator workflow export FEAT-1234 --format agnt # writes FEAT-1234.agnt.workflow.json +``` + +Or over REST (also used by the `operator-export-workflow` plugin node): + +```bash +curl -X POST "http://localhost:7008/api/v1/tickets/FEAT-1234/workflow-export?format=agnt" +``` + +## Output shape + +Each node carries `{ id, type, text, x, y, parameters }` (AGNT's runnable node +shape — `text` is the canvas label, `x`/`y` are coordinates, and `parameters` is +the bag AGNT resolves into the node's tool). Each issue-type step becomes one +node: + +- **`operator-run-step`** — the generic node, carrying `{ ticket, step, prompt, + model, … }` in its `parameters`; it calls Operator's launch endpoint. (The + `prompt` is an inert annotation — Operator owns the prompt internally; the tool + reads only `ticket`/`model`.) +- **`agnt-agent`** — AGNT's native agent-chat node, emitted when a delegator is + an AGNT-hosted remote agent (`remote_agent.platform == "agnt"`), carrying + `agentId` (the agent's UUID) and `message` (the prompt) so AGNT runs the step + itself instead of calling back into Operator. + +The `next_step` chain becomes `edges`, each `{ id, start: { id }, end: { id } }`. +Fan-out shapes (MultiModel / Matrixed / Pipeline) flatten to a single node, and +human review gates, RAG, and MCP requirements are recorded in a `gap` field — +lossy conversions are annotated, not dropped silently. + +This is one half of the broader +[AGNT integration](https://operator.untra.io/getting-started/integrations/agnt/); +AGNT-the-plugin (the `operator-*` node vocabulary that drives Operator) is the +other. diff --git a/docs/getting-started/workflows/claude.md b/docs/getting-started/workflows/claude.md new file mode 100644 index 00000000..d1742262 --- /dev/null +++ b/docs/getting-started/workflows/claude.md @@ -0,0 +1,44 @@ +--- +title: "Claude Workflow" +description: "Export an Operator ticket + issue type into a Claude Code dynamic workflow (.js)." +layout: doc +--- + +# Claude Workflow + +The default export target. Renders a `ticket + issue type` into a **Claude Code +dynamic workflow** — a `.js` module the +[`@untra/naiveworkflow-compiler`](https://operator.untra.io/getting-started/workflows/) +walks to drive Claude Code agents. + +```bash +operator workflow export FEAT-1234 # writes FEAT-1234.workflow.js +operator workflow export FEAT-1234 --format claude --out - # to stdout +``` + +Or over REST: + +```bash +curl -X POST "http://localhost:7008/api/v1/tickets/FEAT-1234/workflow-export?format=claude" +``` + +## Output shape + +The emitted module is deterministic (no wallclock, `Date.now`, or +`Math.random`). It begins with an `export const meta = { name, description, +phases }` block, followed by **top-level statements** (one per step) — not a +wrapped `export default async function`, because that is the form the compiler +expects. + +Each issue-type step maps to a construct: + +| Step type | Emitted as | +|---|---| +| Task / Delegator | `agent()` call (`judge_loop()` for review gates) | +| Classifier | `agent()` with a `schema` option | +| MultiModel / MultiPrompt / Matrixed | `parallel([...])` with `await` binding | +| Pipeline | `await pipeline(items, ...stages)` | +| Rag / Mcp | `agent()` with a `GAP` marker (sandbox can't guarantee FS / tools) | + +Human review gates become a bounded **judge loop** (max attempts + a voting +agent); the original `on_reject` target is preserved in a `GAP` marker. diff --git a/docs/getting-started/workflows/index.md b/docs/getting-started/workflows/index.md new file mode 100644 index 00000000..cc997db2 --- /dev/null +++ b/docs/getting-started/workflows/index.md @@ -0,0 +1,54 @@ +--- +title: "Workflow Formats" +description: "Render an Operator ticket + issue type into a workflow another LLM tool or model can run." +layout: doc +--- + +# Workflow Formats + +Operator is a kanban-shaped orchestrator: each **ticket** carries the work, and +its **issue type** carries the *shape* of the work — an ordered set of steps +(tasks, classifiers, delegators, fan-outs, pipelines, human review gates). A +**workflow export** renders that `ticket + issue type` pair into a concrete +orchestration format another tool or model can execute. + +This is **export-only and lossy-by-design**: Operator emits the format; it does +not parse one back. Shapes a target can't represent natively (human review +gates, fan-out, RAG/MCP) are flattened deterministically and annotated, so the +same input always produces the same output. + +## Formats + +| Format | Artifact | Status | Docs | +|---|---|---|---| +| Claude Workflow | `.js` (Claude Code dynamic workflow) | GA | [Claude Workflow](./claude/) | +| AGNT Workflow | `.json` (AGNT.gg graph) | Alpha | [AGNT Workflow](./agnt/) | + +The authoritative, machine-readable list is the +[`GET /api/v1/workflow-formats`](https://operator.untra.io/schemas/openapi.json) +endpoint, derived from the same source of truth that backs this page. + +## How to export + +CLI: + +```bash +operator workflow export FEAT-1234 # default: claude (.js) +operator workflow export FEAT-1234 --format agnt +``` + +REST (the web UI and VS Code use the same shared code path): + +```bash +# Concrete ticket -> workflow +curl -X POST "http://localhost:7008/api/v1/tickets/FEAT-1234/workflow-export?format=claude" + +# Issue type alone -> preview (placeholder values, no ticket required) +curl "http://localhost:7008/api/v1/issuetypes/FEAT/workflow-preview?format=agnt" + +# Discover the available formats +curl "http://localhost:7008/api/v1/workflow-formats" +``` + +In the TUI, web UI, and VS Code, the **Workflows** section lists the formats and +links to preview/export. diff --git a/docs/maturity/index.md b/docs/maturity/index.md index e1a41cc3..1445b4eb 100644 --- a/docs/maturity/index.md +++ b/docs/maturity/index.md @@ -82,3 +82,10 @@ Operator integrates with many providers and tools across several **verticals**. | Integration | Status | Docs | |---|---|---| | AGNT | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | [AGNT](https://operator.untra.io/getting-started/integrations/agnt/) | + +## Workflow Format + +| Integration | Status | Docs | +|---|---|---| +| Claude Workflow | ![GA](https://img.shields.io/badge/GA-1BB91F) | [Claude Workflow](https://operator.untra.io/getting-started/workflows/claude/) | +| AGNT Workflow | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | [AGNT Workflow](https://operator.untra.io/getting-started/workflows/agnt/) | diff --git a/docs/schemas/config.json b/docs/schemas/config.json index ffd1bd28..6e87c1e9 100644 --- a/docs/schemas/config.json +++ b/docs/schemas/config.json @@ -1440,7 +1440,7 @@ "default": null }, "remote_agent": { - "description": "Declarative reference to a remote, named agent on another platform\n(e.g. an AGNT agent or an `OpenAI` Assistant; see [`crate::config::AgentProfile`]).\n\nExport-only: Operator has no runtime client for those platforms, so a\ndelegator carrying this CANNOT be launched locally — resolution errors out\n(see `delegator_resolution`). It is stored, listed, serialized into an\n`AgentProfile`, and — for `platform == \"agnt\"` — surfaced in the\n`--format agnt` workflow export as an `agnt-agent` node. `None` = ordinary,\nlocally launchable delegator.", + "description": "Declarative reference to a remote, named agent on another platform\n(e.g. an AGNT agent or an `OpenAI` Assistant; see [`crate::config::AgentProfile`]).\n\nExport-only: Operator has no runtime client for those platforms, so a\ndelegator carrying this CANNOT be launched locally — resolution errors out\n(see `delegator_resolution`). It is stored, listed, serialized into an\n`AgentProfile`, and — for `platform == \"agnt\"` — surfaced in the\n`--format agnt` workflow export as a native AGNT `agnt-agent` node, whose\n`agentId` is this reference's `id` (AGNT identifies agents by UUID, so the\n`id` must be the agent's UUID, not its display name). `None` = ordinary,\nlocally launchable delegator.", "anyOf": [ { "$ref": "#/$defs/RemoteAgentRef" @@ -1550,7 +1550,7 @@ "type": "string" }, "id": { - "description": "Platform-native agent identifier (e.g. an AGNT agent name, an `OpenAI` `asst_…` id).", + "description": "Platform-native agent identifier (e.g. an AGNT agent UUID, an `OpenAI` `asst_…` id).", "type": "string" } }, diff --git a/docs/schemas/config.md b/docs/schemas/config.md index 36fec321..197b7aa3 100644 --- a/docs/schemas/config.md +++ b/docs/schemas/config.md @@ -507,7 +507,7 @@ that can be used to launch agents for tickets. | `model_properties` | `object` | No | Arbitrary model properties (e.g., `reasoning_effort`, sandbox) | | `launch_config` | object | No | Optional launch configuration | | `model_server` | `string` \| `null` | No | 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). | -| `remote_agent` | object | No | 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 an `agnt-agent` node. `None` = ordinary, locally launchable delegator. | +| `remote_agent` | object | No | 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. | | `x_agnt` | object | No | 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_openai` | object | No | 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. | | `unmapped_core` | object | No | 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. | @@ -544,7 +544,7 @@ cannot be launched locally (see the guard in `delegator_resolution`). | Property | Type | Required | Description | | --- | --- | --- | --- | | `platform` | `string` | Yes | Hosting platform (e.g. `"agnt"`, `"openai"`). | -| `id` | `string` | Yes | Platform-native agent identifier (e.g. an AGNT agent name, an `OpenAI` `asst_…` id). | +| `id` | `string` | Yes | Platform-native agent identifier (e.g. an AGNT agent UUID, an `OpenAI` `asst_…` id). | ### ModelServer diff --git a/docs/schemas/openapi.json b/docs/schemas/openapi.json index b62883ee..ba45f550 100644 --- a/docs/schemas/openapi.json +++ b/docs/schemas/openapi.json @@ -2369,6 +2369,31 @@ } } } + }, + "/api/v1/workflow-formats": { + "get": { + "tags": [ + "Workflow" + ], + "summary": "List the workflow export formats operator can emit.", + "description": "Returns each [`WorkflowFormat`] with its label, file extension, support\nstatus, and docs link — derived from `WorkflowFormat::ALL` joined to the\n`Workflows` catalog vertical. Lets UIs render a format picker for the\n`format` query param accepted by export/preview.", + "operationId": "workflow_formats", + "responses": { + "200": { + "description": "Available workflow export formats", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowFormatDto" + } + } + } + } + } + } + } } }, "components": { @@ -5060,7 +5085,7 @@ "properties": { "id": { "type": "string", - "description": "Platform-native agent identifier (e.g. an AGNT agent name, an `OpenAI` `asst_…` id)." + "description": "Platform-native agent identifier (e.g. an AGNT agent UUID, an `OpenAI` `asst_…` id)." }, "platform": { "type": "string", @@ -6095,6 +6120,41 @@ "agnt" ] }, + "WorkflowFormatDto": { + "type": "object", + "description": "One workflow export format operator can emit, for `GET /api/v1/workflow-formats`.\n\nA projection of [`WorkflowFormat`] joined to its `Workflows` catalog entry —\nthe single source of truth for the format's [`SupportStatus`] and docs. Lets\nthe UIs render a format picker without hardcoding the list.", + "required": [ + "slug", + "label", + "extension", + "status" + ], + "properties": { + "docs_url": { + "type": [ + "string", + "null" + ], + "description": "Absolute docs URL, or `null` if undocumented." + }, + "extension": { + "type": "string", + "description": "File extension of the emitted artifact, no leading dot (e.g. \"js\", \"json\")." + }, + "label": { + "type": "string", + "description": "Display label (e.g. \"Claude Workflow\")." + }, + "slug": { + "type": "string", + "description": "Stable slug (e.g. \"claude\", \"agnt\") — the value the `format` query param takes." + }, + "status": { + "$ref": "#/components/schemas/SupportStatus", + "description": "Official support / maturity status (from the catalog)." + } + } + }, "WorkflowHintsDto": { "type": "object", "description": "Descriptive workflow hints for a collection (v1: metadata only).", diff --git a/shared/types.ts b/shared/types.ts index d339917e..e86ee792 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -582,7 +582,9 @@ model_server: string | null, * 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 an `agnt-agent` node. `None` = ordinary, + * `--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, @@ -723,7 +725,7 @@ export type RemoteAgentRef = { */ platform: string, /** - * Platform-native agent identifier (e.g. an AGNT agent name, an `OpenAI` `asst_…` id). + * Platform-native agent identifier (e.g. an AGNT agent UUID, an `OpenAI` `asst_…` id). */ id: string, }; @@ -1089,6 +1091,28 @@ suggested_filename: string, */ 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). diff --git a/src/bin/generate_types.rs b/src/bin/generate_types.rs index 9cab4798..d6c1a2e8 100644 --- a/src/bin/generate_types.rs +++ b/src/bin/generate_types.rs @@ -43,7 +43,7 @@ use operator::rest::dto::{ FieldResponse, HealthResponse, IntegrationCatalogEntryDto, IssueTypeResponse, IssueTypeSummary, KanbanProviderCatalogEntry, SectionDto, SectionRowDto, SkillEntry, SkillsResponse, StatusResponse, StepResponse, UpdateIssueTypeRequest, UpdateStepRequest, - WorkflowExportResponse, WorkflowHintsDto, WorkflowPreviewResponse, + WorkflowExportResponse, WorkflowFormatDto, WorkflowHintsDto, WorkflowPreviewResponse, }; use operator::state::{AgentState, CompletedTicket, State}; use operator::types::{ @@ -155,6 +155,7 @@ fn generate_typescript() -> String { // Workflow export DTOs WorkflowExportResponse::decl(&cfg), WorkflowPreviewResponse::decl(&cfg), + WorkflowFormatDto::decl(&cfg), // Ticket creation + alert DTOs CreateTicketRequest::decl(&cfg), CreateTicketResponse::decl(&cfg), diff --git a/src/config/llm_tools.rs b/src/config/llm_tools.rs index feb439a9..4e2244d2 100644 --- a/src/config/llm_tools.rs +++ b/src/config/llm_tools.rs @@ -137,7 +137,7 @@ pub struct SkillDirectoriesOverride { pub struct RemoteAgentRef { /// Hosting platform (e.g. `"agnt"`, `"openai"`). pub platform: String, - /// Platform-native agent identifier (e.g. an AGNT agent name, an `OpenAI` `asst_…` id). + /// Platform-native agent identifier (e.g. an AGNT agent UUID, an `OpenAI` `asst_…` id). pub id: String, } @@ -175,7 +175,9 @@ pub struct Delegator { /// 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 an `agnt-agent` node. `None` = ordinary, + /// `--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. #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_agent: Option, diff --git a/src/integrations/catalog.rs b/src/integrations/catalog.rs index 7321171d..fb43d66a 100644 --- a/src/integrations/catalog.rs +++ b/src/integrations/catalog.rs @@ -31,11 +31,12 @@ pub enum Vertical { LlmTool, Platform, Integration, + Workflows, } impl Vertical { /// All verticals, in README display order. - pub const ALL: [Vertical; 8] = [ + pub const ALL: [Vertical; 9] = [ Vertical::Kanban, Vertical::Model, Vertical::Git, @@ -44,6 +45,7 @@ impl Vertical { Vertical::LlmTool, Vertical::Platform, Vertical::Integration, + Vertical::Workflows, ]; /// Stable lowercase slug (wire id for the REST DTO). @@ -57,6 +59,7 @@ impl Vertical { Vertical::LlmTool => "llm-tool", Vertical::Platform => "platform", Vertical::Integration => "integration", + Vertical::Workflows => "workflows", } } @@ -71,6 +74,7 @@ impl Vertical { Vertical::LlmTool => "LLM Tool", Vertical::Platform => "Platform", Vertical::Integration => "Integration", + Vertical::Workflows => "Workflow Format", } } } @@ -110,7 +114,9 @@ impl CatalogEntry { /// require a docs page (enforced by `tests/vertical_parity.rs`). pub fn all_integrations() -> Vec { use SupportStatus::{Alpha, Beta, Ga, Proto}; - use Vertical::{Editor, Git, Integration, Kanban, LlmTool, Model, Platform, Session}; + use Vertical::{ + Editor, Git, Integration, Kanban, LlmTool, Model, Platform, Session, Workflows, + }; vec![ // --- Kanban providers (mirror KanbanProviderType::ALL) --- entry( @@ -307,6 +313,23 @@ pub fn all_integrations() -> Vec { false, Alpha, ), + // --- Workflow formats (mirror WorkflowFormat::ALL) --- + entry( + Workflows, + "claude", + "Claude Workflow", + Some("getting-started/workflows/claude"), + true, + Ga, + ), + entry( + Workflows, + "agnt", + "AGNT Workflow", + Some("getting-started/workflows/agnt"), + true, + Alpha, + ), ] } diff --git a/src/rest/dto/workflow.rs b/src/rest/dto/workflow.rs index bea00f16..f6d70c65 100644 --- a/src/rest/dto/workflow.rs +++ b/src/rest/dto/workflow.rs @@ -5,7 +5,8 @@ use serde::{Deserialize, Serialize}; use ts_rs::TS; use utoipa::ToSchema; -use crate::workflow_gen::ExportedWorkflow; +use crate::integrations::{catalog, SupportStatus, Vertical}; +use crate::workflow_gen::{ExportedWorkflow, WorkflowFormat}; /// Response for exporting a ticket to a Claude dynamic workflow (`.js`). #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] @@ -54,3 +55,66 @@ impl From for WorkflowPreviewResponse { } } } + +/// One workflow export format operator can emit, for `GET /api/v1/workflow-formats`. +/// +/// A projection of [`WorkflowFormat`] joined to its `Workflows` catalog entry — +/// the single source of truth for the format's [`SupportStatus`] and docs. Lets +/// the UIs render a format picker without hardcoding the list. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct WorkflowFormatDto { + /// Stable slug (e.g. "claude", "agnt") — the value the `format` query param takes. + pub slug: String, + /// Display label (e.g. "Claude Workflow"). + pub label: String, + /// File extension of the emitted artifact, no leading dot (e.g. "js", "json"). + pub extension: String, + /// Official support / maturity status (from the catalog). + pub status: SupportStatus, + /// Absolute docs URL, or `null` if undocumented. + #[serde(skip_serializing_if = "Option::is_none")] + pub docs_url: Option, +} + +/// Project every [`WorkflowFormat`] into a wire DTO, joining each to its +/// `Workflows` catalog entry for status + docs. +pub fn workflow_formats() -> Vec { + WorkflowFormat::ALL + .into_iter() + .map(|f| { + let entry = catalog::entry_for(Vertical::Workflows, f.slug()); + WorkflowFormatDto { + slug: f.slug().to_string(), + label: f.label().to_string(), + extension: f.extension().to_string(), + status: entry + .as_ref() + .map(|e| e.status) + .unwrap_or(SupportStatus::Proto), + docs_url: entry.and_then(|e| e.docs_url()), + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn workflow_formats_projects_every_variant() { + let dtos = workflow_formats(); + assert_eq!(dtos.len(), WorkflowFormat::ALL.len()); + let claude = dtos.iter().find(|d| d.slug == "claude").unwrap(); + assert_eq!(claude.extension, "js"); + assert_eq!(claude.status, SupportStatus::Ga); + assert_eq!( + claude.docs_url.as_deref(), + Some("https://operator.untra.io/getting-started/workflows/claude/") + ); + let agnt = dtos.iter().find(|d| d.slug == "agnt").unwrap(); + assert_eq!(agnt.extension, "json"); + assert_eq!(agnt.status, SupportStatus::Alpha); + } +} diff --git a/src/rest/mod.rs b/src/rest/mod.rs index f4359537..8a8024d0 100644 --- a/src/rest/mod.rs +++ b/src/rest/mod.rs @@ -124,6 +124,8 @@ fn documented_router() -> OpenApiRouter { .routes(routes!(routes::workflow::export)) // Workflow preview endpoint (issue type -> graph, no ticket) .routes(routes!(routes::workflow::preview)) + // Workflow export formats discovery endpoint + .routes(routes!(routes::workflow::formats)) // Step completion endpoint (for opr8r wrapper) .routes(routes!(routes::launch::complete_step)) // Kanban provider endpoints diff --git a/src/rest/openapi.rs b/src/rest/openapi.rs index 25dbbd0e..56333e75 100644 --- a/src/rest/openapi.rs +++ b/src/rest/openapi.rs @@ -20,7 +20,7 @@ use crate::rest::dto::{ StepCompleteResponse, StepResponse, SyncKanbanIssueTypesResponse, TicketDetailResponse, UpdateIssueTypeRequest, UpdateModelServerRequest, UpdateStepRequest, UpdateTicketStatusRequest, UpdateTicketStatusResponse, ValidateKanbanCredentialsRequest, - ValidateKanbanCredentialsResponse, WorkflowExportResponse, WorkflowHintsDto, + ValidateKanbanCredentialsResponse, WorkflowExportResponse, WorkflowFormatDto, WorkflowHintsDto, WorkflowPreviewResponse, WriteKanbanConfigRequest, WriteKanbanConfigResponse, }; // AgentProfile interchange types live in `crate::config`, not `rest::dto`. @@ -107,6 +107,7 @@ use crate::rest::error::ErrorResponse; // Workflow export types WorkflowExportResponse, WorkflowPreviewResponse, + WorkflowFormatDto, crate::workflow_gen::WorkflowFormat, // MCP types McpDescriptorResponse, diff --git a/src/rest/routes/workflow.rs b/src/rest/routes/workflow.rs index 9ac9627f..8052e74d 100644 --- a/src/rest/routes/workflow.rs +++ b/src/rest/routes/workflow.rs @@ -12,7 +12,9 @@ use axum::{ use serde::Deserialize; use crate::queue::Queue; -use crate::rest::dto::{WorkflowExportResponse, WorkflowPreviewResponse}; +use crate::rest::dto::{ + workflow_formats, WorkflowExportResponse, WorkflowFormatDto, WorkflowPreviewResponse, +}; use crate::rest::error::ApiError; use crate::rest::routes::tickets::find_ticket_anywhere; use crate::rest::state::ApiState; @@ -101,6 +103,25 @@ pub async fn preview( Ok(Json(exported.into())) } +/// List the workflow export formats operator can emit. +/// +/// Returns each [`WorkflowFormat`] with its label, file extension, support +/// status, and docs link — derived from `WorkflowFormat::ALL` joined to the +/// `Workflows` catalog vertical. Lets UIs render a format picker for the +/// `format` query param accepted by export/preview. +#[utoipa::path( + operation_id = "workflow_formats", + get, + path = "/api/v1/workflow-formats", + tag = "Workflow", + responses( + (status = 200, description = "Available workflow export formats", body = [WorkflowFormatDto]) + ) +)] +pub async fn formats() -> Json> { + Json(workflow_formats()) +} + #[cfg(test)] mod tests { use super::*; @@ -163,6 +184,14 @@ mod tests { assert!(v["nodes"].is_array()); } + #[tokio::test] + async fn formats_lists_every_workflow_format() { + let body = formats().await.0; + assert_eq!(body.len(), WorkflowFormat::ALL.len()); + assert!(body.iter().any(|f| f.slug == "claude")); + assert!(body.iter().any(|f| f.slug == "agnt")); + } + #[tokio::test] async fn preview_unknown_issuetype_is_not_found() { let state = make_state("preview-unknown"); diff --git a/src/ui/sections/mod.rs b/src/ui/sections/mod.rs index cce63c0e..54ad793b 100644 --- a/src/ui/sections/mod.rs +++ b/src/ui/sections/mod.rs @@ -7,6 +7,7 @@ mod kanban_section; mod llm_section; mod managed_projects_section; mod modelserver_section; +mod workflows_section; pub use config_section::ConfigSection; pub use connections_section::ConnectionsSection; @@ -17,3 +18,4 @@ pub use kanban_section::KanbanSection; pub use llm_section::LlmSection; pub use managed_projects_section::ManagedProjectsSection; pub use modelserver_section::ModelServerSection; +pub use workflows_section::WorkflowsSection; diff --git a/src/ui/sections/workflows_section.rs b/src/ui/sections/workflows_section.rs new file mode 100644 index 00000000..1c0a88d5 --- /dev/null +++ b/src/ui/sections/workflows_section.rs @@ -0,0 +1,114 @@ +//! The **Workflows** status section: the export formats a ticket + issuetype can +//! be rendered into (Claude dynamic workflow `.js`, AGNT graph `.json`). +//! +//! Info-only — formats are always available (no credentials), so the section is +//! `Gray`. Each row names a format, its support status + file extension, and +//! links to its docs. The primary action opens the web UI's Workflows page where +//! per-issuetype preview / per-ticket export run against the existing endpoints. +//! The format list is the same source of truth the REST `workflow-formats` +//! endpoint and the `Workflows` catalog vertical derive from: [`WorkflowFormat`]. + +use crate::integrations::{catalog, Vertical}; +use crate::ui::status_panel::{ + ActionMeta, ActionSet, SectionHealth, SectionId, StatusAction, StatusIcon, StatusSection, + StatusSnapshot, TreeRow, +}; +use crate::workflow_gen::WorkflowFormat; + +pub struct WorkflowsSection; + +impl StatusSection for WorkflowsSection { + fn section_id(&self) -> SectionId { + SectionId::Workflows + } + + fn label(&self) -> &'static str { + "Workflows" + } + + fn prerequisites(&self) -> &[SectionId] { + &[] + } + + fn health(&self, _snapshot: &StatusSnapshot) -> SectionHealth { + SectionHealth::Gray + } + + fn description(&self, _snapshot: &StatusSnapshot) -> String { + format!("{} export formats", WorkflowFormat::ALL.len()) + } + + fn children(&self, snapshot: &StatusSnapshot) -> Vec { + WorkflowFormat::ALL + .into_iter() + .map(|fmt| { + let entry = catalog::entry_for(Vertical::Workflows, fmt.slug()); + let status = entry.as_ref().map(|e| e.status.label()).unwrap_or("Proto"); + let docs_url = entry.and_then(|e| e.docs_url()); + + // Primary opens the web Workflows page (where preview/export run); + // special links to the format's docs when documented. + let primary = match snapshot.api_port() { + Some(port) => StatusAction::OpenWebUiAt { + port, + route: "/workflows".into(), + }, + None => StatusAction::None, + }; + let (special, special_meta) = match docs_url { + Some(url) => ( + StatusAction::OpenUrl(url), + Some(ActionMeta { + title: "Docs", + tooltip: "Open this workflow format's documentation", + }), + ), + None => (StatusAction::None, None), + }; + + TreeRow { + section_id: SectionId::Workflows, + id: format!("workflow-format-{}", fmt.slug()), + depth: 1, + label: fmt.label().to_string(), + description: format!("{status} · .{}", fmt.extension()), + icon: StatusIcon::Tool, + brand_icon: None, + is_header: false, + actions: ActionSet { + primary, + back: StatusAction::None, + special, + special_meta, + refresh: StatusAction::None, + refresh_meta: None, + }, + health: SectionHealth::Gray, + } + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn workflows_section_is_info_only() { + let snap = StatusSnapshot::from_config(&crate::config::Config::default(), vec![]); + assert_eq!(WorkflowsSection.health(&snap), SectionHealth::Gray); + assert!(WorkflowsSection.prerequisites().is_empty()); + } + + #[test] + fn workflows_section_lists_every_format() { + let snap = StatusSnapshot::from_config(&crate::config::Config::default(), vec![]); + let rows = WorkflowsSection.children(&snap); + assert_eq!(rows.len(), WorkflowFormat::ALL.len()); + let claude = rows.iter().find(|r| r.label == "Claude Workflow").unwrap(); + assert_eq!(claude.description, "GA · .js"); + // Documented formats expose a Docs link in the special slot. + assert!(matches!(claude.actions.special, StatusAction::OpenUrl(_))); + } +} diff --git a/src/ui/status_panel.rs b/src/ui/status_panel.rs index e0791593..3a058bb8 100644 --- a/src/ui/status_panel.rs +++ b/src/ui/status_panel.rs @@ -17,7 +17,7 @@ use crate::rest::RestApiStatus; use super::sections::{ ConfigSection, ConnectionsSection, DelegatorSection, GitSection, IssueTypeSection, - KanbanSection, LlmSection, ManagedProjectsSection, ModelServerSection, + KanbanSection, LlmSection, ManagedProjectsSection, ModelServerSection, WorkflowsSection, }; // --------------------------------------------------------------------------- @@ -48,6 +48,8 @@ pub enum SectionId { Delegators, #[serde(rename = "projects")] ManagedProjects, + #[serde(rename = "workflows")] + Workflows, } /// Health state of a section — controls the header color. @@ -98,6 +100,7 @@ impl SectionId { SectionId::IssueTypes => "issuetypes", SectionId::Delegators => "delegators", SectionId::ManagedProjects => "projects", + SectionId::Workflows => "workflows", } } } @@ -883,6 +886,7 @@ impl TreeState { expanded.insert(SectionId::IssueTypes, false); expanded.insert(SectionId::Delegators, false); expanded.insert(SectionId::ManagedProjects, false); + expanded.insert(SectionId::Workflows, false); Self { expanded, selected: 0, @@ -912,6 +916,7 @@ pub fn all_sections() -> Vec> { Box::new(IssueTypeSection), Box::new(DelegatorSection), Box::new(ManagedProjectsSection), + Box::new(WorkflowsSection), ] } @@ -1396,7 +1401,7 @@ mod tests { use super::*; #[test] - fn test_build_section_dtos_returns_all_nine_in_canonical_order() { + fn test_build_section_dtos_returns_all_sections_in_canonical_order() { let snapshot = StatusSnapshot::from_config(&crate::config::Config::default(), vec![]); let dtos = build_section_dtos(&snapshot); let ids: Vec<&str> = dtos.iter().map(|d| d.id.as_str()).collect(); @@ -1412,6 +1417,7 @@ mod tests { "issuetypes", "delegators", "projects", + "workflows", ] ); } @@ -1813,19 +1819,26 @@ mod tests { #[test] fn test_select_next_wraps() { let mut panel = StatusPanel::new("Status".into()); - // Collapse config so only the header is visible + // Collapse config and workflows so only their headers are visible. panel .tree_state .expanded .insert(SectionId::Configuration, false); + panel + .tree_state + .expanded + .insert(SectionId::Workflows, false); - // Use a snapshot where only Configuration is green but Connections prerequisites fail + // Snapshot where Configuration is red, hiding every prerequisite-gated + // section. Workflows has no prerequisites, so it stays visible — leaving + // exactly the Configuration and Workflows headers. let mut snap = test_snapshot(); - snap.config_file_found = false; // Makes Configuration red, hiding Connections + snap.config_file_found = false; let count = panel.visible_count(&snap); - assert_eq!(count, 1, "Only 1 row visible"); + assert_eq!(count, 2, "Configuration + always-visible Workflows headers"); - panel.tree_state.selected = 0; + // Selecting next from the last visible row wraps to the first. + panel.tree_state.selected = count - 1; panel.select_next(&snap); assert_eq!(panel.tree_state.selected, 0, "Should wrap"); } diff --git a/src/workflow_gen/agnt.rs b/src/workflow_gen/agnt.rs index b0b95341..7103d022 100644 --- a/src/workflow_gen/agnt.rs +++ b/src/workflow_gen/agnt.rs @@ -12,6 +12,17 @@ //! The emitted nodes use the `operator-*` type vocabulary defined by the //! companion AGNT plugin (`agnt-plugin/`), so an exported workflow runs in AGNT //! when that plugin is installed. +//! +//! The node/edge shape matches AGNT's *runnable* workflow schema, verified +//! against AGNT's own code (`agnt-gg/agnt`), not its simplified API-examples +//! docs page: nodes carry `{ id, type, text, x, y, parameters }` and edges carry +//! `{ id, start: { id }, end: { id } }`. The runtime engine (`WorkflowEngine.js`) +//! traverses `edge.start.id`/`edge.end.id`, the node executor (`NodeExecutor.js`) +//! resolves `node.parameters` into the tool's `execute(params)`, and the +//! workflow validator (`orchestrator/workflowTools.js`) rejects nodes lacking +//! `text`/`x`/`y` or edges lacking `id`/`start.id`/`end.id`. Workflows are stored +//! verbatim (`WorkflowService.saveWorkflow`), so the shape we emit is the shape +//! that runs. use anyhow::{Context, Result}; use handlebars::Handlebars; @@ -39,14 +50,14 @@ const NODE_TYPE: &str = "operator-run-step"; /// The node type emitted for a step whose resolved delegator declaratively /// references a named AGNT agent (see [`crate::config::AgentProfile`]). Rather /// than route the step back through Operator (`operator-run-step`), AGNT runs the -/// step natively with the referenced agent. The `agentName` key carries the -/// reference. +/// step natively with its own agent-chat node. /// -/// NOTE (cross-language contract — confirm against AGNT's native node schema): -/// the type string `"agnt-agent"` and the `agentName` config key are Operator's -/// chosen vocabulary; align them with the AGNT runtime / `agnt-plugin/` before -/// treating this as stable, the same way `operator-run-step` agrees on -/// `ticket`/`step` with `agnt-plugin/run-step.js`. +/// This is AGNT's native tool, confirmed against its tool registry +/// (`frontend/src/views/Docs/docfiles/tools/agnt-agent.md` in `agnt-gg/agnt`): +/// the registered tool id is `agnt-agent` and its required parameters are +/// `agentId` (the AGNT agent id, a UUID) and `message` (the prompt to send). It +/// belongs to the AGNT runtime, so an exported workflow with an `agnt-agent` node +/// runs natively in AGNT without the operator plugin. const AGNT_AGENT_NODE_TYPE: &str = "agnt-agent"; /// An AGNT workflow document: a graph of nodes connected by edges. @@ -58,21 +69,33 @@ struct AgntWorkflow { edges: Vec, } -/// One AGNT workflow node. `config` is the per-node parameter bag the plugin -/// tool reads at execution time. +/// One AGNT workflow node. `parameters` is the per-node bag the node executor +/// resolves into the tool's `execute(params)`; `text` is the canvas label and +/// `x`/`y` the canvas coordinates (all required by AGNT's workflow validator). #[derive(Debug, Serialize)] struct AgntNode { id: String, #[serde(rename = "type")] node_type: String, - config: Value, + text: String, + x: i64, + y: i64, + parameters: Value, } -/// A directed edge between two nodes (by `id`). +/// A directed edge. AGNT's engine traverses `start.id` → `end.id` and tracks the +/// edge by `id`, so all three are required (a bare `{source,target}` won't run). #[derive(Debug, Serialize)] struct AgntEdge { - source: String, - target: String, + id: String, + start: EdgeEnd, + end: EdgeEnd, +} + +/// One endpoint of an [`AgntEdge`], referencing a node by `id`. +#[derive(Debug, Serialize)] +struct EdgeEnd { + id: String, } /// Render `ticket` against `issuetype` into an AGNT workflow JSON string. @@ -93,8 +116,8 @@ pub fn export_workflow_agnt( let steps = ordered_steps(issuetype); let mut nodes = Vec::with_capacity(steps.len()); - for step in &steps { - nodes.push(build_node(&hbs, &ctx, step, ticket, config)?); + for (index, step) in steps.iter().enumerate() { + nodes.push(build_node(&hbs, &ctx, step, ticket, config, index)?); } // Edges follow the `next_step` chain (only when the target resolves to a @@ -104,8 +127,13 @@ pub fn export_workflow_agnt( if let Some(next) = step.next_step.as_deref() { if issuetype.get_step(next).is_some() { edges.push(AgntEdge { - source: step.name.clone(), - target: next.to_string(), + id: format!("{}->{}", step.name, next), + start: EdgeEnd { + id: step.name.clone(), + }, + end: EdgeEnd { + id: next.to_string(), + }, }); } } @@ -120,21 +148,21 @@ pub fn export_workflow_agnt( serde_json::to_string_pretty(&wf).context("failed to serialize AGNT workflow") } -/// Build one `operator-run-step` node from a step. Lossy cases (RAG/MCP -/// sandboxing, fan-out shapes, human review gates) are recorded in `config` -/// rather than dropped: `gap` collects `OPERATOR-GAP` notes, `fanout` summarizes -/// a flattened parallel shape. +/// Build one node from a step. `index` positions the node vertically on the +/// canvas (the chain is linear). Lossy cases (RAG/MCP sandboxing, fan-out shapes, +/// human review gates) are recorded in `parameters` rather than dropped: `gap` +/// collects `OPERATOR-GAP` notes, `fanout` summarizes a flattened parallel shape. fn build_node( hbs: &Handlebars, ctx: &Value, step: &StepSchema, ticket: &Ticket, app_config: &Config, + index: usize, ) -> Result { - let mut config = Map::new(); - config.insert("ticket".into(), json!(ticket.id)); - config.insert("step".into(), json!(step.name)); - config.insert("phase".into(), json!(step.display_name())); + let mut parameters = Map::new(); + parameters.insert("ticket".into(), json!(ticket.id)); + parameters.insert("step".into(), json!(step.name)); let mut effective_prompt = render(hbs, &step.prompt, ctx)?; let mut model = step.agent.clone(); @@ -159,7 +187,7 @@ fn build_node( } else { json!({ "type": "object" }) }; - config.insert("schema".into(), schema); + parameters.insert("schema".into(), schema); } StepTypeTag::Rag => { gaps.push(format!( @@ -172,7 +200,7 @@ fn build_node( .map(|s| json!(describe_rag_source(s))) .collect(); if !srcs.is_empty() { - config.insert("contextSources".into(), Value::Array(srcs)); + parameters.insert("contextSources".into(), Value::Array(srcs)); } } } @@ -190,7 +218,7 @@ fn build_node( }) .collect(); if !tools.is_empty() { - config.insert("requiredTools".into(), Value::Array(tools)); + parameters.insert("requiredTools".into(), Value::Array(tools)); } } } @@ -198,7 +226,7 @@ fn build_node( | StepTypeTag::MultiPrompt | StepTypeTag::Matrixed | StepTypeTag::Pipeline => { - config.insert("fanout".into(), json!(fanout_description(step))); + parameters.insert("fanout".into(), json!(fanout_description(step))); gaps.push(format!( "{GAP_MARKER}: {} fan-out flattened to a single node; the parallel/voting shape runs inside one operator agent launch.", step_type_label(&step.step_type), @@ -222,7 +250,7 @@ fn build_node( } // If the step's resolved delegator (held in `model`) declaratively references - // an AGNT agent, emit an `agnt-agent` node so AGNT runs the step natively + // an AGNT agent, emit a native `agnt-agent` node so AGNT runs the step itself // rather than calling back into Operator. Operator-only launch_config on such // a delegator has no analog on the AGNT side, so its presence is gap-marked. // Only AGNT-hosted remote agents get a native node; other platforms (e.g. @@ -236,13 +264,13 @@ fn build_node( .is_some_and(|r| r.platform == "agnt") }); let node_type = if let Some(d) = agnt_delegator { - let agent_name = d + let agent_id = d .remote_agent .as_ref() .expect("filtered to an agnt remote_agent above") .id .clone(); - config.insert("agentName".into(), json!(agent_name)); + parameters.insert("agentId".into(), json!(agent_id)); if d.launch_config.is_some() { gaps.push(format!( "{GAP_MARKER}: delegator '{}' carries operator launch_config (permission mode/flags/worktree/docker) that AGNT agents have no analog for; only the agent reference is exported.", @@ -254,21 +282,31 @@ fn build_node( NODE_TYPE }; - config.insert("prompt".into(), json!(effective_prompt)); + // The agnt-agent tool reads `message` (the prompt to send to the agent); the + // operator-run-step tool ignores the prompt (Operator owns it internally) and + // carries it only as an inert annotation for the AGNT canvas. + if node_type == AGNT_AGENT_NODE_TYPE { + parameters.insert("message".into(), json!(effective_prompt)); + } else { + parameters.insert("prompt".into(), json!(effective_prompt)); + } if let Some(m) = model { - config.insert("model".into(), json!(m)); + parameters.insert("model".into(), json!(m)); } if !step.allowed_tools.is_empty() { - config.insert("allowedTools".into(), json!(step.allowed_tools)); + parameters.insert("allowedTools".into(), json!(step.allowed_tools)); } if !gaps.is_empty() { - config.insert("gap".into(), json!(gaps.join(" | "))); + parameters.insert("gap".into(), json!(gaps.join(" | "))); } Ok(AgntNode { id: step.name.clone(), node_type: node_type.to_string(), - config: Value::Object(config), + text: step.display_name().to_string(), + x: 0, + y: index as i64 * 160, + parameters: Value::Object(parameters), }) } @@ -406,33 +444,57 @@ mod tests { assert_eq!(n["type"], NODE_TYPE, "every node is an operator-* node"); assert!(n["id"].as_str().is_some_and(|s| !s.is_empty()), "node id"); assert!( - n["config"]["prompt"].is_string(), - "node config carries a prompt" + n["parameters"]["prompt"].is_string(), + "node parameters carry a prompt" ); assert_eq!( - n["config"]["ticket"], "FEAT-1234", - "node config carries the ticket id" + n["parameters"]["ticket"], "FEAT-1234", + "node parameters carry the ticket id" ); } } + /// AGNT's workflow validator rejects nodes lacking `text`/`x`/`y` and edges + /// lacking `id`/`start.id`/`end.id`. Guards the canonical runnable shape. + #[test] + fn agnt_nodes_and_edges_carry_canvas_shape() { + let v = export("FEAT"); + for n in v["nodes"].as_array().unwrap() { + assert!( + n["text"].as_str().is_some_and(|s| !s.is_empty()), + "node '{}' needs a non-empty text label", + n["id"] + ); + assert!(n["x"].is_i64(), "node '{}' needs numeric x", n["id"]); + assert!(n["y"].is_i64(), "node '{}' needs numeric y", n["id"]); + } + for e in v["edges"].as_array().unwrap() { + assert!(e["id"].as_str().is_some_and(|s| !s.is_empty()), "edge id"); + assert!(e["start"]["id"].is_string(), "edge start.id"); + assert!(e["end"]["id"].is_string(), "edge end.id"); + } + } + /// Guards the cross-language contract between this emitter and the /// `operator-run-step` plugin tool (`agnt-plugin/run-step.js`), which reads - /// `config.ticket`. If the emitter stops writing the keys the tool requires, - /// an exported graph would fail at runtime in AGNT — this catches that. + /// `params.ticket` (resolved from `node.parameters`). If the emitter stops + /// writing the keys the tool requires, an exported graph would fail at + /// runtime in AGNT — this catches that. #[test] fn agnt_nodes_carry_keys_the_run_step_tool_requires() { let v = export("FEAT"); for n in v["nodes"].as_array().unwrap() { assert!( - n["config"]["ticket"] + n["parameters"]["ticket"] .as_str() .is_some_and(|s| !s.is_empty()), "node '{}' missing the 'ticket' key the run-step tool requires", n["id"] ); assert!( - n["config"]["step"].as_str().is_some_and(|s| !s.is_empty()), + n["parameters"]["step"] + .as_str() + .is_some_and(|s| !s.is_empty()), "node '{}' missing 'step'", n["id"] ); @@ -462,12 +524,16 @@ mod tests { assert_eq!(edges.len(), expected, "one edge per resolving next_step"); for e in edges { assert!( - ids.contains(e["source"].as_str().unwrap()), - "edge source is a node" + ids.contains(e["start"]["id"].as_str().unwrap()), + "edge start.id is a node" + ); + assert!( + ids.contains(e["end"]["id"].as_str().unwrap()), + "edge end.id is a node" ); assert!( - ids.contains(e["target"].as_str().unwrap()), - "edge target is a node" + e["id"].as_str().is_some_and(|s| !s.is_empty()), + "edge carries an id" ); } } @@ -514,7 +580,7 @@ mod tests { for n in v["nodes"].as_array().unwrap() { if review_steps.contains(&n["id"].as_str().unwrap()) { assert!( - n["config"]["gap"].is_string(), + n["parameters"]["gap"].is_string(), "review-gated step '{}' must record a gap", n["id"] ); @@ -547,11 +613,11 @@ mod tests { assert_eq!(node["id"], "vote"); assert_eq!(node["type"], NODE_TYPE); assert!( - node["config"]["fanout"].is_string(), + node["parameters"]["fanout"].is_string(), "fan-out step records its flattened shape" ); assert!( - node["config"]["gap"].is_string(), + node["parameters"]["gap"].is_string(), "fan-out flattening is gap-marked" ); } @@ -601,7 +667,7 @@ mod tests { &it, None, &PipelineEnv::default(), - &config_with_remote_delegator("agnt-researcher", "agnt", "Research Assistant"), + &config_with_remote_delegator("agnt-researcher", "agnt", "agent-uuid-123"), ) .expect("export"); let v: Value = serde_json::from_str(&out).unwrap(); @@ -611,8 +677,12 @@ mod tests { "step bound to an AGNT-referencing delegator must emit an agnt-agent node" ); assert_eq!( - node["config"]["agentName"], "Research Assistant", - "the agnt-agent node carries the referenced AGNT agent name" + node["parameters"]["agentId"], "agent-uuid-123", + "the agnt-agent node carries the referenced AGNT agent id" + ); + assert!( + node["parameters"]["message"].is_string(), + "the agnt-agent node carries the prompt as 'message'" ); } @@ -636,8 +706,8 @@ mod tests { "a non-AGNT remote delegator gets no agnt-agent node" ); assert!( - v["nodes"][0]["config"].get("agentName").is_none(), - "non-AGNT node carries no agentName" + v["nodes"][0]["parameters"].get("agentId").is_none(), + "non-AGNT node carries no agentId" ); } @@ -668,8 +738,8 @@ mod tests { "a normal delegator keeps the operator-run-step node type" ); assert!( - v["nodes"][0]["config"].get("agentName").is_none(), - "non-AGNT node carries no agentName" + v["nodes"][0]["parameters"].get("agentId").is_none(), + "non-AGNT node carries no agentId" ); } } diff --git a/src/workflow_gen/command.rs b/src/workflow_gen/command.rs index f295a395..c90f422a 100644 --- a/src/workflow_gen/command.rs +++ b/src/workflow_gen/command.rs @@ -50,7 +50,7 @@ fn preview_filename(key: &str, format: WorkflowFormat) -> String { /// /// `config` is needed only by the AGNT target, which resolves each step's /// delegator name against `config.delegators` to decide between an -/// `operator-run-step` node and an `agnt-agent` node. The Claude target ignores it. +/// `operator-run-step` node and a native AGNT `agnt-agent` node. The Claude target ignores it. fn render( ticket: &Ticket, issuetype: &IssueType, @@ -108,7 +108,7 @@ pub fn export_workflow_for_issuetype( let ticket = preview_ticket(issuetype); // No config/filesystem context in a preview: environment-dependent pipeline // item sources (projects/glob) render as symbolic placeholders, and with an - // empty delegator set the AGNT target never resolves an `agnt-agent` node — + // empty delegator set the AGNT target never resolves a native `agnt-agent` node — // so those nodes appear only in real ticket exports, by design. let contents = render( &ticket, diff --git a/src/workflow_gen/format.rs b/src/workflow_gen/format.rs index e950c749..71b85910 100644 --- a/src/workflow_gen/format.rs +++ b/src/workflow_gen/format.rs @@ -22,3 +22,62 @@ pub enum WorkflowFormat { /// AGNT.gg workflow graph (`.json`). Agnt, } + +impl WorkflowFormat { + /// Every format, in catalog/display order. Source of truth cross-checked by + /// `tests/vertical_parity.rs` against the `Workflows` catalog vertical. + pub const ALL: [WorkflowFormat; 2] = [WorkflowFormat::Claude, WorkflowFormat::Agnt]; + + /// Stable lowercase slug — must equal the `Workflows` catalog entry slug. + pub fn slug(&self) -> &'static str { + match self { + WorkflowFormat::Claude => "claude", + WorkflowFormat::Agnt => "agnt", + } + } + + /// Human label, matching the `Workflows` catalog entry label. + pub fn label(&self) -> &'static str { + match self { + WorkflowFormat::Claude => "Claude Workflow", + WorkflowFormat::Agnt => "AGNT Workflow", + } + } + + /// File extension of the emitted artifact (no leading dot). + pub fn extension(&self) -> &'static str { + match self { + WorkflowFormat::Claude => "js", + WorkflowFormat::Agnt => "json", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_variants_have_distinct_slugs() { + let slugs: Vec<_> = WorkflowFormat::ALL + .iter() + .map(WorkflowFormat::slug) + .collect(); + assert_eq!(slugs, vec!["claude", "agnt"]); + } + + #[test] + fn slug_matches_serde_lowercase() { + // The catalog parity + REST DTO rely on slug() equaling the serde repr. + for f in WorkflowFormat::ALL { + let json = serde_json::to_string(&f).unwrap(); + assert_eq!(json, format!("\"{}\"", f.slug())); + } + } + + #[test] + fn extensions_are_known() { + assert_eq!(WorkflowFormat::Claude.extension(), "js"); + assert_eq!(WorkflowFormat::Agnt.extension(), "json"); + } +} diff --git a/tests/agnt_plugin_tool_names.rs b/tests/agnt_plugin_tool_names.rs new file mode 100644 index 00000000..34807a9e --- /dev/null +++ b/tests/agnt_plugin_tool_names.rs @@ -0,0 +1,69 @@ +//! Asserts every AGNT plugin tool sets `this.name` to its manifest `type`. +//! +//! AGNT's `PluginManager` registers and routes each tool instance by its +//! `this.name` property. Ironclad rule: the constructor's `this.name` MUST equal +//! the tool's `type` in `agnt-plugin/manifest.json`. A class missing `this.name` +//! registers under `undefined` — it installs but the node never fires. +//! +//! This test reads files only (no JS runtime): it parses the manifest for the +//! source-of-truth `type` -> `entryPoint` pairs, then confirms each referenced +//! `.js` file assigns `this.name = ""` in its constructor. + +use std::fs; +use std::path::PathBuf; + +/// Repo root = crate manifest dir (tests run with CWD at the crate root). +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +#[test] +fn test_every_agnt_tool_sets_this_name_to_manifest_type() { + let root = repo_root(); + let plugin_dir = root.join("agnt-plugin"); + let manifest_path = plugin_dir.join("manifest.json"); + let manifest_raw = fs::read_to_string(&manifest_path) + .unwrap_or_else(|e| panic!("failed to read {}: {e}", manifest_path.display())); + let manifest: serde_json::Value = serde_json::from_str(&manifest_raw) + .unwrap_or_else(|e| panic!("failed to parse {}: {e}", manifest_path.display())); + + let tools = manifest["tools"] + .as_array() + .expect("manifest.json: `tools` must be an array"); + assert!(!tools.is_empty(), "manifest.json declares no tools"); + + let mut problems = Vec::new(); + for tool in tools { + let ty = tool["type"] + .as_str() + .expect("each tool must declare a string `type`"); + let entry = tool["entryPoint"] + .as_str() + .unwrap_or_else(|| panic!("tool {ty:?} missing string `entryPoint`")); + + let file_path = plugin_dir.join(entry.trim_start_matches("./")); + let src = match fs::read_to_string(&file_path) { + Ok(s) => s, + Err(e) => { + problems.push(format!(" {entry}: failed to read ({e})")); + continue; + } + }; + + // Accept single or double quotes around the type string. + let dq = format!("this.name = \"{ty}\""); + let sq = format!("this.name = '{ty}'"); + if !src.contains(&dq) && !src.contains(&sq) { + problems.push(format!( + " {entry}: missing `this.name = \"{ty}\"` in constructor" + )); + } + } + + assert!( + problems.is_empty(), + "AGNT plugin tools must set this.name to their manifest type:\n{}\n\ + Add a constructor that sets `this.name` to the manifest `type` for each file above.", + problems.join("\n") + ); +} diff --git a/tests/vertical_parity.rs b/tests/vertical_parity.rs index 29217bc0..8d619055 100644 --- a/tests/vertical_parity.rs +++ b/tests/vertical_parity.rs @@ -26,6 +26,7 @@ use operator::api::providers::model_server::ModelServerKind; use operator::config::SessionWrapperType; use operator::integrations::{all_integrations, CatalogEntry, SupportStatus, Vertical}; use operator::types::pr::GitProvider; +use operator::workflow_gen::WorkflowFormat; /// Path relative to the crate root, regardless of the test's working directory. fn repo_path(rel: &str) -> PathBuf { @@ -89,6 +90,13 @@ fn test_every_provider_enum_variant_has_catalog_entry() { "session/editor '{slug}'" ); } + for f in WorkflowFormat::ALL { + assert!( + has(Vertical::Workflows, f.slug()), + "workflows '{}'", + f.slug() + ); + } } /// Every badged entry has a README badge linking to its docs URL, and that docs diff --git a/ui/src/concepts.ts b/ui/src/concepts.ts index 7b8bb7c9..61e99a56 100644 --- a/ui/src/concepts.ts +++ b/ui/src/concepts.ts @@ -118,6 +118,14 @@ export const CONCEPTS: Record = { docsUrl: `${DOCS_BASE}/configuration/`, summary: 'Projects operator manages and routes tickets into.', }, + workflows: { + key: 'workflows', + icon: 'type-hierarchy', + label: 'Workflows', + route: '/workflows', + docsUrl: `${DOCS_BASE}/getting-started/workflows/`, + summary: 'Export formats a ticket + issue type can be rendered into for other tools.', + }, }; /** Sidebar order for the status sections (matches the TUI / VS Code ordering). */ @@ -131,6 +139,7 @@ export const STATUS_KEYS = [ 'issuetypes', 'delegators', 'projects', + 'workflows', ] as const; /** Sidebar order for the web-only pages. */ diff --git a/ui/src/main.tsx b/ui/src/main.tsx index bb3dd798..bb2299f5 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -32,6 +32,7 @@ createRoot(document.getElementById('root')!).render( } /> } /> } /> + } /> } /> } /> } /> diff --git a/vscode-extension/package.json b/vscode-extension/package.json index 3f3a6403..195d9d7c 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -282,6 +282,10 @@ "title": "Operator: Open Queue in Operator UI", "icon": "$(globe)" }, + { + "command": "operator.openWorkflows", + "title": "Operator: Open Workflows in Operator UI" + }, { "command": "operator.syncKanbanCollection", "title": "Operator: Sync Collection" diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index 7bb7ea07..f47b0cd7 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -1149,6 +1149,8 @@ export async function activate( () => openOperatorUi(ctx.getCurrentTicketsDir(), 'kanban')), vscode.commands.registerCommand('operator.openQueue', () => openOperatorUi(ctx.getCurrentTicketsDir(), 'queue')), + vscode.commands.registerCommand('operator.openWorkflows', + () => openOperatorUi(ctx.getCurrentTicketsDir(), 'workflows')), vscode.commands.registerCommand('operator.syncKanbanCollection', (item: StatusItem) => syncKanbanCollectionCommand(ctx, item)), vscode.commands.registerCommand('operator.addJiraProject', diff --git a/vscode-extension/src/open-operator-ui.ts b/vscode-extension/src/open-operator-ui.ts index ac71c5cc..b39cce84 100644 --- a/vscode-extension/src/open-operator-ui.ts +++ b/vscode-extension/src/open-operator-ui.ts @@ -21,7 +21,8 @@ export type OperatorUiRoute = | 'projects' | 'kanban' | 'queue' - | 'config'; + | 'config' + | 'workflows'; const ROUTE_HASH: Record = { dashboard: '#/', @@ -30,6 +31,7 @@ const ROUTE_HASH: Record = { kanban: '#/kanban', queue: '#/queue', config: '#/config', + workflows: '#/workflows', }; /** diff --git a/vscode-extension/src/sections/workflows-section.ts b/vscode-extension/src/sections/workflows-section.ts new file mode 100644 index 00000000..ae14d849 --- /dev/null +++ b/vscode-extension/src/sections/workflows-section.ts @@ -0,0 +1,71 @@ +import * as vscode from 'vscode'; +import { StatusItem } from '../status-item'; +import type { SectionContext, StatusSection } from './types'; +import type { SectionId, SectionHealth } from '../generated'; +import { discoverApiUrl } from '../api-client'; +import type { WorkflowFormatDto } from '../generated/WorkflowFormatDto'; + +/** + * Workflows section — the export formats a ticket + issue type can be rendered + * into (Claude `.js`, AGNT `.json`). Info-only and always visible (no + * prerequisites): formats need no configuration. Rows link out to the hosted + * Operator UI's Workflows page, where preview/export run — the extension does + * not reimplement that surface. + */ +interface WorkflowsState { + apiAvailable: boolean; + formats: WorkflowFormatDto[]; +} + +export class WorkflowsSection implements StatusSection { + readonly sectionId: SectionId = 'workflows'; + readonly prerequisites: SectionId[] = []; + + private state: WorkflowsState = { apiAvailable: false, formats: [] }; + + health(): SectionHealth { + return 'Gray'; + } + + async check(ctx: SectionContext): Promise { + try { + const apiUrl = await discoverApiUrl(ctx.ticketsDir); + const response = await fetch(`${apiUrl}/api/v1/workflow-formats`); + if (response.ok) { + const formats = await response.json() as WorkflowFormatDto[]; + this.state = { apiAvailable: true, formats }; + return; + } + } catch { + // API not available — fall through to the unavailable state. + } + this.state = { apiAvailable: false, formats: [] }; + } + + getTopLevelItem(_ctx: SectionContext): StatusItem { + const count = this.state.formats.length; + return new StatusItem({ + label: 'Workflows', + description: this.state.apiAvailable ? `${count} export formats` : 'API required', + icon: 'type-hierarchy', + collapsibleState: count > 0 + ? vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.None, + sectionId: this.sectionId, + health: this.health(), + }); + } + + getChildren(_ctx: SectionContext, _element?: StatusItem): StatusItem[] { + return this.state.formats.map((fmt) => new StatusItem({ + label: fmt.label, + description: `${fmt.status} · .${fmt.extension}`, + icon: 'tools', + collapsibleState: vscode.TreeItemCollapsibleState.None, + sectionId: this.sectionId, + health: this.health(), + // Link out to the hosted UI's Workflows page (preview/export live there). + command: { command: 'operator.openWorkflows', title: 'Open Workflows in Operator UI' }, + })); + } +} diff --git a/vscode-extension/src/status-provider.ts b/vscode-extension/src/status-provider.ts index 6c5fa806..a362fd26 100644 --- a/vscode-extension/src/status-provider.ts +++ b/vscode-extension/src/status-provider.ts @@ -7,7 +7,7 @@ * Sections use progressive disclosure — they only appear when prerequisites are met: * Tier 0: Configuration (always visible) * Tier 1: Connections (requires configReady) - * Tier 2: Kanban, LLM Tools, Model Servers, Git (requires connectionsReady / llmReady) + * Tier 2: Kanban/kanban, LLM Tools/llm, Model Servers/model-servers, Git/git (requires connectionsReady / llmReady) * Tier 3: Issue Types/issuetypes (kanbanConfigured), Delegators/delegators (llmConfigured), Managed Projects/projects (gitConfigured) */ @@ -25,6 +25,7 @@ import { IssueTypeSection } from './sections/issuetype-section'; import { DelegatorSection } from './sections/delegator-section'; import { ModelServerSection } from './sections/modelserver-section'; import { ManagedProjectsSection } from './sections/managed-projects-section'; +import { WorkflowsSection } from './sections/workflows-section'; // Backward-compatible re-exports export { StatusItem } from './status-item'; @@ -60,6 +61,7 @@ export class StatusTreeProvider implements vscode.TreeDataProvider { private delegatorSection: DelegatorSection; private modelServerSection: ModelServerSection; private managedProjectsSection: ManagedProjectsSection; + private workflowsSection: WorkflowsSection; // All sections for check() and routing private allSections: StatusSection[]; @@ -77,6 +79,7 @@ export class StatusTreeProvider implements vscode.TreeDataProvider { this.delegatorSection = new DelegatorSection(); this.modelServerSection = new ModelServerSection(); this.managedProjectsSection = new ManagedProjectsSection(); + this.workflowsSection = new WorkflowsSection(); // Canonical section order — must match the `SectionId` enum in // src/ui/status_panel.rs (the single source of truth) and the TUI's @@ -91,6 +94,7 @@ export class StatusTreeProvider implements vscode.TreeDataProvider { this.issueTypeSection, this.delegatorSection, this.managedProjectsSection, + this.workflowsSection, ]; this.sectionMap = new Map(this.allSections.map(s => [s.sectionId, s])); this.ctx = this.buildContext(); diff --git a/vscode-extension/webview-ui/types/defaults.ts b/vscode-extension/webview-ui/types/defaults.ts index 86e8a701..01933596 100644 --- a/vscode-extension/webview-ui/types/defaults.ts +++ b/vscode-extension/webview-ui/types/defaults.ts @@ -59,6 +59,9 @@ const DEFAULT_CONFIG: Config = { preset: 'dev_kanban', collection: [], active_collection: null, + collections_fetch_enabled: true, + collections_manifest_url: 'https://operator.untra.io/collections/index.json', + collections_fetch_timeout_secs: BigInt(5), }, api: { pr_check_interval_secs: BigInt(300), From 7a1620a4bc6a9cf29153f5288da5b90693e94113 Mon Sep 17 00:00:00 2001 From: untra Date: Wed, 1 Jul 2026 17:39:52 -0600 Subject: [PATCH 05/11] good pass updated examples --- .github/workflows/docs.yml | 1 + bindings/KanbanStatusMapping.ts | 25 ++ bindings/ListKanbanStatusesRequest.ts | 15 + bindings/ListKanbanStatusesResponse.ts | 7 + bindings/ProjectSyncConfig.ts | 5 +- bindings/WriteGithubConfigBody.ts | 7 +- bindings/WriteJiraConfigBody.ts | 7 +- bindings/WriteLinearConfigBody.ts | 7 +- collections/README.md | 44 ++ .../community/example_chores/CHORE.json | 45 +++ collections/community/example_chores/CHORE.md | 14 + .../community/example_chores/collection.json | 41 ++ docs/.tool-versions | 1 + docs/collections/dev_kanban/collection.json | 11 +- .../collections/devops_kanban/collection.json | 17 +- .../elves_overnight/collection.json | 14 +- docs/collections/full/ASSESS.json | 64 --- docs/collections/full/ASSESS.md | 14 - docs/collections/full/FEAT.json | 112 ------ docs/collections/full/FEAT.md | 15 - docs/collections/full/FIX.json | 138 ------- docs/collections/full/FIX.md | 18 - docs/collections/full/INIT.json | 85 ---- docs/collections/full/INIT.md | 18 - docs/collections/full/INV.json | 137 ------- docs/collections/full/INV.md | 34 -- docs/collections/full/SPIKE.json | 98 ----- docs/collections/full/SPIKE.md | 26 -- docs/collections/full/SYNC.json | 71 ---- docs/collections/full/SYNC.md | 12 - docs/collections/full/TASK.json | 75 ---- docs/collections/full/TASK.md | 26 -- docs/collections/full/collection.json | 85 ---- docs/collections/index.json | 39 +- .../jr_orchestration/collection.json | 17 +- .../{AGENT-SETUP.json => AGENT_SETUP.json} | 4 +- .../{AGENT-SETUP.md => AGENT_SETUP.md} | 0 .../collections/operator/PROJECT_INIT.json | 2 +- .../{PROJECT-INIT.md => PROJECT_INIT.md} | 0 docs/collections/operator/collection.json | 54 ++- docs/collections/ralph_loop/collection.json | 11 +- docs/collections/schema.json | 21 +- docs/collections/simple/collection.json | 18 +- docs/getting-started/kanban/github.md | 26 +- docs/getting-started/kanban/index.md | 20 + docs/getting-started/kanban/jira.md | 27 +- docs/getting-started/kanban/linear.md | 20 +- docs/schemas/config.json | 37 +- docs/schemas/config.md | 19 +- docs/schemas/metadata.md | 8 +- docs/schemas/openapi.json | 202 ++++++++++ docs/startup/index.md | 4 +- src/api/kanban_sync.rs | 101 ++++- src/app/kanban_onboarding.rs | 3 + src/app/session.rs | 4 +- src/app/tickets.rs | 8 +- .../elves_overnight/collection.json | 69 +++- src/collections/fetch.rs | 1 + .../jr_orchestration/collection.json | 71 +++- src/collections/manifest.rs | 156 ++++++++ src/collections/mod.rs | 131 +++--- .../{AGENT-SETUP.json => AGENT_SETUP.json} | 4 +- .../{AGENT-SETUP.md => AGENT_SETUP.md} | 0 .../collections/operator/PROJECT_INIT.json | 2 +- .../{PROJECT-INIT.md => PROJECT_INIT.md} | 0 src/collections/operator/collection.json | 59 ++- src/collections/ralph_loop/collection.json | 56 ++- src/collections/simple/collection.json | 26 +- src/collections/validate.rs | 377 ++++++++++++++++++ src/config/config_tests.rs | 124 +++++- src/config/kanban.rs | 70 +++- src/docs_gen/collections_manifest.rs | 3 + src/issuetypes/schema.rs | 62 ++- src/mcp/tickets.rs | 29 ++ src/queue/ticket.rs | 11 +- src/rest/dto/kanban.rs | 95 +++++ src/rest/mod.rs | 4 +- src/rest/openapi.rs | 28 +- src/rest/routes/kanban.rs | 41 +- src/rest/routes/kanban_onboarding.rs | 30 +- src/rest/state.rs | 2 +- src/schemas/issuetype_collection_schema.json | 21 +- src/schemas/ticket_metadata.schema.json | 8 +- src/services/kanban_onboarding.rs | 109 ++++- src/services/kanban_sync.rs | 14 +- src/startup/mod.rs | 4 +- src/startup/templates.rs | 23 +- src/templates/schema.rs | 14 +- src/ui/dialogs/sync_confirm.rs | 13 +- src/ui/kanban_view.rs | 21 +- src/ui/setup/mod.rs | 2 +- src/ui/setup/types.rs | 6 +- tests/community_collections.rs | 45 +++ vscode-extension/src/api-client.ts | 21 + vscode-extension/src/config-panel.ts | 28 +- .../test/suite/config-panel.test.ts | 22 + vscode-extension/webview-ui/App.tsx | 24 +- .../webview-ui/components/ConfigPage.tsx | 6 + .../components/kanban/ProjectRow.tsx | 89 ++++- .../components/kanban/ProviderCard.tsx | 6 + .../sections/KanbanProvidersSection.tsx | 12 + vscode-extension/webview-ui/types/messages.ts | 3 + 102 files changed, 2532 insertions(+), 1344 deletions(-) create mode 100644 bindings/KanbanStatusMapping.ts create mode 100644 bindings/ListKanbanStatusesRequest.ts create mode 100644 bindings/ListKanbanStatusesResponse.ts create mode 100644 collections/README.md create mode 100644 collections/community/example_chores/CHORE.json create mode 100644 collections/community/example_chores/CHORE.md create mode 100644 collections/community/example_chores/collection.json create mode 100644 docs/.tool-versions delete mode 100644 docs/collections/full/ASSESS.json delete mode 100644 docs/collections/full/ASSESS.md delete mode 100644 docs/collections/full/FEAT.json delete mode 100644 docs/collections/full/FEAT.md delete mode 100644 docs/collections/full/FIX.json delete mode 100644 docs/collections/full/FIX.md delete mode 100644 docs/collections/full/INIT.json delete mode 100644 docs/collections/full/INIT.md delete mode 100644 docs/collections/full/INV.json delete mode 100644 docs/collections/full/INV.md delete mode 100644 docs/collections/full/SPIKE.json delete mode 100644 docs/collections/full/SPIKE.md delete mode 100644 docs/collections/full/SYNC.json delete mode 100644 docs/collections/full/SYNC.md delete mode 100644 docs/collections/full/TASK.json delete mode 100644 docs/collections/full/TASK.md delete mode 100644 docs/collections/full/collection.json rename docs/collections/operator/{AGENT-SETUP.json => AGENT_SETUP.json} (97%) rename docs/collections/operator/{AGENT-SETUP.md => AGENT_SETUP.md} (100%) rename src/collections/operator/PROJECT-INIT.json => docs/collections/operator/PROJECT_INIT.json (98%) rename docs/collections/operator/{PROJECT-INIT.md => PROJECT_INIT.md} (100%) rename src/collections/operator/{AGENT-SETUP.json => AGENT_SETUP.json} (97%) rename src/collections/operator/{AGENT-SETUP.md => AGENT_SETUP.md} (100%) rename docs/collections/operator/PROJECT-INIT.json => src/collections/operator/PROJECT_INIT.json (98%) rename src/collections/operator/{PROJECT-INIT.md => PROJECT_INIT.md} (100%) create mode 100644 src/collections/validate.rs create mode 100644 tests/community_collections.rs diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 3d5ed750..47a0ef4c 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -10,6 +10,7 @@ on: - 'src/taxonomy/taxonomy.toml' - 'src/templates/*.json' - 'src/collections/**' + - 'collections/**' - 'src/schemas/**' - '.github/workflows/docs.yml' workflow_dispatch: diff --git a/bindings/KanbanStatusMapping.ts b/bindings/KanbanStatusMapping.ts new file mode 100644 index 00000000..f5678e20 --- /dev/null +++ b/bindings/KanbanStatusMapping.ts @@ -0,0 +1,25 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Explicit mapping from operator's strict todo/doing/done states to the + * external board's column/status names. + * + * Drives bidirectional sync: issues are pulled from the `todo` column, + * pushed to `doing` when a ticket is claimed, to `done` when completed, and + * back to `todo` when requeued. Unset fields fall back per-transition + * (`doing` → "In Progress", `done` → "Done"); requeue only pushes when + * `todo` is explicitly mapped. + */ +export type KanbanStatusMapping = { +/** + * External column for operator "todo" (queued work; also the pull source) + */ +todo?: string | null, +/** + * External column for operator "doing" (claimed/launched tickets) + */ +doing?: string | null, +/** + * External column for operator "done" (completed tickets) + */ +done?: string | null, }; diff --git a/bindings/ListKanbanStatusesRequest.ts b/bindings/ListKanbanStatusesRequest.ts new file mode 100644 index 00000000..682eb237 --- /dev/null +++ b/bindings/ListKanbanStatusesRequest.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { GithubCredentials } from "./GithubCredentials"; +import type { JiraCredentials } from "./JiraCredentials"; +import type { KanbanProviderKind } from "./KanbanProviderKind"; +import type { LinearCredentials } from "./LinearCredentials"; + +/** + * Request to list workflow statuses/columns for a specific project using + * ephemeral creds (onboarding wizard — before any config is persisted). + */ +export type ListKanbanStatusesRequest = { provider: KanbanProviderKind, +/** + * Project/team key to list statuses for + */ +project_key: string, jira?: JiraCredentials | null, linear?: LinearCredentials | null, github?: GithubCredentials | null, }; diff --git a/bindings/ListKanbanStatusesResponse.ts b/bindings/ListKanbanStatusesResponse.ts new file mode 100644 index 00000000..78a75571 --- /dev/null +++ b/bindings/ListKanbanStatusesResponse.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Response wrapper for list-statuses: the external board's column names, + * in board order, for populating todo/doing/done mapping dropdowns. + */ +export type ListKanbanStatusesResponse = { statuses: Array, }; diff --git a/bindings/ProjectSyncConfig.ts b/bindings/ProjectSyncConfig.ts index 60a1c500..b8b26074 100644 --- a/bindings/ProjectSyncConfig.ts +++ b/bindings/ProjectSyncConfig.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { KanbanStatusMapping } from "./KanbanStatusMapping"; /** * Per-project/team sync configuration for a kanban provider @@ -12,9 +13,9 @@ export type ProjectSyncConfig = { */ sync_user_id: string, /** - * Workflow statuses to sync (empty = default/first status only) + * Mapping of operator todo/doing/done to external board columns */ -sync_statuses: Array, +status_mapping?: KanbanStatusMapping, /** * Optional `IssueTypeCollection` name this project maps to. * Not required for kanban onboarding or sync. diff --git a/bindings/WriteGithubConfigBody.ts b/bindings/WriteGithubConfigBody.ts index 116ff8ae..db645fed 100644 --- a/bindings/WriteGithubConfigBody.ts +++ b/bindings/WriteGithubConfigBody.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { KanbanStatusMapping } from "./KanbanStatusMapping"; /** * Body for writing a GitHub Projects v2 config section. @@ -21,4 +22,8 @@ project_key: string, /** * Numeric GitHub `databaseId` of the user whose items to sync */ -sync_user_id: string, }; +sync_user_id: string, +/** + * Mapping of operator todo/doing/done to external board columns + */ +status_mapping?: KanbanStatusMapping | null, }; diff --git a/bindings/WriteJiraConfigBody.ts b/bindings/WriteJiraConfigBody.ts index bb7b1fdb..9069358f 100644 --- a/bindings/WriteJiraConfigBody.ts +++ b/bindings/WriteJiraConfigBody.ts @@ -1,6 +1,11 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { KanbanStatusMapping } from "./KanbanStatusMapping"; /** * Body for writing a Jira project config section. */ -export type WriteJiraConfigBody = { domain: string, email: string, api_key_env: string, project_key: string, sync_user_id: string, }; +export type WriteJiraConfigBody = { domain: string, email: string, api_key_env: string, project_key: string, sync_user_id: string, +/** + * Mapping of operator todo/doing/done to external board columns + */ +status_mapping?: KanbanStatusMapping | null, }; diff --git a/bindings/WriteLinearConfigBody.ts b/bindings/WriteLinearConfigBody.ts index b9ae3e73..d07a051b 100644 --- a/bindings/WriteLinearConfigBody.ts +++ b/bindings/WriteLinearConfigBody.ts @@ -1,6 +1,11 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { KanbanStatusMapping } from "./KanbanStatusMapping"; /** * Body for writing a Linear project/team config section. */ -export type WriteLinearConfigBody = { workspace_key: string, api_key_env: string, project_key: string, sync_user_id: string, }; +export type WriteLinearConfigBody = { workspace_key: string, api_key_env: string, project_key: string, sync_user_id: string, +/** + * Mapping of operator todo/doing/done to external board columns + */ +status_mapping?: KanbanStatusMapping | null, }; diff --git a/collections/README.md b/collections/README.md new file mode 100644 index 00000000..f9ccbeef --- /dev/null +++ b/collections/README.md @@ -0,0 +1,44 @@ +# Community Collections + +This directory hosts **community-contributed issuetype collections** — shareable +AI workflow shapes that operator instances can browse and install from +[operator.untra.io/collections](https://operator.untra.io/collections/). + +Community collections are **hosted-only**: they are published to the docs site +by the docs generator but are never compiled into the operator binary. The +curated embedded set lives in `src/collections/`. + +## Contributing a collection + +1. Create `collections/community//` where `` matches + `^[a-z0-9_]{3,64}$` (e.g. `gastown_loop`). +2. Add a `collection.json` manifest conforming to + [the collection schema](https://operator.untra.io/collections/schema.json): + - `schema_version: 1` + - `id` equal to the directory name + - `tier: "community"` with **`author`, `url`, and `license`** (SPDX id) — + required for community submissions + - `issue_types`: 1–32 entries; keys match `^[A-Z][A-Z0-9_]{1,15}$` + (hyphens are reserved for the `{KEY}-{number}` ticket-id separator); + paths are bare filenames next to the manifest + - optional `workflow_hints` (loop shape, memory surfaces, review gates, + stop conditions) and `kanban_defaults.suggested_type_mappings` + (descriptive only — they inform users and onboarding, not execution) +3. Add one `.json` per issuetype conforming to + [the issuetype schema](https://operator.untra.io/schemas/issuetype.json), + plus an optional `.md` ticket template. +4. Do **not** set checksums — the docs generator computes them at publish time. +5. Run the CI gate locally before opening a PR: + + ```bash + cargo test --test community_collections + ``` + +See `community/example_chores/` for a minimal working example. + +## Review expectations + +Submissions are reviewed for prompt quality and safety, not just schema +validity. A good collection describes a *workflow shape worth sharing*: +what loop it runs, what memory it keeps, what gates it enforces, and when +it stops. diff --git a/collections/community/example_chores/CHORE.json b/collections/community/example_chores/CHORE.json new file mode 100644 index 00000000..5db5f457 --- /dev/null +++ b/collections/community/example_chores/CHORE.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://operator.untra.io/schemas/issuetype.json", + "key": "CHORE", + "name": "Chore", + "description": "Small recurring maintenance task with a verification step", + "mode": "autonomous", + "glyph": "*", + "color": "cyan", + "project_required": true, + "fields": [ + { + "name": "id", + "description": "Ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0 + }, + { + "name": "summary", + "description": "One-line description of the chore", + "type": "string", + "required": false, + "placeholder": "e.g. rotate dependency lockfiles", + "display_order": 1 + } + ], + "steps": [ + { + "name": "execute", + "display_name": "Executing", + "outputs": ["code", "report"], + "prompt": "Complete this maintenance chore:\n\n1. Read the ticket summary and context\n2. Make the smallest change that completes the chore\n3. Note anything that should become a follow-up ticket\n", + "review_type": "none", + "next_step": "verify" + }, + { + "name": "verify", + "display_name": "Verifying", + "outputs": ["report"], + "prompt": "Verify the chore is complete: run the project's test or lint command and summarize the result.\n", + "review_type": "none" + } + ] +} diff --git a/collections/community/example_chores/CHORE.md b/collections/community/example_chores/CHORE.md new file mode 100644 index 00000000..ef1ac300 --- /dev/null +++ b/collections/community/example_chores/CHORE.md @@ -0,0 +1,14 @@ +# Chore: {{ summary }} + +**Project**: {{ project }} +**Created**: {{ date }} + +## Context + +Describe why this chore matters and any constraints. + +## Definition of Done + +- [ ] The change is made and minimal +- [ ] Project checks pass +- [ ] Follow-ups (if any) are filed as new tickets diff --git a/collections/community/example_chores/collection.json b/collections/community/example_chores/collection.json new file mode 100644 index 00000000..89b38bca --- /dev/null +++ b/collections/community/example_chores/collection.json @@ -0,0 +1,41 @@ +{ + "schema_version": 1, + "id": "example_chores", + "name": "Example Chores", + "description": "Minimal example community collection demonstrating the shareable format.", + "version": "0.1.0", + "tier": "community", + "author": "untra", + "url": "https://github.com/untra/operator", + "tags": [ + "example", + "starter" + ], + "issue_types": [ + { + "key": "CHORE", + "schema_path": "CHORE.json", + "template_path": "CHORE.md" + } + ], + "workflow_hints": { + "loop_kind": "single_pass", + "memory_surfaces": [ + "ticket" + ], + "review_gates": [ + "test_suite" + ], + "external_tools": [ + "git" + ], + "stop_conditions": [ + "tests_green" + ], + "runner_semantics": "prompt_driven" + }, + "default_selected": [ + "CHORE" + ], + "license": "MIT" +} diff --git a/docs/.tool-versions b/docs/.tool-versions new file mode 100644 index 00000000..d942ba84 --- /dev/null +++ b/docs/.tool-versions @@ -0,0 +1 @@ +ruby 3.1.7 diff --git a/docs/collections/dev_kanban/collection.json b/docs/collections/dev_kanban/collection.json index 76d13a50..5ec89404 100644 --- a/docs/collections/dev_kanban/collection.json +++ b/docs/collections/dev_kanban/collection.json @@ -14,27 +14,32 @@ "dev" ], "compatibility": null, + "tier": "official", + "kanban_defaults": null, "issue_types": [ { "key": "TASK", "schema_path": "TASK.json", "schema_checksum": "f654229454da050ca038c273cc74884c2dbe68f09b293eee3764f23d6771b939", "template_path": "TASK.md", - "template_checksum": "7a71bf692588af1d1ac5d9192edd64316353ada616e22019dfff7683ab5d09c4" + "template_checksum": "7a71bf692588af1d1ac5d9192edd64316353ada616e22019dfff7683ab5d09c4", + "workflow_preview_path": null }, { "key": "FEAT", "schema_path": "FEAT.json", "schema_checksum": "42a04e3c8c821e69f334e92b94d57e5aa18e08bc064fe82f53839284b834e520", "template_path": "FEAT.md", - "template_checksum": "fbc7e17641d8e796dad14c09fe20ffcbbdbdb8f58199b0d29d9e9729b51b4d5a" + "template_checksum": "fbc7e17641d8e796dad14c09fe20ffcbbdbdb8f58199b0d29d9e9729b51b4d5a", + "workflow_preview_path": null }, { "key": "FIX", "schema_path": "FIX.json", "schema_checksum": "4deafa08b2dcf1c94462d7079574be05d2efd6ff0abb6036b8fde955dca0e0a1", "template_path": "FIX.md", - "template_checksum": "5237e1faf9b2c49d80fc9efff5a0d3e184f86c4fa5a34ff4dfed26ee883856bf" + "template_checksum": "5237e1faf9b2c49d80fc9efff5a0d3e184f86c4fa5a34ff4dfed26ee883856bf", + "workflow_preview_path": null } ], "workflow_hints": { diff --git a/docs/collections/devops_kanban/collection.json b/docs/collections/devops_kanban/collection.json index 3866784d..3f9d22c1 100644 --- a/docs/collections/devops_kanban/collection.json +++ b/docs/collections/devops_kanban/collection.json @@ -14,41 +14,48 @@ "devops" ], "compatibility": null, + "tier": "official", + "kanban_defaults": null, "issue_types": [ { "key": "TASK", "schema_path": "TASK.json", "schema_checksum": "f654229454da050ca038c273cc74884c2dbe68f09b293eee3764f23d6771b939", "template_path": "TASK.md", - "template_checksum": "7a71bf692588af1d1ac5d9192edd64316353ada616e22019dfff7683ab5d09c4" + "template_checksum": "7a71bf692588af1d1ac5d9192edd64316353ada616e22019dfff7683ab5d09c4", + "workflow_preview_path": null }, { "key": "FEAT", "schema_path": "FEAT.json", "schema_checksum": "2e8405834ef2a8a62966c70357cbe23f5557f446009e8d434d9fae4efc8f626a", "template_path": "FEAT.md", - "template_checksum": "fbc7e17641d8e796dad14c09fe20ffcbbdbdb8f58199b0d29d9e9729b51b4d5a" + "template_checksum": "fbc7e17641d8e796dad14c09fe20ffcbbdbdb8f58199b0d29d9e9729b51b4d5a", + "workflow_preview_path": null }, { "key": "FIX", "schema_path": "FIX.json", "schema_checksum": "4deafa08b2dcf1c94462d7079574be05d2efd6ff0abb6036b8fde955dca0e0a1", "template_path": "FIX.md", - "template_checksum": "5237e1faf9b2c49d80fc9efff5a0d3e184f86c4fa5a34ff4dfed26ee883856bf" + "template_checksum": "5237e1faf9b2c49d80fc9efff5a0d3e184f86c4fa5a34ff4dfed26ee883856bf", + "workflow_preview_path": null }, { "key": "SPIKE", "schema_path": "SPIKE.json", "schema_checksum": "1f69f05190fdff5545371c042c388e276accbbb50179ce65365f17dac042c0b5", "template_path": "SPIKE.md", - "template_checksum": "030d37b1b4b3b8a26db62bd590a61fb5b9e01d5a39be6ab771bd56e31179f2c4" + "template_checksum": "030d37b1b4b3b8a26db62bd590a61fb5b9e01d5a39be6ab771bd56e31179f2c4", + "workflow_preview_path": null }, { "key": "INV", "schema_path": "INV.json", "schema_checksum": "42129e41c6c735adb47061a7d99478a1e4ed9ca5ca9f42b3c300c4a833f9265b", "template_path": "INV.md", - "template_checksum": "65ef45c01d30b93f9d81b830896f20fe4eb9645e82d8e16ea1bd77ac4f359050" + "template_checksum": "65ef45c01d30b93f9d81b830896f20fe4eb9645e82d8e16ea1bd77ac4f359050", + "workflow_preview_path": null } ], "workflow_hints": { diff --git a/docs/collections/elves_overnight/collection.json b/docs/collections/elves_overnight/collection.json index 70c6661d..fd8c935f 100644 --- a/docs/collections/elves_overnight/collection.json +++ b/docs/collections/elves_overnight/collection.json @@ -15,34 +15,40 @@ "elves" ], "compatibility": null, + "tier": "community", + "kanban_defaults": null, "issue_types": [ { "key": "ELVSTAGE", "schema_path": "ELVSTAGE.json", "schema_checksum": "35dd4dc5460d4d88e28278df3a8591eb3a38c639e8e548a6fd893aee0c5a3982", "template_path": "ELVSTAGE.md", - "template_checksum": "31a80e4ee58c016dd13076a6042008a32021c5d9b974b29a912eb301d850e1b2" + "template_checksum": "31a80e4ee58c016dd13076a6042008a32021c5d9b974b29a912eb301d850e1b2", + "workflow_preview_path": null }, { "key": "ELVBATCH", "schema_path": "ELVBATCH.json", "schema_checksum": "16c957079f1cc5777f8feff8dc97b79a1b59d08a27df533d23bb610ad3ebaeb3", "template_path": "ELVBATCH.md", - "template_checksum": "e92e6f5a71629a1c9eb39c77799d304d5fc9175f8077f6a7c595bb6e60f7d1da" + "template_checksum": "e92e6f5a71629a1c9eb39c77799d304d5fc9175f8077f6a7c595bb6e60f7d1da", + "workflow_preview_path": null }, { "key": "LANDPR", "schema_path": "LANDPR.json", "schema_checksum": "663cd76ad36b3fddf8516c7d489bc595ab7e8ee4ef36c84d8bd2916de886afe5", "template_path": "LANDPR.md", - "template_checksum": "b0f09ec49c38f5f66d0af77bcc018337bff1df1e814b0ee05aadd38be17a685b" + "template_checksum": "b0f09ec49c38f5f66d0af77bcc018337bff1df1e814b0ee05aadd38be17a685b", + "workflow_preview_path": null }, { "key": "ELVRPT", "schema_path": "ELVRPT.json", "schema_checksum": "491f175c67462671e862420ce3a1b7275e3fcfda4edd91501cc491069bee4569", "template_path": "ELVRPT.md", - "template_checksum": "747897b723b96b5945c14ef46f44af2a7b733517b24ceb569ff8bec1b81a0c20" + "template_checksum": "747897b723b96b5945c14ef46f44af2a7b733517b24ceb569ff8bec1b81a0c20", + "workflow_preview_path": null } ], "workflow_hints": { diff --git a/docs/collections/full/ASSESS.json b/docs/collections/full/ASSESS.json deleted file mode 100644 index 4b2c4671..00000000 --- a/docs/collections/full/ASSESS.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "$schema": "../../schemas/issuetype_schema.json", - "key": "ASSESS", - "name": "Project Assessment", - "description": "Analyze project structure and generate project-context.json and catalog-info.yaml", - "mode": "autonomous", - "glyph": "~", - "color": "magenta", - "project_required": true, - "agent_prompt": "Review this project to create an agent for project assessment. The agent analyzes project structure, detects the project Kind from file patterns (using the 25-Kind taxonomy), identifies commands/entry points/environment variables, and generates both project-context.json (for AI agents) and catalog-info.yaml (for project catalog). Output ONLY the agent system prompt.", - "fields": [ - { - "name": "id", - "description": "Unique ticket identifier", - "type": "string", - "required": true, - "auto": "id", - "display_order": 0, - "user_editable": false - }, - { - "name": "summary", - "description": "Assessment description", - "type": "string", - "required": true, - "default": "", - "placeholder": "Assess project structure", - "max_length": 120, - "display_order": 1 - }, - { - "name": "kind_override", - "description": "Override detected Kind (optional)", - "type": "string", - "required": false, - "default": "", - "placeholder": "e.g., microservice, infrastructure", - "display_order": 2 - } - ], - "steps": [ - { - "name": "analyze", - "display_name": "Analyzing", - "outputs": ["report"], - "jsonSchemaFile": ".tickets/schemas/project_analysis.schema.json", - "prompt": "Analyze the project structure and output structured JSON conforming to the schema:\n\n1. **Kind Detection**: Match file patterns against the 25-Kind taxonomy:\n - Foundation: infrastructure, identity-access, config-policy, monorepo-meta\n - Standards: design-system, software-library, proto-sdk, blueprint, security-tooling, compliance-audit\n - Engines: ml-model, data-etl, microservice, api-gateway, ui-frontend, internal-tool\n - Ecosystem: build-tool, e2e-test, docs-site, playbook, cli-devtool\n - Noncurrent: reference-example, experiment-sandbox, archival-fork, test-data-fixtures\n\n2. **Languages/Frameworks/Databases**: Detect from manifest files (package.json, Cargo.toml, requirements.txt, go.mod), imports, and file extensions.\n\n3. **Commands**: Extract from:\n - package.json scripts (start, dev, test, build, lint)\n - Makefile targets\n - Cargo.toml [[bin]] sections\n - Scripts in bin/ or scripts/\n\n4. **Entry Points**: Identify key files:\n - binary_entry: src/main.rs, index.js, main.py\n - library_entry: src/lib.rs, lib/index.js\n - config: config/*.toml, .env.example\n - routes: src/routes.rs, routes/*.ts\n - main_component: src/App.tsx, src/App.vue\n\n5. **Environment Variables**: Scan .env.example, docker-compose.yml, config files for required env vars.\n\nIf kind_override is set, use that instead of auto-detection.", - "allowed_tools": ["Read", "Glob", "Grep"], - "next_step": "generate" - }, - { - "name": "generate", - "display_name": "Generating", - "outputs": ["code"], - "prompt": "Generate output files from the analysis JSON:\n\n1. **Write `project-context.json`** (for AI agents):\n - Save the complete structured analysis JSON to project root\n - This is the primary output for AI consumption\n\n2. **Write `catalog-info.yaml`** (for project catalog):\n ```yaml\n apiVersion: backstage.io/v1alpha1\n kind: Component\n metadata:\n name: {{ project }}\n description: \n annotations:\n backstage.io/techdocs-ref: dir:.\n tags:\n - \n - \n spec:\n type: \n lifecycle: production\n owner: \n ```\n\n3. **Document assessment** in `.tickets/assessments/{{ id }}.md`:\n - Kind detected with confidence score\n - Key technologies found\n - Commands available\n - Entry points identified", - "allowed_tools": ["Read", "Write", "Edit"], - "review_type": "plan", - "on_reject": { - "goto_step": "analyze", - "prompt": "Re-analyze the project with feedback: {{ rejection_reason }}" - } - } - ] -} diff --git a/docs/collections/full/ASSESS.md b/docs/collections/full/ASSESS.md deleted file mode 100644 index 4772d288..00000000 --- a/docs/collections/full/ASSESS.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -id: {{ id }} -{{#if step }}step: {{ step }} -{{/if}}status: {{ status }} -created: {{ created_datetime }} -{{#if kind_override }}kind_override: {{ kind_override }} -{{/if}}--- - -# Assessment: {{ summary }} - -{{#if kind_override }} -## Kind Override -{{ kind_override }} -{{/if}} diff --git a/docs/collections/full/FEAT.json b/docs/collections/full/FEAT.json deleted file mode 100644 index 1574db95..00000000 --- a/docs/collections/full/FEAT.json +++ /dev/null @@ -1,112 +0,0 @@ -{ - "$schema": "../../schemas/issuetype_schema.json", - "key": "FEAT", - "name": "Feature", - "description": "New feature or enhancement ticket", - "mode": "autonomous", - "glyph": "*", - "color": "green", - "project_required": true, - "agent_prompt": "Review this project and prepare to create an agent for implementing new features. The agent should read tickets from .tickets/, create feature branches, implement code following existing patterns, run tests and linting, and create PRs. Output ONLY the agent system prompt.", - "prompt": "You are implementing a new feature for the {{ project }} project.\n\nBefore starting, review the acceptance criteria and understand the requirements.\n\n## Acceptance Criteria\n{{ acceptance_criteria }}\n\n## Definition of Done\nFollow this definition of done before claiming success:\n{{ definition_of_done }}", - "fields": [ - { - "name": "id", - "description": "Unique ticket identifier", - "type": "string", - "required": true, - "auto": "id", - "display_order": 0, - "user_editable": false - }, - { - "name": "priority", - "description": "Ticket priority level", - "type": "enum", - "required": false, - "default": "P2-medium", - "options": ["P0-critical", "P1-high", "P2-medium", "P3-low"], - "display_order": 1 - }, - { - "name": "branch", - "description": "Git branch name", - "type": "string", - "required": true, - "auto": "branch", - "display_order": 2, - "user_editable": false - }, - { - "name": "summary", - "description": "name or summary of this feature", - "type": "string", - "required": true, - "default": "", - "placeholder": "Brief description of the feature", - "max_length": 120, - "display_order": 3 - }, - { - "name": "user_story", - "description": "Why is this feature needed? User story format preferred", - "type": "text", - "required": false, - "default": "", - "placeholder": "As a USER, I want to X, so I can Y", - "display_order": 4 - } - ], - "steps": [ - { - "name": "plan", - "display_name": "Planning", - "outputs": ["plan"], - "prompt": "You are implementing a new feature. First, read the ticket and explore the codebase to understand:\n1. Where the feature should be implemented\n2. What existing code/patterns to follow\n3. What tests need to be added\n\nCreate a detailed implementation plan in `.tickets/plans/{{ id }}.md` with:\n- Files to create/modify\n- Key implementation steps\n- Test coverage requirements\n- Any dependencies or risks", - "allowed_tools": ["Read", "Glob", "Grep", "Write"], - "review_type": "plan", - "on_reject": { - "goto_step": "plan", - "prompt": "Plan rejected: {{ rejection_reason }}\n\nRevise the plan based on feedback." - }, - "next_step": "build" - }, - { - "name": "build", - "display_name": "Building", - "outputs": ["code"], - "prompt": "Build the feature structure based on the plan in `.tickets/plans/{{ id }}.md`.\n\nIn this step, focus on:\n- Creating new files and modules\n- Setting up the basic structure\n- Adding type definitions and interfaces\n- Creating stub implementations\n\nDo not implement full logic yet - that comes in the next step.", - "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], - "next_step": "code" - }, - { - "name": "code", - "display_name": "Coding", - "outputs": ["code"], - "prompt": "Implement the feature logic based on the plan and structure.\n\nGuidelines:\n- Follow existing code patterns and conventions\n- Add appropriate comments for complex logic\n- Keep changes minimal and focused\n- Run formatters after making changes", - "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], - "agent": "claude-opus", - "next_step": "test" - }, - { - "name": "test", - "display_name": "Testing", - "outputs": ["test", "code"], - "prompt": "Add tests for the new feature and ensure all tests pass.\n\n1. Write unit tests for new functions/modules\n2. Add integration tests if applicable\n3. Run the full test suite: `cargo test`\n4. Run linting: `cargo clippy`\n5. Run formatting: `cargo fmt`\n\nFix any failures before proceeding.", - "allowed_tools": ["Read", "Write", "Edit", "Bash"], - "next_step": "deploy" - }, - { - "name": "deploy", - "display_name": "Deploying", - "outputs": ["pr"], - "prompt": "Create a pull request for the feature:\n\n1. Commit all changes with a descriptive message\n2. Push the feature branch\n3. Create a PR with:\n - Clear title: `feat({{ project }}): {{ summary }}`\n - Description of changes\n - Link to ticket: {{ id }}\n - Test instructions\n\nFeature complete. Move ticket to completed after PR is merged.", - "allowed_tools": ["Bash", "Read"], - "review_type": "pr", - "on_reject": { - "goto_step": "code", - "prompt": "Deploy rejected: {{ rejection_reason }}\n\nAddress feedback and revise the implementation." - } - } - ] -} diff --git a/docs/collections/full/FEAT.md b/docs/collections/full/FEAT.md deleted file mode 100644 index 10d13ae3..00000000 --- a/docs/collections/full/FEAT.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -id: {{ id }} -{{#if step }}step: {{ step }} -{{/if}}status: {{ status }} -created: {{ created_datetime }} -branch: {{ branch }} -{{#if priority }}priority: {{ priority }} -{{/if}}--- - -# Feature: {{ summary }} - -{{#if context }} -## Context -{{ context }} -{{/if}} diff --git a/docs/collections/full/FIX.json b/docs/collections/full/FIX.json deleted file mode 100644 index 39320e99..00000000 --- a/docs/collections/full/FIX.json +++ /dev/null @@ -1,138 +0,0 @@ -{ - "$schema": "../../schemas/issuetype_schema.json", - "key": "FIX", - "name": "Fix", - "description": "Bug fix, follow-up work, tech debt, or refactoring ticket", - "mode": "autonomous", - "glyph": "#", - "color": "magenta", - "project_required": true, - "agent_prompt": "Review this project and prepare to create an agent for bug fixes. The agent should read tickets from .tickets/, reproduce issues, implement minimal fixes, verify with tests, and create PRs. Output ONLY the agent system prompt.", - "prompt": "You are fixing a bug or addressing technical debt in the {{ project }} project.\n\nBefore starting, review the acceptance criteria and understand the root cause.\n\n## Acceptance Criteria\n{{ acceptance_criteria }}\n\n## Definition of Done\nFollow this definition of done before claiming success:\n{{ definition_of_done }}", - "fields": [ - { - "name": "id", - "description": "Unique ticket identifier", - "type": "string", - "required": true, - "auto": "id", - "display_order": 0, - "user_editable": false - }, - { - "name": "priority", - "description": "Ticket priority level", - "type": "enum", - "required": false, - "default": "P2-medium", - "options": ["P0-critical", "P1-high", "P2-medium", "P3-low"], - "display_order": 1 - }, - { - "name": "severity", - "description": "Bug severity (if applicable)", - "type": "enum", - "required": false, - "default": "N/A", - "options": ["S0-outage", "S1-major", "S2-minor", "S3-cosmetic", "N/A"], - "display_order": 2 - }, - { - "name": "branch", - "description": "Git branch name", - "type": "string", - "required": true, - "auto": "branch", - "display_order": 3, - "user_editable": false - }, - { - "name": "fix_type", - "description": "Type of fix work", - "type": "enum", - "required": false, - "default": "Bug fix", - "options": ["Bug fix", "Follow-up work", "Technical debt", "Refactoring", "Test coverage", "Documentation"], - "display_order": 4 - }, - { - "name": "summary", - "description": "name or summary of this bugfix", - "type": "string", - "required": true, - "default": "", - "placeholder": "Brief description of what needs to be fixed", - "max_length": 120, - "display_order": 5 - }, - { - "name": "parent", - "description": "Parent / Epic ticket ID", - "type": "string", - "required": false, - "default": "", - "placeholder": "Parent ticket ID if applicable", - "display_order": 6 - }, - { - "name": "user_story", - "description": "Context for the fix (steps to reproduce, background)", - "type": "text", - "required": false, - "default": "", - "placeholder": "Steps to reproduce bug, or context for tech debt", - "display_order": 7 - } - ], - "steps": [ - { - "name": "plan", - "display_name": "Planning", - "outputs": ["plan"], - "prompt": "Assess the project and plan reproduction:\n\n1. Understand the project structure\n2. Find how to run the project and tests\n3. Locate applicable test code\n4. Create a plan to reproduce the bug\n\nWrite plan to `.tickets/plans/{{ id }}.md` with:\n- How to run the project\n- Relevant test commands\n- Steps to reproduce the issue\n- Initial hypotheses about the root cause", - "allowed_tools": ["Read", "Glob", "Grep", "Write", "Bash"], - "review_type": "plan", - "on_reject": { - "goto_step": "plan", - "prompt": "Plan rejected: {{ rejection_reason }}\n\nRevise the reproduction plan." - }, - "next_step": "reproduce" - }, - { - "name": "reproduce", - "display_name": "Reproducing", - "outputs": ["test", "report"], - "prompt": "Reproduce the bug:\n\n1. Follow the plan to reproduce the issue\n2. Write a failing test that demonstrates the bug\n3. Document the reproduction in `.tickets/notes/{{ id }}.md`\n4. Confirm the test fails as expected", - "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], - "next_step": "test" - }, - { - "name": "test", - "display_name": "Baseline Testing", - "outputs": ["report"], - "prompt": "Establish test baseline:\n\n1. Run full test suite to understand current state\n2. Document which tests pass/fail\n3. Identify any related test coverage gaps\n4. Note any flaky or slow tests", - "allowed_tools": ["Read", "Bash"], - "next_step": "fix" - }, - { - "name": "fix", - "display_name": "Fixing", - "outputs": ["code"], - "prompt": "Implement the fix:\n\n1. Apply minimal fix to address the root cause\n2. Verify the previously failing test now passes\n3. Run full test suite: `cargo test`\n4. Run linting: `cargo clippy`\n5. Run formatting: `cargo fmt`\n\nKeep the fix focused and minimal.", - "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], - "next_step": "deploy" - }, - { - "name": "deploy", - "display_name": "Deploying", - "outputs": ["review"], - "prompt": "Create a pull request for the fix:\n\n1. Commit all changes with message: `fix({{ project }}): {{ summary }}`\n2. Push the fix branch\n3. Create a PR with:\n - Root cause analysis\n - Description of the fix\n - Test verification\n - Link to ticket: {{ id }}\n\nFix complete. Move ticket to completed after PR is merged.", - "allowed_tools": ["Bash", "Read"], - "review_type": "plan", - "on_reject": { - "goto_step": "fix", - "prompt": "Deploy rejected: {{ rejection_reason }}\n\nAddress feedback and revise the fix." - } - } - ] -} diff --git a/docs/collections/full/FIX.md b/docs/collections/full/FIX.md deleted file mode 100644 index e23158ff..00000000 --- a/docs/collections/full/FIX.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -id: {{ id }} -{{#if step }}step: {{ step }} -{{/if}}status: {{ status }} -created: {{ created_datetime }} -branch: {{ branch }} -{{#if priority }}priority: {{ priority }} -{{/if}}{{#if severity }}severity: {{ severity }} -{{/if}}{{#if fix_type }}fix_type: {{ fix_type }} -{{/if}}{{#if parent }}parent: {{ parent }} -{{/if}}--- - -# Fix: {{ summary }} - -{{#if context }} -## Context -{{ context }} -{{/if}} diff --git a/docs/collections/full/INIT.json b/docs/collections/full/INIT.json deleted file mode 100644 index 1c2bf388..00000000 --- a/docs/collections/full/INIT.json +++ /dev/null @@ -1,85 +0,0 @@ -{ - "$schema": "../../schemas/issuetype_schema.json", - "key": "INIT", - "name": "Workspace Init", - "description": "Initialize workspace structure", - "mode": "paired", - "glyph": "%", - "color": "green", - "project_required": false, - "agent_prompt": "Review this workspace to create an agent for workspace initialization. The agent scaffolds the workspace directory structure in .tickets/operator/workspace/, configures project locations and authentication, and verifies tool installation. It works in paired mode with the operator for configuration decisions. Output ONLY the agent system prompt.", - "fields": [ - { - "name": "id", - "description": "Unique ticket identifier", - "type": "string", - "required": true, - "auto": "id", - "display_order": 0, - "user_editable": false - }, - { - "name": "workspace", - "description": "Workspace root path", - "type": "string", - "required": true, - "default": "", - "placeholder": "Path to workspace root", - "display_order": 1 - }, - { - "name": "branding_name", - "description": "Custom branding name", - "type": "string", - "required": false, - "default": "", - "placeholder": "e.g., My Company Portal", - "display_order": 2 - }, - { - "name": "summary", - "description": "Initialization description", - "type": "string", - "required": true, - "default": "", - "placeholder": "Initialize workspace", - "max_length": 120, - "display_order": 3 - } - ], - "steps": [ - { - "name": "scaffold", - "display_name": "Scaffolding", - "outputs": ["code"], - "prompt": "Create the workspace directory structure:\n\n1. Create `.tickets/operator/workspace/` directory\n2. Generate workspace configuration files\n3. Create standard directory structure\n4. Set up branding directory with defaults\n\nAsk operator for branding preferences if not specified.", - "allowed_tools": ["Read", "Write", "Bash"], - "next_step": "configure" - }, - { - "name": "configure", - "display_name": "Configuring", - "outputs": ["code"], - "prompt": "Configure workspace for local development:\n\n1. Generate workspace configuration with:\n - File-based catalog locations\n - Local database (SQLite)\n2. Scan workspace for existing catalog-info.yaml files\n3. Add all discovered locations to config\n4. Configure branding if branding_name is set\n\nReview configuration with operator before proceeding.", - "allowed_tools": ["Read", "Write", "Glob"], - "review_type": "plan", - "on_reject": { - "goto_step": "configure", - "prompt": "Revise configuration based on feedback: {{ rejection_reason }}" - }, - "next_step": "verify" - }, - { - "name": "verify", - "display_name": "Verifying", - "outputs": ["report"], - "prompt": "Verify the workspace setup:\n\n1. Verify configuration is valid\n2. Check all referenced projects exist\n3. Document setup in `.tickets/operator/workspace/README.md`\n\nReport any issues that need manual resolution.", - "allowed_tools": ["Read", "Write", "Bash"], - "review_type": "plan", - "on_reject": { - "goto_step": "scaffold", - "prompt": "Setup verification failed. Re-scaffold with feedback: {{ rejection_reason }}" - } - } - ] -} diff --git a/docs/collections/full/INIT.md b/docs/collections/full/INIT.md deleted file mode 100644 index 05ef2b5d..00000000 --- a/docs/collections/full/INIT.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -id: {{ id }} -{{#if step }}step: {{ step }} -{{/if}}workspace: {{ workspace }} -{{#if branding_name }}branding_name: {{ branding_name }} -{{/if}}status: {{ status }} -created: {{ created_datetime }} ---- - -# Workspace Init: {{ summary }} - -## Workspace -{{ workspace }} - -{{#if branding_name }} -## Branding -{{ branding_name }} -{{/if}} diff --git a/docs/collections/full/INV.json b/docs/collections/full/INV.json deleted file mode 100644 index 480e502b..00000000 --- a/docs/collections/full/INV.json +++ /dev/null @@ -1,137 +0,0 @@ -{ - "$schema": "../../schemas/issuetype_schema.json", - "key": "INV", - "name": "Investigation", - "description": "Incident investigation ticket (paired mode)", - "mode": "paired", - "glyph": "!", - "color": "yellow", - "project_required": false, - "agent_prompt": "Review this project and prepare to create an agent for incident investigations. The agent triages issues, investigates root causes, coordinates remediation, and documents postmortems. It works in paired mode with the operator. Output ONLY the agent system prompt.", - "fields": [ - { - "name": "id", - "description": "Unique ticket identifier", - "type": "string", - "required": true, - "auto": "id", - "display_order": 0, - "user_editable": false - }, - { - "name": "scope", - "description": "Affected scope/project", - "type": "enum", - "required": false, - "default": "global", - "options": ["global", "adminsvc", "apisvc", "gamesvc", "g", "hushsvc", "uzersvc", "outboundsvc", "www", "iac", "proto", "e2e", "operator"], - "display_order": 1 - }, - { - "name": "severity", - "description": "Incident severity", - "type": "enum", - "required": false, - "default": "S1-major", - "options": ["S0-outage", "S1-major", "S2-minor"], - "display_order": 2 - }, - { - "name": "source", - "description": "How the incident was detected", - "type": "enum", - "required": false, - "default": "monitoring", - "options": ["alert", "user-report", "monitoring", "deploy-failure", "test-failure"], - "display_order": 3 - }, - { - "name": "summary", - "description": "named summary of the failure investigation", - "type": "string", - "required": true, - "default": "", - "placeholder": "Brief description of the observed failure", - "max_length": 120, - "display_order": 4 - }, - { - "name": "observed_behavior", - "description": "What is happening? Include error messages, logs", - "type": "text", - "required": false, - "default": "", - "placeholder": "Error messages, log excerpts, metrics", - "display_order": 5 - }, - { - "name": "expected_behavior", - "description": "What should be happening instead?", - "type": "text", - "required": false, - "default": "", - "placeholder": "Expected behavior under normal conditions", - "display_order": 6 - }, - { - "name": "impact", - "description": "Users/services affected", - "type": "string", - "required": false, - "default": "", - "placeholder": "Affected users, services, or systems", - "display_order": 7 - } - ], - "steps": [ - { - "name": "triage", - "display_name": "Triage", - "outputs": ["report"], - "prompt": "Triage the incident:\n\n1. Confirm the issue is real and ongoing\n2. Assess severity and impact\n3. Identify affected systems\n4. Begin documenting in `.tickets/incidents/{{ id }}.md`\n\nWork with the operator to gather initial information.", - "allowed_tools": ["Read", "Glob", "Grep", "Bash", "Write"], - "next_step": "investigate" - }, - { - "name": "investigate", - "display_name": "Investigating", - "outputs": ["report"], - "prompt": "Investigate the root cause:\n\n1. Search logs and metrics for anomalies\n2. Trace the error path through the code\n3. Identify recent changes that might be related\n4. Form hypotheses and test them\n5. Update `.tickets/incidents/{{ id }}.md` with findings", - "allowed_tools": ["Read", "Glob", "Grep", "Bash", "Write"], - "next_step": "remediate" - }, - { - "name": "remediate", - "display_name": "Remediating", - "outputs": ["code", "ticket"], - "prompt": "Determine remediation:\n\n1. If a quick fix is possible, implement it\n2. If a hotfix is needed, create a FIX ticket\n3. If a rollback is needed, coordinate with the operator\n4. Document the resolution in the incident report", - "allowed_tools": ["Read", "Write", "Edit", "Bash"], - "review_type": "plan", - "on_reject": { - "goto_step": "investigate", - "prompt": "More investigation needed. Continue searching for the root cause." - }, - "next_step": "postmortem" - }, - { - "name": "postmortem", - "display_name": "Postmortem", - "outputs": ["report", "ticket"], - "prompt": "Complete the postmortem:\n\n1. Finalize the incident report with:\n - Timeline of events\n - Root cause analysis\n - Remediation taken\n - Lessons learned\n2. Create follow-up tickets for preventive measures", - "allowed_tools": ["Read", "Write", "Bash"], - "review_type": "plan", - "on_reject": { - "goto_step": "postmortem", - "prompt": "Postmortem needs more detail. Expand the analysis." - }, - "next_step": "done" - }, - { - "name": "done", - "display_name": "Complete", - "outputs": ["review"], - "prompt": "Investigation complete. Move ticket to completed.", - "allowed_tools": ["Bash"] - } - ] -} diff --git a/docs/collections/full/INV.md b/docs/collections/full/INV.md deleted file mode 100644 index b03380c9..00000000 --- a/docs/collections/full/INV.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -id: {{ id }} -{{#if step }}step: {{ step }} -{{/if}}{{#if scope }}scope: {{ scope }} -{{/if}}status: {{ status }} -created: {{ created_datetime }} -{{#if severity }}severity: {{ severity }} -{{/if}}{{#if source }}source: {{ source }} -{{/if}}--- - -# Investigation: {{ summary }} - -{{#if observed_behavior }} -## Observed Behavior -{{ observed_behavior }} -{{/if}} - -{{#if expected_behavior }} -## Expected Behavior -{{ expected_behavior }} -{{/if}} - -{{#if impact }} -## Impact -{{ impact }} -{{/if}} - -## Timeline -| Time | Event | -|------|-------| -| {{ created_datetime }} | Investigation opened | - -## Findings - diff --git a/docs/collections/full/SPIKE.json b/docs/collections/full/SPIKE.json deleted file mode 100644 index a4577402..00000000 --- a/docs/collections/full/SPIKE.json +++ /dev/null @@ -1,98 +0,0 @@ -{ - "$schema": "../../schemas/issuetype_schema.json", - "key": "SPIKE", - "name": "Spike", - "description": "Research or exploration ticket (paired mode)", - "mode": "paired", - "glyph": "?", - "color": "blue", - "project_required": false, - "agent_prompt": "Review this project and prepare to create an agent for research spikes. The agent works in paired mode, explores the codebase, researches external docs, and documents findings. It should ask questions when uncertain. Output ONLY the agent system prompt.", - "fields": [ - { - "name": "id", - "description": "Unique ticket identifier", - "type": "string", - "required": true, - "auto": "id", - "display_order": 0, - "user_editable": false - }, - { - "name": "scope", - "description": "Scope of the spike", - "type": "enum", - "required": false, - "default": "global", - "options": ["global", "adminsvc", "apisvc", "gamesvc", "g", "hushsvc", "uzersvc", "outboundsvc", "www", "iac", "proto", "e2e", "operator"], - "display_order": 1 - }, - { - "name": "priority", - "description": "Ticket priority level", - "type": "enum", - "required": false, - "default": "P2-medium", - "options": ["P0-critical", "P1-high", "P2-medium", "P3-low"], - "display_order": 2 - }, - { - "name": "summary", - "description": "Research question or topic", - "type": "string", - "required": true, - "default": "", - "placeholder": "What needs to be researched or explored?", - "max_length": 120, - "display_order": 3 - }, - { - "name": "user_story", - "description": "Why is this spike needed? What decision does it inform?", - "type": "text", - "required": false, - "default": "", - "placeholder": "Background context and what decisions depend on this research", - "display_order": 4 - }, - { - "name": "success_criteria", - "description": "How will we know the spike is complete?", - "type": "text", - "required": false, - "default": "", - "placeholder": "Questions to answer, deliverables expected", - "display_order": 5 - } - ], - "steps": [ - { - "name": "explore", - "display_name": "Exploration", - "outputs": ["report"], - "prompt": "This is a research spike. Work with the operator to explore:\n\n1. Read and understand the research question\n2. Search the codebase for relevant patterns\n3. Research external documentation if needed\n4. Discuss findings with the operator\n\nDocument discoveries in `.tickets/spikes/{{ id }}.md`", - "allowed_tools": ["Read", "Glob", "Grep", "WebFetch", "WebSearch", "Write"], - "next_step": "summarize" - }, - { - "name": "summarize", - "display_name": "Summarizing", - "outputs": ["report", "ticket"], - "prompt": "Summarize findings from the spike:\n\n1. Update `.tickets/spikes/{{ id }}.md` with:\n - Key findings\n - Recommendations\n - Trade-offs identified\n - Open questions\n2. If follow-up work is identified, create ticket(s)", - "allowed_tools": ["Read", "Write", "Bash"], - "review_type": "plan", - "on_reject": { - "goto_step": "explore", - "prompt": "More research is needed. Continue exploring with the operator." - }, - "next_step": "done" - }, - { - "name": "done", - "display_name": "Complete", - "outputs": ["review"], - "prompt": "Spike complete. Move ticket to completed.", - "allowed_tools": ["Bash"] - } - ] -} diff --git a/docs/collections/full/SPIKE.md b/docs/collections/full/SPIKE.md deleted file mode 100644 index bfe6f136..00000000 --- a/docs/collections/full/SPIKE.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -id: {{ id }} -{{#if step }}step: {{ step }} -{{/if}}{{#if scope }}scope: {{ scope }} -{{/if}}status: {{ status }} -created: {{ created_datetime }} -{{#if priority }}priority: {{ priority }} -{{/if}}--- - -# Spike: {{ summary }} - -{{#if context }} -## Context -{{ context }} -{{/if}} - -{{#if success_criteria }} -## Success Criteria -{{ success_criteria }} -{{/if}} - -## Conversation Log -### Session: {{ created_date }} - -## Findings - diff --git a/docs/collections/full/SYNC.json b/docs/collections/full/SYNC.json deleted file mode 100644 index 519cd3b5..00000000 --- a/docs/collections/full/SYNC.json +++ /dev/null @@ -1,71 +0,0 @@ -{ - "$schema": "../../schemas/issuetype_schema.json", - "key": "SYNC", - "name": "Catalog Sync", - "description": "Refresh catalog entries from projects", - "mode": "autonomous", - "glyph": "@", - "color": "blue", - "project_required": false, - "agent_prompt": "Review this workspace to create an agent for catalog synchronization. The agent discovers all projects with catalog-info.yaml files, validates them against the catalog schema, checks for inconsistencies, and updates catalog entries as needed. It can run across the entire workspace or target specific projects. Output ONLY the agent system prompt.", - "fields": [ - { - "name": "id", - "description": "Unique ticket identifier", - "type": "string", - "required": true, - "auto": "id", - "display_order": 0, - "user_editable": false - }, - { - "name": "scope", - "description": "Sync scope", - "type": "enum", - "required": false, - "default": "all", - "options": ["all", "changed", "project"], - "display_order": 1 - }, - { - "name": "summary", - "description": "Sync description", - "type": "string", - "required": true, - "default": "", - "placeholder": "Sync catalog entries", - "max_length": 120, - "display_order": 2 - } - ], - "steps": [ - { - "name": "scan", - "display_name": "Scanning", - "outputs": ["report"], - "prompt": "Discover all catalog entries in the workspace:\n\n1. Find all catalog-info.yaml files in projects\n2. Parse each file and validate structure\n3. Build a dependency graph from relations\n4. Identify:\n - New entries (not in catalog)\n - Changed entries (modified since last sync)\n - Removed entries (catalog-info.yaml deleted)\n5. Document findings in `.tickets/syncs/{{ id }}.md`", - "allowed_tools": ["Read", "Glob", "Grep", "Write"], - "next_step": "validate" - }, - { - "name": "validate", - "display_name": "Validating", - "outputs": ["report"], - "prompt": "Validate all catalog entries:\n\n1. Check each catalog-info.yaml against schema\n2. Verify all referenced entities exist\n3. Check for circular dependencies\n4. Validate owner references\n5. Report any validation errors\n\nUpdate `.tickets/syncs/{{ id }}.md` with validation results.", - "allowed_tools": ["Read", "Write"], - "next_step": "update" - }, - { - "name": "update", - "display_name": "Updating", - "outputs": ["report"], - "prompt": "Update the project catalog:\n\n1. If catalog server is running, trigger catalog refresh\n2. If file-based, update the locations config\n3. Log all changes made\n4. Verify catalog is consistent after update\n\nComplete the sync report in `.tickets/syncs/{{ id }}.md`.", - "allowed_tools": ["Read", "Write", "Bash"], - "review_type": "plan", - "on_reject": { - "goto_step": "validate", - "prompt": "Re-validate with feedback: {{ rejection_reason }}" - } - } - ] -} diff --git a/docs/collections/full/SYNC.md b/docs/collections/full/SYNC.md deleted file mode 100644 index 6838583b..00000000 --- a/docs/collections/full/SYNC.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -id: {{ id }} -{{#if step }}step: {{ step }} -{{/if}}scope: {{ scope }} -status: {{ status }} -created: {{ created_datetime }} ---- - -# Catalog Sync: {{ summary }} - -## Scope -{{ scope }} diff --git a/docs/collections/full/TASK.json b/docs/collections/full/TASK.json deleted file mode 100644 index 04b95bd2..00000000 --- a/docs/collections/full/TASK.json +++ /dev/null @@ -1,75 +0,0 @@ -{ - "$schema": "../../schemas/issuetype_schema.json", - "key": "TASK", - "name": "Task", - "description": "Focused task that executes one specific thing", - "mode": "autonomous", - "glyph": ">", - "color": "cyan", - "project_required": false, - "fields": [ - { - "name": "id", - "description": "Unique ticket identifier", - "type": "string", - "required": true, - "auto": "id", - "display_order": 0, - "user_editable": false - }, - { - "name": "priority", - "description": "Ticket priority level", - "type": "enum", - "required": false, - "default": "P2-medium", - "options": ["P0-critical", "P1-high", "P2-medium", "P3-low"], - "display_order": 1 - }, - { - "name": "summary", - "description": "One-line description of the task", - "type": "string", - "required": true, - "default": "", - "placeholder": "Brief description of the task", - "max_length": 120, - "display_order": 2 - }, - { - "name": "description", - "description": "Detailed description of the task", - "type": "text", - "required": true, - "default": "", - "placeholder": "Full description of what needs to be done", - "display_order": 3 - }, - { - "name": "points", - "description": "Story points estimate", - "type": "integer", - "required": false, - "default": "0", - "display_order": 4 - }, - { - "name": "user_story", - "description": "User story or background context", - "type": "text", - "required": false, - "default": "", - "placeholder": "As a USER, I want to X, so I can Y", - "display_order": 5 - } - ], - "steps": [ - { - "name": "execute", - "display_name": "Executing", - "outputs": ["code", "report"], - "prompt": "Execute this task:\n\n1. Read the ticket summary and context\n2. Explore the codebase as needed\n3. Complete the task as specified\n4. Document what was done\n\nThis is a focused task - complete it directly without creating follow-up tickets or plans.", - "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"] - } - ] -} diff --git a/docs/collections/full/TASK.md b/docs/collections/full/TASK.md deleted file mode 100644 index b764c240..00000000 --- a/docs/collections/full/TASK.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -id: {{ id }} -{{#if step }}step: {{ step }} -{{/if}}status: {{ status }} -created: {{ created_datetime }} -{{#if priority }}priority: {{ priority }} -{{/if}}{{#if points }}points: {{ points }} -{{/if}}--- - -# Task: {{ summary }} - -## Description -{{ description }} - -{{#if user_story }} -## User Story -{{ user_story }} -{{/if}} - -{{#if acceptance_criteria }} -## Acceptance Criteria -{{ acceptance_criteria }} -{{/if}} - -## Plan -*Plan will be written to `.tickets/plans/{{ id }}.md`* diff --git a/docs/collections/full/collection.json b/docs/collections/full/collection.json deleted file mode 100644 index 521155bb..00000000 --- a/docs/collections/full/collection.json +++ /dev/null @@ -1,85 +0,0 @@ -{ - "schema_version": 1, - "id": "full", - "name": "Full", - "description": "Full workflow: all issue types combined", - "version": "1.0.0", - "publisher": "untra", - "author": "Operator!", - "url": "https://github.com/untra/operator", - "license": "MIT", - "tags": [ - "builtin" - ], - "compatibility": null, - "issue_types": [ - { - "key": "TASK", - "schema_path": "TASK.json", - "schema_checksum": "f654229454da050ca038c273cc74884c2dbe68f09b293eee3764f23d6771b939", - "template_path": "TASK.md", - "template_checksum": "7a71bf692588af1d1ac5d9192edd64316353ada616e22019dfff7683ab5d09c4" - }, - { - "key": "FEAT", - "schema_path": "FEAT.json", - "schema_checksum": "f647a5aaec7514f3dfbaad70bc757e3360e49ba7e35ce496e98dec010ea9e51b", - "template_path": "FEAT.md", - "template_checksum": "fbc7e17641d8e796dad14c09fe20ffcbbdbdb8f58199b0d29d9e9729b51b4d5a" - }, - { - "key": "FIX", - "schema_path": "FIX.json", - "schema_checksum": "4deafa08b2dcf1c94462d7079574be05d2efd6ff0abb6036b8fde955dca0e0a1", - "template_path": "FIX.md", - "template_checksum": "5237e1faf9b2c49d80fc9efff5a0d3e184f86c4fa5a34ff4dfed26ee883856bf" - }, - { - "key": "SPIKE", - "schema_path": "SPIKE.json", - "schema_checksum": "1f69f05190fdff5545371c042c388e276accbbb50179ce65365f17dac042c0b5", - "template_path": "SPIKE.md", - "template_checksum": "030d37b1b4b3b8a26db62bd590a61fb5b9e01d5a39be6ab771bd56e31179f2c4" - }, - { - "key": "INV", - "schema_path": "INV.json", - "schema_checksum": "42129e41c6c735adb47061a7d99478a1e4ed9ca5ca9f42b3c300c4a833f9265b", - "template_path": "INV.md", - "template_checksum": "65ef45c01d30b93f9d81b830896f20fe4eb9645e82d8e16ea1bd77ac4f359050" - }, - { - "key": "ASSESS", - "schema_path": "ASSESS.json", - "schema_checksum": "63d218368c58df22606fbd654920edeb304ebc1bc7282501be38ae14027aa9e1", - "template_path": "ASSESS.md", - "template_checksum": "e2fb41d771ccf37af6189cdc0ef9b2752d3e1ddb1fa193f0264fbbdd432e7e0f" - }, - { - "key": "SYNC", - "schema_path": "SYNC.json", - "schema_checksum": "6e52fb05db6e09cd723becb8ffe0388957c639b39fb81efe38d7e9cb8d725e55", - "template_path": "SYNC.md", - "template_checksum": "fc5cf44f553720b44ef328c560155fda137102cee28d60505b289d35e45d79cf" - }, - { - "key": "INIT", - "schema_path": "INIT.json", - "schema_checksum": "bd7387742f9d81e49dcbe05cf25d3db0544e2f48843fb855df881d7ab094a0d6", - "template_path": "INIT.md", - "template_checksum": "ff54445fb446a8ba8155e00259ebf44860c2e8d1865df6943ceec648fe3650e8" - } - ], - "workflow_hints": null, - "default_selected": [ - "TASK", - "FEAT", - "FIX", - "SPIKE", - "INV", - "ASSESS", - "SYNC", - "INIT" - ], - "checksum": "e1f9940d1c3f84b26ec6e574bc4f4804d2f9e6dc64298344ef3c06ec9dabbb77" -} diff --git a/docs/collections/index.json b/docs/collections/index.json index d410a91c..802874b4 100644 --- a/docs/collections/index.json +++ b/docs/collections/index.json @@ -11,7 +11,9 @@ "builtin" ], "manifest_path": "simple/collection.json", - "checksum": "fce81369f98e00ee6fe35194076055b59f4882d8d1613a914bbce660593a05b4" + "checksum": "a365a781cfa26378726fe0c381daaf95ba783898c78e315794940c0863e627dd", + "tier": "official", + "docs_path": null }, { "id": "dev_kanban", @@ -24,7 +26,9 @@ "dev" ], "manifest_path": "dev_kanban/collection.json", - "checksum": "87460062d63068e9e96f6ee628cc466bec6951c0b766f338178b66cb5ac1d23f" + "checksum": "746173e1a6d6fd161a71e9a0d11d8d189bcea650b5ed0934ff45b33dbe6046c0", + "tier": "official", + "docs_path": null }, { "id": "devops_kanban", @@ -37,7 +41,9 @@ "devops" ], "manifest_path": "devops_kanban/collection.json", - "checksum": "068c5112a615ccc4f9a1c11e0b90c1a193b07e5b07565100880750f1624edaa4" + "checksum": "dd214563b84e5cdac33a5f2fffa91a22cfe334cf5d26693a762ff7e27dc4f030", + "tier": "official", + "docs_path": null }, { "id": "operator", @@ -49,18 +55,9 @@ "automation" ], "manifest_path": "operator/collection.json", - "checksum": "9a5aa2ac773bd4e445cb951f870b7e78ff3e0002be9a66bba05c14e9abe9c4f3" - }, - { - "id": "full", - "name": "Full", - "description": "Full workflow: all issue types combined", - "version": "1.0.0", - "tags": [ - "builtin" - ], - "manifest_path": "full/collection.json", - "checksum": "305830707c108a825bd5aab14aaf9e489e0d7b286e36c77269ad0da94420f2d4" + "checksum": "4fa68663b0346086879714210440d1f3893ffab29b182683fcb15d3c2789b546", + "tier": "official", + "docs_path": null }, { "id": "ralph_loop", @@ -74,7 +71,9 @@ "ralph" ], "manifest_path": "ralph_loop/collection.json", - "checksum": "f37e1379c1ffea6208d25add591daa468b680c2a796b04fc490c0a4e30f96c39" + "checksum": "fd59584f70acc1c5e7aa3b03d36ea31f30316018457e3e866991bd7f70d76e9d", + "tier": "community", + "docs_path": null }, { "id": "jr_orchestration", @@ -88,7 +87,9 @@ "jr" ], "manifest_path": "jr_orchestration/collection.json", - "checksum": "e89a9ec49af83f5c431fc95e0418943d7aedc3fd087b5b05ee12b339007c0f39" + "checksum": "d2aac0a3215aaaf96b28cb9960f56ef82ffd0c9fa834023ca69385ff29310ea7", + "tier": "community", + "docs_path": null }, { "id": "elves_overnight", @@ -102,7 +103,9 @@ "elves" ], "manifest_path": "elves_overnight/collection.json", - "checksum": "1078479804ba0d581bff29d7e153904dee0d46cfb41d4f05841891814a9f62eb" + "checksum": "0edf6f1503fc13302ab616d1c681ebad53b3f5a8475c548c7c37760a341a475e", + "tier": "community", + "docs_path": null } ] } diff --git a/docs/collections/jr_orchestration/collection.json b/docs/collections/jr_orchestration/collection.json index 889dfba2..670b4076 100644 --- a/docs/collections/jr_orchestration/collection.json +++ b/docs/collections/jr_orchestration/collection.json @@ -15,41 +15,48 @@ "jr" ], "compatibility": null, + "tier": "community", + "kanban_defaults": null, "issue_types": [ { "key": "JRPLAN", "schema_path": "JRPLAN.json", "schema_checksum": "f123c0fbf30095cfc0f42298789656bfccbc2a5bac12ce48a836820110c550ac", "template_path": "JRPLAN.md", - "template_checksum": "d6b07074e56971a6e451cb620fe4a478740d17882140d6ad58da3209874a19f2" + "template_checksum": "d6b07074e56971a6e451cb620fe4a478740d17882140d6ad58da3209874a19f2", + "workflow_preview_path": null }, { "key": "JRFEAT", "schema_path": "JRFEAT.json", "schema_checksum": "8de197203daf3ba5635d4cd0bdee5665372a732f0b8110ad8a92615b36906c20", "template_path": "JRFEAT.md", - "template_checksum": "85da760b93724d4f7eb05c86ca6ae427c58c240a141a797091aa4b4bcf828160" + "template_checksum": "85da760b93724d4f7eb05c86ca6ae427c58c240a141a797091aa4b4bcf828160", + "workflow_preview_path": null }, { "key": "JRTASK", "schema_path": "JRTASK.json", "schema_checksum": "48decac3204ad569f35c2b7603272db35d7084990d46d90483b326e1d718d6b6", "template_path": "JRTASK.md", - "template_checksum": "a3029a01c71f68d505d4c209eaa022ca8f3925797bb41173d5fe78c6d5c8a6fe" + "template_checksum": "a3029a01c71f68d505d4c209eaa022ca8f3925797bb41173d5fe78c6d5c8a6fe", + "workflow_preview_path": null }, { "key": "JRREV", "schema_path": "JRREV.json", "schema_checksum": "e99767e4d217392b321118e3a7e7f016636437361ed94f8453667348ff1e9394", "template_path": "JRREV.md", - "template_checksum": "2ae11b478d2c87d68a729907037d67a56385bf9580748f278e1ce67166b51e14" + "template_checksum": "2ae11b478d2c87d68a729907037d67a56385bf9580748f278e1ce67166b51e14", + "workflow_preview_path": null }, { "key": "JRREBASE", "schema_path": "JRREBASE.json", "schema_checksum": "08772fab7390c7c2bc88f37d2584975c76173aba1c4618493662632fcd2f0dc2", "template_path": "JRREBASE.md", - "template_checksum": "032e592b86ec4a40610e51a073af930e0aff5e34a920e0be9a2128b57c432afa" + "template_checksum": "032e592b86ec4a40610e51a073af930e0aff5e34a920e0be9a2128b57c432afa", + "workflow_preview_path": null } ], "workflow_hints": { diff --git a/docs/collections/operator/AGENT-SETUP.json b/docs/collections/operator/AGENT_SETUP.json similarity index 97% rename from docs/collections/operator/AGENT-SETUP.json rename to docs/collections/operator/AGENT_SETUP.json index 3826b811..0ff04e9b 100644 --- a/docs/collections/operator/AGENT-SETUP.json +++ b/docs/collections/operator/AGENT_SETUP.json @@ -1,6 +1,6 @@ { "$schema": "../../schemas/issuetype_schema.json", - "key": "AGENT-SETUP", + "key": "AGENT_SETUP", "name": "Agent Setup", "description": "Set up Claude agent configuration for a project", "mode": "paired", @@ -32,7 +32,7 @@ "name": "agent_tool", "description": "Target agent tool (claude, aider, etc.)", "type": "enum", - "values": ["claude", "aider", "gemini"], + "options": ["claude", "aider", "gemini"], "required": true, "default": "claude", "display_order": 2 diff --git a/docs/collections/operator/AGENT-SETUP.md b/docs/collections/operator/AGENT_SETUP.md similarity index 100% rename from docs/collections/operator/AGENT-SETUP.md rename to docs/collections/operator/AGENT_SETUP.md diff --git a/src/collections/operator/PROJECT-INIT.json b/docs/collections/operator/PROJECT_INIT.json similarity index 98% rename from src/collections/operator/PROJECT-INIT.json rename to docs/collections/operator/PROJECT_INIT.json index 66c72848..b9f5aafb 100644 --- a/src/collections/operator/PROJECT-INIT.json +++ b/docs/collections/operator/PROJECT_INIT.json @@ -1,6 +1,6 @@ { "$schema": "../../schemas/issuetype_schema.json", - "key": "PROJECT-INIT", + "key": "PROJECT_INIT", "name": "Project Initialization", "description": "Initialize project with Operator conventions", "mode": "autonomous", diff --git a/docs/collections/operator/PROJECT-INIT.md b/docs/collections/operator/PROJECT_INIT.md similarity index 100% rename from docs/collections/operator/PROJECT-INIT.md rename to docs/collections/operator/PROJECT_INIT.md diff --git a/docs/collections/operator/collection.json b/docs/collections/operator/collection.json index bfc8425f..ceea2bf5 100644 --- a/docs/collections/operator/collection.json +++ b/docs/collections/operator/collection.json @@ -13,48 +13,72 @@ "automation" ], "compatibility": null, + "tier": "official", + "kanban_defaults": null, "issue_types": [ { "key": "ASSESS", "schema_path": "ASSESS.json", "schema_checksum": "63d218368c58df22606fbd654920edeb304ebc1bc7282501be38ae14027aa9e1", "template_path": "ASSESS.md", - "template_checksum": "e2fb41d771ccf37af6189cdc0ef9b2752d3e1ddb1fa193f0264fbbdd432e7e0f" + "template_checksum": "e2fb41d771ccf37af6189cdc0ef9b2752d3e1ddb1fa193f0264fbbdd432e7e0f", + "workflow_preview_path": null }, { "key": "SYNC", "schema_path": "SYNC.json", "schema_checksum": "6e52fb05db6e09cd723becb8ffe0388957c639b39fb81efe38d7e9cb8d725e55", "template_path": "SYNC.md", - "template_checksum": "fc5cf44f553720b44ef328c560155fda137102cee28d60505b289d35e45d79cf" + "template_checksum": "fc5cf44f553720b44ef328c560155fda137102cee28d60505b289d35e45d79cf", + "workflow_preview_path": null }, { "key": "INIT", "schema_path": "INIT.json", "schema_checksum": "bd7387742f9d81e49dcbe05cf25d3db0544e2f48843fb855df881d7ab094a0d6", "template_path": "INIT.md", - "template_checksum": "ff54445fb446a8ba8155e00259ebf44860c2e8d1865df6943ceec648fe3650e8" + "template_checksum": "ff54445fb446a8ba8155e00259ebf44860c2e8d1865df6943ceec648fe3650e8", + "workflow_preview_path": null }, { - "key": "AGENT-SETUP", - "schema_path": "AGENT-SETUP.json", - "schema_checksum": "c611e58617b180838b286ec0907ef11d17e25581f56699cedef4d31c1223b15a", - "template_path": "AGENT-SETUP.md", - "template_checksum": "528f9437434e5ed8f8ca2f8e09c3acb04d021e8fa2fbb978cb6aed5c005aa8f7" + "key": "AGENT_SETUP", + "schema_path": "AGENT_SETUP.json", + "schema_checksum": "6f934f8a688058d85453cf97c39a79a83f71ddce2a1493b9fcfe441ded485d5e", + "template_path": "AGENT_SETUP.md", + "template_checksum": "528f9437434e5ed8f8ca2f8e09c3acb04d021e8fa2fbb978cb6aed5c005aa8f7", + "workflow_preview_path": null }, { - "key": "PROJECT-INIT", - "schema_path": "PROJECT-INIT.json", - "schema_checksum": "0fa43b3dbb839b0a440c2e702a9fd499ebd172bfcaaa4266a20aed7db7c90072", - "template_path": "PROJECT-INIT.md", - "template_checksum": "767a9a6481908826809222e87a36c840f759047170b0ff9d917ffa2846a19805" + "key": "PROJECT_INIT", + "schema_path": "PROJECT_INIT.json", + "schema_checksum": "3d942d60f658e477043ae294ed8006e5d7d4964771c47470c409ce4ab114b684", + "template_path": "PROJECT_INIT.md", + "template_checksum": "767a9a6481908826809222e87a36c840f759047170b0ff9d917ffa2846a19805", + "workflow_preview_path": null } ], - "workflow_hints": null, + "workflow_hints": { + "loop_kind": "single_pass", + "memory_surfaces": [ + "ticket", + "catalog-info.yaml", + ".claude/agents/" + ], + "review_gates": [ + "human" + ], + "external_tools": [ + "git" + ], + "stop_conditions": [ + "setup_artifacts_written" + ], + "runner_semantics": "prompt_driven" + }, "default_selected": [ "ASSESS", "SYNC", "INIT" ], - "checksum": "4f8f40dde1b85d01d5da929ff4329ea126ffbf9da94c5e5de60154101d1881bd" + "checksum": "ec34f16fbf1571e7662b85074f857f19656ae704569a5be083e49d1c5f741069" } diff --git a/docs/collections/ralph_loop/collection.json b/docs/collections/ralph_loop/collection.json index b75a4d14..2afb2a31 100644 --- a/docs/collections/ralph_loop/collection.json +++ b/docs/collections/ralph_loop/collection.json @@ -15,27 +15,32 @@ "ralph" ], "compatibility": null, + "tier": "community", + "kanban_defaults": null, "issue_types": [ { "key": "PRD", "schema_path": "PRD.json", "schema_checksum": "6d70b60b8c3986d4782c5e8c5e453c1b9dc44234cb2bbd054498231945435ce1", "template_path": "PRD.md", - "template_checksum": "c719e1af7c715cdac7d59ec8586d06c612debfbf8c560d5be3ae738499fe9518" + "template_checksum": "c719e1af7c715cdac7d59ec8586d06c612debfbf8c560d5be3ae738499fe9518", + "workflow_preview_path": null }, { "key": "STORY", "schema_path": "STORY.json", "schema_checksum": "7af1d443e7083d08c860a51a6524fec969c99812e6ee51d181058d7b8e92bd0e", "template_path": "STORY.md", - "template_checksum": "340c5fda8830dd79fbe8432dc6921d1556e145d657933b8575f1092013bd9f54" + "template_checksum": "340c5fda8830dd79fbe8432dc6921d1556e145d657933b8575f1092013bd9f54", + "workflow_preview_path": null }, { "key": "RLOOP", "schema_path": "RLOOP.json", "schema_checksum": "edb318f1adf22fb6ce18ebff5b99f3e741ff8aee911e34d4864bd756e038ab8d", "template_path": "RLOOP.md", - "template_checksum": "c29422d0a45c58aa22aee9d010f9ea38d3ecaf47940e53611b082ac180194fb3" + "template_checksum": "c29422d0a45c58aa22aee9d010f9ea38d3ecaf47940e53611b082ac180194fb3", + "workflow_preview_path": null } ], "workflow_hints": { diff --git a/docs/collections/schema.json b/docs/collections/schema.json index d889f9dc..f405b408 100644 --- a/docs/collections/schema.json +++ b/docs/collections/schema.json @@ -21,6 +21,24 @@ "url": { "type": ["string", "null"], "description": "Link to the collection's source (GitHub repo or project page)." }, "license": { "type": ["string", "null"], "description": "SPDX license id." }, "tags": { "type": "array", "items": { "type": "string" } }, + "tier": { + "type": "string", + "enum": ["official", "community"], + "default": "official", + "description": "Provenance tier: who authored and maintains the collection. Orthogonal to distribution (curated community-authored collections may ship embedded)." + }, + "kanban_defaults": { + "type": ["object", "null"], + "description": "Descriptive kanban onboarding defaults. v1 is metadata only: suggestions seed the onboarding mapping UI but do not drive sync behavior.", + "additionalProperties": false, + "properties": { + "suggested_type_mappings": { + "type": "object", + "additionalProperties": { "type": "string" }, + "description": "Suggested provider issue-type NAME -> collection issuetype key." + } + } + }, "compatibility": { "type": ["object", "null"], "additionalProperties": false, @@ -43,7 +61,8 @@ "schema_path": { "type": "string", "description": "Path to the issuetype JSON, relative to the manifest." }, "schema_checksum": { "type": "string", "description": "SHA-256 (lowercase hex) of the issuetype JSON bytes. Required for hosted manifests; omitted for embedded ones." }, "template_path": { "type": ["string", "null"], "description": "Optional path to the markdown template, relative to the manifest." }, - "template_checksum": { "type": ["string", "null"], "description": "SHA-256 (lowercase hex) of the markdown template bytes, if present." } + "template_checksum": { "type": ["string", "null"], "description": "SHA-256 (lowercase hex) of the markdown template bytes, if present." }, + "workflow_preview_path": { "type": ["string", "null"], "description": "Path to a pre-generated workflow preview (.js), relative to the manifest. Filled by the docs producer for visualization; excluded from checksum derivation." } } } }, diff --git a/docs/collections/simple/collection.json b/docs/collections/simple/collection.json index 79d68938..101cf86f 100644 --- a/docs/collections/simple/collection.json +++ b/docs/collections/simple/collection.json @@ -12,16 +12,30 @@ "builtin" ], "compatibility": null, + "tier": "official", + "kanban_defaults": null, "issue_types": [ { "key": "TASK", "schema_path": "TASK.json", "schema_checksum": "f654229454da050ca038c273cc74884c2dbe68f09b293eee3764f23d6771b939", "template_path": "TASK.md", - "template_checksum": "7a71bf692588af1d1ac5d9192edd64316353ada616e22019dfff7683ab5d09c4" + "template_checksum": "7a71bf692588af1d1ac5d9192edd64316353ada616e22019dfff7683ab5d09c4", + "workflow_preview_path": null } ], - "workflow_hints": null, + "workflow_hints": { + "loop_kind": "single_pass", + "memory_surfaces": [ + "ticket" + ], + "review_gates": [], + "external_tools": [], + "stop_conditions": [ + "task_complete" + ], + "runner_semantics": "prompt_driven" + }, "default_selected": [ "TASK" ], diff --git a/docs/getting-started/kanban/github.md b/docs/getting-started/kanban/github.md index 320f5b1e..bcf2ebbe 100644 --- a/docs/getting-started/kanban/github.md +++ b/docs/getting-started/kanban/github.md @@ -72,8 +72,12 @@ api_key_env = "OPERATOR_GITHUB_TOKEN" # default [kanban.github."my-org".projects.PVT_kwDOABcdefg] sync_user_id = "12345678" # numeric GitHub `databaseId` -sync_statuses = ["In Progress", "Todo"] collection_name = "dev_kanban" + +[kanban.github."my-org".projects.PVT_kwDOABcdefg.status_mapping] +todo = "Todo" # Status option pulled into operator's queue (and requeue target) +doing = "In Progress" # Status pushed when a ticket is launched/claimed +done = "Done" # Status pushed when a ticket completes ``` The hashmap key under `[kanban.github.""]` is the GitHub owner login (user or org). Project keys inside `projects` are **GraphQL node IDs** (e.g. `PVT_kwDOABcdefg`) — not project numbers — because every Projects v2 mutation needs the node ID and storing it directly avoids an extra lookup per call. @@ -171,15 +175,29 @@ Operator's `kanban_issuetype_service` syncs the available types into a local cat ```toml [kanban.github."my-org".projects.PVT_kwDOABcdefg] sync_user_id = "12345678" # your numeric GitHub databaseId -sync_statuses = ["In Progress", "Todo"] # Status field option names to sync collection_name = "dev_kanban" # IssueTypeCollection to use +[kanban.github."my-org".projects.PVT_kwDOABcdefg.status_mapping] +todo = "Todo" # Status option pulled into operator's queue (and requeue target) +doing = "In Progress" # Status pushed when a ticket is launched/claimed +done = "Done" # Status pushed when a ticket completes + [kanban.github."my-org".projects.PVT_kwDOABcdefg.type_mappings] "L_bug" = "FIX" "L_feature" = "FEAT" "L_spike" = "SPIKE" ``` +`status_mapping` maps operator's strict todo/doing/done states to the project's +`Status` single-select option names. Issues are pulled from the `todo` (and +`doing`) columns; with `bidirectional = true`, ticket transitions push the card +to the mapped column (requeue → `todo` only fires when `todo` is mapped; +unmapped `doing`/`done` fall back to `"In Progress"`/`"Done"`). Discover the +real option names via `GET /api/v1/kanban/github/PVT_kwDOABcdefg/statuses`. + +> **Migrating from `sync_statuses`:** the old list is no longer read (the key +> is silently ignored). Re-express it as the `status_mapping` table above. + The keys in `type_mappings` are the GraphQL label IDs (or issue type IDs) returned by `get_issue_types()` — they're persisted in the local issue type catalog after the first sync, and you can find them with: ```bash @@ -194,7 +212,7 @@ Pull issues from GitHub Projects: operator sync ``` -The provider client-side filters by your `sync_user_id` (project items don't support server-side assignee filtering in the GraphQL API), so very large projects may pull a few extra pages before applying the filter. Status filtering uses the `Status` single-select field's option names — make sure the values in `sync_statuses` exactly match the names defined in your project (case-insensitive). +The provider client-side filters by your `sync_user_id` (project items don't support server-side assignee filtering in the GraphQL API), so very large projects may pull a few extra pages before applying the filter. Status filtering uses the `Status` single-select field's option names — make sure the values in `status_mapping` exactly match the names defined in your project (case-insensitive). ### What gets synced @@ -249,7 +267,7 @@ If `project` (or `read:project`) is missing, that's your problem. - Confirm `sync_user_id` is the numeric `databaseId`, **not** your login. `gh api user --jq .id` returns the right value. - Confirm the issue is actually assigned to that user. Operator filters client-side after fetching, so unassigned items are dropped silently. -- Confirm the issue's Status field value appears in `sync_statuses`. Match is case-insensitive but must otherwise be exact. +- Confirm the issue's Status field value matches the `status_mapping` `todo` (or `doing`) column. Match is case-insensitive but must otherwise be exact. - For huge projects (>500 items), check the operator logs for pagination warnings. ### "No GitHub Projects v2 found for this token" diff --git a/docs/getting-started/kanban/index.md b/docs/getting-started/kanban/index.md index fb89a881..c77a475d 100644 --- a/docs/getting-started/kanban/index.md +++ b/docs/getting-started/kanban/index.md @@ -24,6 +24,26 @@ Operator syncs tickets from your kanban provider: 3. **Assign**: Dispatches tickets to available agents 4. **Update**: Pushes status changes back to your provider +## Column Mapping (todo / doing / done) + +Operator is strict about its three internal states — **todo**, **doing**, +**done** — because they represent the work actually inflight at operator's +level. External boards have flexible columns, so each synced project declares +a `status_mapping` linking the two: + +```toml +[kanban.."".projects..status_mapping] +todo = "To Do" # pulled into the queue; requeue pushes back here +doing = "In Progress" # pushed when a ticket is launched/claimed +done = "Done" # pushed when a ticket completes +``` + +With `bidirectional = true`, a synced ticket moves on the external board as +operator works it: launch → `doing`, complete → `done`, return-to-queue → +`todo`. The board's real column names are discoverable via the +`/api/v1/kanban/statuses` endpoints, and the VS Code config panel offers them +as dropdowns. See the per-provider guides for details. + ## Choosing a Provider Both Jira Cloud and Linear are fully supported kanban providers: diff --git a/docs/getting-started/kanban/jira.md b/docs/getting-started/kanban/jira.md index 15845bae..78781c6e 100644 --- a/docs/getting-started/kanban/jira.md +++ b/docs/getting-started/kanban/jira.md @@ -87,10 +87,35 @@ Configure sync settings for each project: ```toml [kanban.jira."your-org.atlassian.net".projects.PROJ] sync_user_id = "5e3f7acd9876543210abcdef" # Your Jira accountId -sync_statuses = ["To Do", "In Progress"] # Statuses to sync (empty = default only) collection_name = "dev_kanban" # IssueTypeCollection to use + +[kanban.jira."your-org.atlassian.net".projects.PROJ.status_mapping] +todo = "To Do" # Column pulled into operator's queue (and requeue target) +doing = "In Progress" # Column pushed when a ticket is launched/claimed +done = "Done" # Column pushed when a ticket completes ``` +### Column Mapping (todo / doing / done) + +Operator is strict about its three internal states — todo, doing, done — while +Jira boards have arbitrary columns. `status_mapping` declares which Jira status +corresponds to each operator state: + +- Issues are **pulled** from the `todo` (and `doing`) columns into the queue. +- With `bidirectional = true`, launching a ticket moves the Jira issue to + `doing`, completing it moves it to `done`, and returning it to the queue + moves it back to `todo` (requeue only pushes when `todo` is mapped). +- Unmapped `doing`/`done` fall back to `"In Progress"`/`"Done"` on push. + +Discover the board's real column names via +`POST /api/v1/kanban/statuses` (onboarding) or +`GET /api/v1/kanban/jira/PROJ/statuses` (configured project) — the VS Code +config panel uses these to populate the mapping dropdowns. + +> **Migrating from `sync_statuses`:** the old +> `sync_statuses = ["To Do", "In Progress"]` list is no longer read (the key is +> silently ignored). Re-express it as the explicit `status_mapping` table above. + ## Troubleshooting ### Authentication errors diff --git a/docs/getting-started/kanban/linear.md b/docs/getting-started/kanban/linear.md index 5da6e2bc..4d4cab7e 100644 --- a/docs/getting-started/kanban/linear.md +++ b/docs/getting-started/kanban/linear.md @@ -92,10 +92,28 @@ Configure sync settings for each team: ```toml [kanban.linear."team-uuid-here".projects.default] sync_user_id = "user-uuid-here" # Your Linear user ID -sync_statuses = ["Todo", "In Progress"] # Statuses to sync (empty = default only) collection_name = "dev_kanban" # IssueTypeCollection to use + +[kanban.linear."team-uuid-here".projects.default.status_mapping] +todo = "Todo" # Workflow state pulled into operator's queue (and requeue target) +doing = "In Progress" # State pushed when a ticket is launched/claimed +done = "Done" # State pushed when a ticket completes ``` +### Column Mapping (todo / doing / done) + +`status_mapping` declares which Linear workflow state corresponds to each of +operator's strict todo/doing/done states. Issues are pulled from the `todo` +(and `doing`) states; with `bidirectional = true`, ticket transitions are +pushed back to the mapped states (requeue → `todo` only fires when `todo` is +mapped; unmapped `doing`/`done` fall back to `"In Progress"`/`"Done"`). + +Discover the team's real state names via `POST /api/v1/kanban/statuses` +(onboarding) or `GET /api/v1/kanban/linear/TEAM/statuses` (configured team). + +> **Migrating from `sync_statuses`:** the old list is no longer read (the key +> is silently ignored). Re-express it as the `status_mapping` table above. + ## Troubleshooting ### Authentication errors diff --git a/docs/schemas/config.json b/docs/schemas/config.json index 6e87c1e9..682a9c35 100644 --- a/docs/schemas/config.json +++ b/docs/schemas/config.json @@ -1283,13 +1283,9 @@ "type": "string", "default": "" }, - "sync_statuses": { - "description": "Workflow statuses to sync (empty = default/first status only)", - "type": "array", - "items": { - "type": "string" - }, - "default": [] + "status_mapping": { + "description": "Mapping of operator todo/doing/done to external board columns", + "$ref": "#/$defs/KanbanStatusMapping" }, "collection_name": { "description": "Optional `IssueTypeCollection` name this project maps to.\nNot required for kanban onboarding or sync.", @@ -1313,6 +1309,33 @@ } } }, + "KanbanStatusMapping": { + "description": "Explicit mapping from operator's strict todo/doing/done states to the\nexternal board's column/status names.\n\nDrives bidirectional sync: issues are pulled from the `todo` column,\npushed to `doing` when a ticket is claimed, to `done` when completed, and\nback to `todo` when requeued. Unset fields fall back per-transition\n(`doing` → \"In Progress\", `done` → \"Done\"); requeue only pushes when\n`todo` is explicitly mapped.", + "type": "object", + "properties": { + "todo": { + "description": "External column for operator \"todo\" (queued work; also the pull source)", + "type": [ + "string", + "null" + ] + }, + "doing": { + "description": "External column for operator \"doing\" (claimed/launched tickets)", + "type": [ + "string", + "null" + ] + }, + "done": { + "description": "External column for operator \"done\" (completed tickets)", + "type": [ + "string", + "null" + ] + } + } + }, "LinearConfig": { "description": "Linear provider configuration\n\nThe workspace slug is specified as the `HashMap` key in KanbanConfig.linear", "type": "object", diff --git a/docs/schemas/config.md b/docs/schemas/config.md index 197b7aa3..60f5df94 100644 --- a/docs/schemas/config.md +++ b/docs/schemas/config.md @@ -443,11 +443,28 @@ Per-project/team sync configuration for a kanban provider | Property | Type | Required | Description | | --- | --- | --- | --- | | `sync_user_id` | `string` | No | User ID to sync issues for (provider-specific format) - Jira: accountId (e.g., "5e3f7acd9876543210abcdef") - Linear: user ID (e.g., "abc12345-6789-0abc-def0-123456789abc") - GitHub Projects: numeric GitHub `databaseId` (e.g., "12345678") | -| `sync_statuses` | `array` | No | Workflow statuses to sync (empty = default/first status only) | +| `status_mapping` | → `KanbanStatusMapping` | No | Mapping of operator todo/doing/done to external board columns | | `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. | +### KanbanStatusMapping + +Explicit mapping from operator's strict todo/doing/done states to the +external board's column/status names. + +Drives bidirectional sync: issues are pulled from the `todo` column, +pushed to `doing` when a ticket is claimed, to `done` when completed, and +back to `todo` when requeued. Unset fields fall back per-transition +(`doing` → "In Progress", `done` → "Done"); requeue only pushes when +`todo` is explicitly mapped. + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `todo` | `string` \| `null` | No | External column for operator "todo" (queued work; also the pull source) | +| `doing` | `string` \| `null` | No | External column for operator "doing" (claimed/launched tickets) | +| `done` | `string` \| `null` | No | External column for operator "done" (completed tickets) | + ### LinearConfig Linear provider configuration diff --git a/docs/schemas/metadata.md b/docs/schemas/metadata.md index 84503d71..92abfbf9 100644 --- a/docs/schemas/metadata.md +++ b/docs/schemas/metadata.md @@ -25,7 +25,7 @@ Schema for operator-tracked ticket metadata in YAML frontmatter. This schema doc | Property | Type | Required | Description | | --- | --- | --- | --- | -| `id` | `string` | Yes | Kanban ticket ID (e.g., FEAT-1234). Also used for tmux session name derivation. | +| `id` | `string` | Yes | Kanban ticket ID (e.g., FEAT-1234). Also used for tmux session name derivation. Key grammar: uppercase start, then uppercase letters, digits, or underscores (hyphen is reserved as the key/number separator). | | `status` | `string` | Yes | Operator workflow status | | `step` | `string` | No | Current workflow step name (e.g., plan, build, code, test, deploy) | | `priority` | `string` | No | Ticket priority level | @@ -41,10 +41,10 @@ Schema for operator-tracked ticket metadata in YAML frontmatter. This schema doc ### id -- **Description**: Kanban ticket ID (e.g., FEAT-1234). Also used for tmux session name derivation. +- **Description**: Kanban ticket ID (e.g., FEAT-1234). Also used for tmux session name derivation. Key grammar: uppercase start, then uppercase letters, digits, or underscores (hyphen is reserved as the key/number separator). - **Type**: `string` -- **Pattern**: `^[A-Z]+-\d+$` -- **Examples**: `FEAT-1234`, `FIX-5678`, `SPIKE-0001`, `INV-0042`, `TASK-9999` +- **Pattern**: `^[A-Z][A-Z0-9_]*-\d+$` +- **Examples**: `FEAT-1234`, `FIX-5678`, `SPIKE-0001`, `INV-0042`, `TASK-9999`, `AGENT_SETUP-0001` ### status diff --git a/docs/schemas/openapi.json b/docs/schemas/openapi.json index ba45f550..6b088b2a 100644 --- a/docs/schemas/openapi.json +++ b/docs/schemas/openapi.json @@ -1309,6 +1309,38 @@ } } }, + "/api/v1/kanban/statuses": { + "post": { + "tags": [ + "Kanban" + ], + "summary": "POST /`api/v1/kanban/statuses`", + "description": "List the workflow statuses/columns of a specific project using ephemeral\ncredentials, so onboarding UIs can offer real column names in the\ntodo/doing/done mapping dropdowns. No persistence side effects.", + "operationId": "kanban_list_statuses", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListKanbanStatusesRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Workflow statuses/columns for the project", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListKanbanStatusesResponse" + } + } + } + } + } + } + }, "/api/v1/kanban/validate": { "post": { "tags": [ @@ -1440,6 +1472,54 @@ } } }, + "/api/v1/kanban/{provider}/{project_key}/statuses": { + "get": { + "tags": [ + "Kanban" + ], + "summary": "GET /`api/v1/kanban/:provider/:project_key/statuses`", + "description": "Returns the external board's workflow statuses/columns for an\nalready-configured provider/project, using the stored config's\ncredentials. Used by config UIs (e.g. the VS Code `ProjectRow`) to populate\nthe todo/doing/done mapping dropdowns after onboarding.", + "operationId": "kanban_project_statuses", + "parameters": [ + { + "name": "provider", + "in": "path", + "description": "Kanban provider name (e.g. jira, linear, github)", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "project_key", + "in": "path", + "description": "Provider project/team key", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Workflow statuses/columns for the project", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListKanbanStatusesResponse" + } + } + } + }, + "400": { + "description": "Unknown provider/project" + }, + "500": { + "description": "Failed to fetch statuses from provider" + } + } + } + }, "/api/v1/llm-tools": { "get": { "tags": [ @@ -4093,6 +4173,33 @@ "github" ] }, + "KanbanStatusMapping": { + "type": "object", + "description": "Explicit mapping from operator's strict todo/doing/done states to the\nexternal board's column/status names.\n\nDrives bidirectional sync: issues are pulled from the `todo` column,\npushed to `doing` when a ticket is claimed, to `done` when completed, and\nback to `todo` when requeued. Unset fields fall back per-transition\n(`doing` → \"In Progress\", `done` → \"Done\"); requeue only pushes when\n`todo` is explicitly mapped.", + "properties": { + "doing": { + "type": [ + "string", + "null" + ], + "description": "External column for operator \"doing\" (claimed/launched tickets)" + }, + "done": { + "type": [ + "string", + "null" + ], + "description": "External column for operator \"done\" (completed tickets)" + }, + "todo": { + "type": [ + "string", + "null" + ], + "description": "External column for operator \"todo\" (queued work; also the pull source)" + } + } + }, "KanbanSyncResponse": { "type": "object", "description": "Response for kanban sync operations", @@ -4455,6 +4562,68 @@ } } }, + "ListKanbanStatusesRequest": { + "type": "object", + "description": "Request to list workflow statuses/columns for a specific project using\nephemeral creds (onboarding wizard — before any config is persisted).", + "required": [ + "provider", + "project_key" + ], + "properties": { + "github": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/GithubCredentials" + } + ] + }, + "jira": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/JiraCredentials" + } + ] + }, + "linear": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LinearCredentials" + } + ] + }, + "project_key": { + "type": "string", + "description": "Project/team key to list statuses for" + }, + "provider": { + "$ref": "#/components/schemas/KanbanProviderKind" + } + } + }, + "ListKanbanStatusesResponse": { + "type": "object", + "description": "Response wrapper for list-statuses: the external board's column names,\nin board order, for populating todo/doing/done mapping dropdowns.", + "required": [ + "statuses" + ], + "properties": { + "statuses": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "LlmToolsResponse": { "type": "object", "description": "Response listing detected LLM tools", @@ -6242,6 +6411,17 @@ "type": "string", "description": "`GraphQL` project node ID (e.g., `PVT_kwDOABcdefg`)" }, + "status_mapping": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/KanbanStatusMapping", + "description": "Mapping of operator todo/doing/done to external board columns" + } + ] + }, "sync_user_id": { "type": "string", "description": "Numeric GitHub `databaseId` of the user whose items to sync" @@ -6271,6 +6451,17 @@ "project_key": { "type": "string" }, + "status_mapping": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/KanbanStatusMapping", + "description": "Mapping of operator todo/doing/done to external board columns" + } + ] + }, "sync_user_id": { "type": "string" } @@ -6352,6 +6543,17 @@ "project_key": { "type": "string" }, + "status_mapping": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/KanbanStatusMapping", + "description": "Mapping of operator todo/doing/done to external board columns" + } + ] + }, "sync_user_id": { "type": "string" }, diff --git a/docs/startup/index.md b/docs/startup/index.md index 4853e041..fbdf7994 100644 --- a/docs/startup/index.md +++ b/docs/startup/index.md @@ -198,8 +198,8 @@ The default criteria cover formatting, tests, and lint checks. You can customize Create startup tickets to help initialize your projects: - **ASSESS tickets**: Scan projects for catalog-info.yaml, create if missing -- **AGENT-SETUP tickets**: Configure Claude agents for each project -- **PROJECT-INIT tickets**: Run both ASSESS and AGENT-SETUP for each project +- **AGENT_SETUP tickets**: Configure Claude agents for each project +- **PROJECT_INIT tickets**: Run both ASSESS and AGENT_SETUP for each project These tickets are optional and help automate common setup tasks. diff --git a/src/api/kanban_sync.rs b/src/api/kanban_sync.rs index ae0e9e37..2399f93c 100644 --- a/src/api/kanban_sync.rs +++ b/src/api/kanban_sync.rs @@ -58,6 +58,18 @@ impl KanbanBidirectionalSync { } } + /// Called when a ticket is returned to the queue (doing → todo). Pushes the + /// mapped "todo" status to the provider — no-op unless `status_mapping.todo` + /// is explicitly configured (there is no safe universal default column). + pub async fn on_ticket_requeued(&self, ticket: &Ticket) { + if let Some((provider, sync_cfg)) = self.resolve(ticket) { + if let Some(status) = todo_status(&sync_cfg) { + let status = status.to_string(); + self.push_status(ticket, &*provider, &status).await; + } + } + } + /// Called when a step completes. Appends an activity log entry to the upstream issue. pub async fn on_step_completed( &self, @@ -235,20 +247,20 @@ impl KanbanBidirectionalSync { } } +fn todo_status(sync_cfg: &ProjectSyncConfig) -> Option<&str> { + sync_cfg.status_mapping.todo.as_deref() +} + fn doing_status(sync_cfg: &ProjectSyncConfig) -> &str { sync_cfg - .sync_statuses - .first() - .map(String::as_str) + .status_mapping + .doing + .as_deref() .unwrap_or("In Progress") } fn done_status(sync_cfg: &ProjectSyncConfig) -> &str { - sync_cfg - .sync_statuses - .last() - .map(String::as_str) - .unwrap_or("Done") + sync_cfg.status_mapping.done.as_deref().unwrap_or("Done") } fn build_create_request(ticket: &Ticket, sync_cfg: &ProjectSyncConfig) -> CreateIssueRequest { @@ -268,46 +280,97 @@ fn build_create_request(ticket: &Ticket, sync_cfg: &ProjectSyncConfig) -> Create #[cfg(test)] mod tests { use super::*; + use crate::config::KanbanStatusMapping; - fn make_sync_cfg(statuses: Vec<&str>) -> ProjectSyncConfig { + fn make_sync_cfg( + todo: Option<&str>, + doing: Option<&str>, + done: Option<&str>, + ) -> ProjectSyncConfig { ProjectSyncConfig { - sync_statuses: statuses.into_iter().map(ToString::to_string).collect(), + status_mapping: KanbanStatusMapping { + todo: todo.map(ToString::to_string), + doing: doing.map(ToString::to_string), + done: done.map(ToString::to_string), + }, bidirectional: true, ..Default::default() } } #[test] - fn test_doing_status_from_sync_statuses() { - let cfg = make_sync_cfg(vec!["Started", "In Review", "Completed"]); + fn test_doing_status_from_mapping() { + let cfg = make_sync_cfg(Some("Backlog"), Some("Started"), Some("Completed")); assert_eq!(doing_status(&cfg), "Started"); } #[test] - fn test_done_status_from_sync_statuses() { - let cfg = make_sync_cfg(vec!["Started", "In Review", "Completed"]); + fn test_done_status_from_mapping() { + let cfg = make_sync_cfg(Some("Backlog"), Some("Started"), Some("Completed")); assert_eq!(done_status(&cfg), "Completed"); } #[test] - fn test_doing_status_default() { - let cfg = make_sync_cfg(vec![]); + fn test_doing_status_default_when_unmapped() { + let cfg = make_sync_cfg(None, None, None); assert_eq!(doing_status(&cfg), "In Progress"); } #[test] - fn test_done_status_default() { - let cfg = make_sync_cfg(vec![]); + fn test_done_status_default_when_unmapped() { + let cfg = make_sync_cfg(None, None, None); assert_eq!(done_status(&cfg), "Done"); } + #[test] + fn test_todo_status_none_without_mapping() { + // No fallback: requeue push must stay silent when todo is unmapped. + let cfg = make_sync_cfg(None, Some("Started"), Some("Completed")); + assert_eq!(todo_status(&cfg), None); + } + + #[test] + fn test_todo_status_some_with_mapping() { + let cfg = make_sync_cfg(Some("Backlog"), None, None); + assert_eq!(todo_status(&cfg), Some("Backlog")); + } + #[test] fn test_skips_non_bidirectional_projects() { - let mut cfg = make_sync_cfg(vec!["In Progress", "Done"]); + let mut cfg = make_sync_cfg(Some("To Do"), Some("In Progress"), Some("Done")); cfg.bidirectional = false; // sync service would not resolve a provider for this config // (we test the flag is respected by verifying bidirectional=false // is explicitly handled in resolve()) assert!(!cfg.bidirectional); } + + #[tokio::test] + async fn test_on_ticket_requeued_noop_without_todo_mapping() { + // Ticket without external linkage + empty config: resolve() returns + // None and the call must be a silent no-op. + let sync = KanbanBidirectionalSync::new(Arc::new(crate::config::Config::default())); + let ticket = Ticket { + filename: String::new(), + filepath: String::new(), + timestamp: String::new(), + ticket_type: "FIX".to_string(), + project: "demo".to_string(), + id: "FIX-1".to_string(), + summary: String::new(), + priority: String::new(), + status: "queued".to_string(), + step: String::new(), + content: String::new(), + sessions: std::collections::HashMap::default(), + llm_task: crate::queue::LlmTask::default(), + worktree_path: None, + branch: None, + external_id: None, + external_url: None, + external_provider: None, + step_delegators: std::collections::HashMap::default(), + }; + sync.on_ticket_requeued(&ticket).await; + } } diff --git a/src/app/kanban_onboarding.rs b/src/app/kanban_onboarding.rs index e577177d..f254aea4 100644 --- a/src/app/kanban_onboarding.rs +++ b/src/app/kanban_onboarding.rs @@ -248,6 +248,8 @@ impl App { api_key_env: api_key_env.clone(), project_key: project_key.clone(), sync_user_id: account_id, + // TUI wizard has no column-mapping step; map via web/VS Code config UI. + status_mapping: None, }), linear: None, github: None, @@ -297,6 +299,7 @@ impl App { api_key_env: api_key_env.clone(), project_key: project_key.clone(), sync_user_id: user_id, + status_mapping: None, }), github: None, }; diff --git a/src/app/session.rs b/src/app/session.rs index 5bb5f678..53020e29 100644 --- a/src/app/session.rs +++ b/src/app/session.rs @@ -131,7 +131,9 @@ impl App { .get_in_progress_ticket(ticket_id)? .ok_or_else(|| anyhow::anyhow!("Ticket not found: {ticket_id}"))?; - // Move ticket back to queue + // Move ticket back to queue. Upstream kanban requeue-push (doing→todo) + // is wired on the MCP return_to_queue path; this sync recovery path + // stays local-only to avoid blocking on provider calls. queue.return_to_queue(&ticket)?; // Remove agent from state diff --git a/src/app/tickets.rs b/src/app/tickets.rs index 8a699f45..b9afe9dc 100644 --- a/src/app/tickets.rs +++ b/src/app/tickets.rs @@ -155,7 +155,7 @@ impl App { for project in &discovered_projects { let project_path = projects_path.join(project); - // ASSESS or PROJECT-INIT creates assess tickets + // ASSESS or PROJECT_INIT creates assess tickets if startup_tickets.contains(&"assess".to_string()) || startup_tickets.contains(&"project_init".to_string()) { @@ -189,7 +189,7 @@ impl App { } } - // AGENT-SETUP or PROJECT-INIT creates agent tickets + // AGENT_SETUP or PROJECT_INIT creates agent tickets if startup_tickets.contains(&"agent_setup".to_string()) || startup_tickets.contains(&"project_init".to_string()) { @@ -203,12 +203,12 @@ impl App { tracing::info!( created = ?result.created, project = %project, - "Created AGENT-SETUP startup tickets" + "Created AGENT_SETUP startup tickets" ); } } Err(e) => { - tracing::warn!(project = %project, error = %e, "Failed to create AGENT-SETUP tickets"); + tracing::warn!(project = %project, error = %e, "Failed to create AGENT_SETUP tickets"); } } } diff --git a/src/collections/elves_overnight/collection.json b/src/collections/elves_overnight/collection.json index ccde1304..dda70c50 100644 --- a/src/collections/elves_overnight/collection.json +++ b/src/collections/elves_overnight/collection.json @@ -8,20 +8,69 @@ "author": "Aigora", "url": "https://github.com/aigorahub/elves", "license": "MIT", - "tags": ["agentic-loop", "overnight", "batch", "elves"], + "tier": "community", + "tags": [ + "agentic-loop", + "overnight", + "batch", + "elves" + ], "issue_types": [ - { "key": "ELVSTAGE", "schema_path": "ELVSTAGE.json", "template_path": "ELVSTAGE.md" }, - { "key": "ELVBATCH", "schema_path": "ELVBATCH.json", "template_path": "ELVBATCH.md" }, - { "key": "LANDPR", "schema_path": "LANDPR.json", "template_path": "LANDPR.md" }, - { "key": "ELVRPT", "schema_path": "ELVRPT.json", "template_path": "ELVRPT.md" } + { + "key": "ELVSTAGE", + "schema_path": "ELVSTAGE.json", + "template_path": "ELVSTAGE.md" + }, + { + "key": "ELVBATCH", + "schema_path": "ELVBATCH.json", + "template_path": "ELVBATCH.md" + }, + { + "key": "LANDPR", + "schema_path": "LANDPR.json", + "template_path": "LANDPR.md" + }, + { + "key": "ELVRPT", + "schema_path": "ELVRPT.json", + "template_path": "ELVRPT.md" + } ], "workflow_hints": { "loop_kind": "staged_long_running_batch_loop", - "memory_surfaces": ["docs/elves/survival-guide.md", "docs/elves/execution-log.md", "docs/elves/learnings.md", ".elves-session.json", "PR comments and checks"], - "review_gates": ["stage_review", "batch_validation", "fresh_review", "judge_verdict", "human_land_gate"], - "external_tools": ["git", "gh", "project test command", "optional browser or visual review command"], - "stop_conditions": ["batch complete and checkpointed", "validation cannot be repaired safely", "PR has unresolved requested changes", "time/risk budget exhausted"], + "memory_surfaces": [ + "docs/elves/survival-guide.md", + "docs/elves/execution-log.md", + "docs/elves/learnings.md", + ".elves-session.json", + "PR comments and checks" + ], + "review_gates": [ + "stage_review", + "batch_validation", + "fresh_review", + "judge_verdict", + "human_land_gate" + ], + "external_tools": [ + "git", + "gh", + "project test command", + "optional browser or visual review command" + ], + "stop_conditions": [ + "batch complete and checkpointed", + "validation cannot be repaired safely", + "PR has unresolved requested changes", + "time/risk budget exhausted" + ], "runner_semantics": "prompt_driven" }, - "default_selected": ["ELVSTAGE", "ELVBATCH", "LANDPR", "ELVRPT"] + "default_selected": [ + "ELVSTAGE", + "ELVBATCH", + "LANDPR", + "ELVRPT" + ] } diff --git a/src/collections/fetch.rs b/src/collections/fetch.rs index 7052c43a..fdcb6516 100644 --- a/src/collections/fetch.rs +++ b/src/collections/fetch.rs @@ -333,6 +333,7 @@ mod tests { schema_checksum: schema_sum.to_string(), template_path: None, template_checksum: None, + workflow_preview_path: None, } } diff --git a/src/collections/jr_orchestration/collection.json b/src/collections/jr_orchestration/collection.json index 7516ca94..25f22e6d 100644 --- a/src/collections/jr_orchestration/collection.json +++ b/src/collections/jr_orchestration/collection.json @@ -8,21 +8,70 @@ "author": "snapwich", "url": "https://github.com/snapwich/jr", "license": "MIT", - "tags": ["agentic-loop", "feature-graph", "review", "jr"], + "tier": "community", + "tags": [ + "agentic-loop", + "feature-graph", + "review", + "jr" + ], "issue_types": [ - { "key": "JRPLAN", "schema_path": "JRPLAN.json", "template_path": "JRPLAN.md" }, - { "key": "JRFEAT", "schema_path": "JRFEAT.json", "template_path": "JRFEAT.md" }, - { "key": "JRTASK", "schema_path": "JRTASK.json", "template_path": "JRTASK.md" }, - { "key": "JRREV", "schema_path": "JRREV.json", "template_path": "JRREV.md" }, - { "key": "JRREBASE", "schema_path": "JRREBASE.json", "template_path": "JRREBASE.md" } + { + "key": "JRPLAN", + "schema_path": "JRPLAN.json", + "template_path": "JRPLAN.md" + }, + { + "key": "JRFEAT", + "schema_path": "JRFEAT.json", + "template_path": "JRFEAT.md" + }, + { + "key": "JRTASK", + "schema_path": "JRTASK.json", + "template_path": "JRTASK.md" + }, + { + "key": "JRREV", + "schema_path": "JRREV.json", + "template_path": "JRREV.md" + }, + { + "key": "JRREBASE", + "schema_path": "JRREBASE.json", + "template_path": "JRREBASE.md" + } ], "workflow_hints": { "loop_kind": "feature_task_review_graph", - "memory_surfaces": [".tickets/jr/{{ id }}/plan.md", ".tickets/jr/{{ feature_id }}/handoff.md", "ticket parent/dependency notes"], - "review_gates": ["code_review", "architect_review", "human_pr_review"], - "external_tools": ["git", "gh", "project test command"], - "stop_conditions": ["feature PR ready for human review", "review changes requested", "blocked dependency documented", "review escalated to human after repeated changes (operator stops; no auto-handoff)"], + "memory_surfaces": [ + ".tickets/jr/{{ id }}/plan.md", + ".tickets/jr/{{ feature_id }}/handoff.md", + "ticket parent/dependency notes" + ], + "review_gates": [ + "code_review", + "architect_review", + "human_pr_review" + ], + "external_tools": [ + "git", + "gh", + "project test command" + ], + "stop_conditions": [ + "feature PR ready for human review", + "review changes requested", + "blocked dependency documented", + "review escalated to human after repeated changes (operator stops; no auto-handoff)" + ], "runner_semantics": "prompt_driven" }, - "default_selected": ["JRPLAN", "JRFEAT", "JRTASK", "JRREV", "JRREBASE"] + "default_selected": [ + "JRPLAN", + "JRFEAT", + "JRTASK", + "JRREV", + "JRREBASE" + ] } diff --git a/src/collections/manifest.rs b/src/collections/manifest.rs index 24882f47..b4e57bb1 100644 --- a/src/collections/manifest.rs +++ b/src/collections/manifest.rs @@ -11,11 +11,27 @@ //! embedded in the binary. use serde::{Deserialize, Serialize}; +use std::collections::HashMap; /// Current manifest schema version. Manifests with an unknown version are /// rejected by the fetcher and fall back to the embedded copy. pub const SCHEMA_VERSION: u32 = 1; +/// Provenance tier of a collection: who authored and maintains it. +/// +/// Orthogonal to distribution — curated community-authored collections may +/// ship embedded in the binary, while community submissions under +/// `collections/community/` are hosted-only. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CollectionTier { + /// Authored and maintained by the operator project. + #[default] + Official, + /// Community-authored (attribution via `author`/`url`/`license`). + Community, +} + /// Top-level index listing the available collections. This is what the /// configurable `collections_manifest_url` points at. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -50,6 +66,13 @@ pub struct CollectionIndexEntry { pub manifest_path: String, /// SHA-256 (lowercase hex) of the referenced `collection.json` bytes. pub checksum: String, + /// Provenance tier (defaults to official for older indexes). + #[serde(default)] + pub tier: CollectionTier, + /// Path to the collection's docs page, relative to the site root + /// `/collections/` (e.g. `dev_kanban/`). Informational, for deep links. + #[serde(default)] + pub docs_path: Option, } /// A single collection manifest (`collection.json`). @@ -88,6 +111,12 @@ pub struct CollectionManifest { /// Compatibility constraints. #[serde(default)] pub compatibility: Option, + /// Provenance tier (defaults to official for older manifests). + #[serde(default)] + pub tier: CollectionTier, + /// Descriptive kanban onboarding defaults (v1: metadata only). + #[serde(default)] + pub kanban_defaults: Option, /// Issue types in this collection (display order). pub issue_types: Vec, /// Descriptive workflow hints (v1: metadata only, no execution behavior). @@ -128,6 +157,22 @@ pub struct IssueTypeEntry { /// SHA-256 (lowercase hex) of the markdown template bytes, if present. #[serde(default)] pub template_checksum: Option, + /// Path to a pre-generated workflow preview (`.js`), relative to the + /// manifest. Filled by the docs producer for visualization; excluded + /// from checksum derivation. + #[serde(default)] + pub workflow_preview_path: Option, +} + +/// Descriptive kanban onboarding defaults for a collection. +/// +/// v1 is metadata only: suggestions seed the onboarding mapping UI but do +/// not drive sync behavior. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct KanbanDefaults { + /// Suggested provider issue-type NAME -> collection issuetype key. + #[serde(default)] + pub suggested_type_mappings: HashMap, } /// Descriptive metadata about a collection's intended agentic loop shape. @@ -284,4 +329,115 @@ mod tests { assert_eq!(m.id, m2.id); assert_eq!(m.type_keys(), m2.type_keys()); } + + #[test] + fn test_manifest_tier_defaults_to_official() { + // Older manifests without a tier field stay valid and are official. + let m = CollectionManifest::from_json(DEV_KANBAN_JSON).unwrap(); + assert_eq!(m.tier, CollectionTier::Official); + } + + #[test] + fn test_manifest_tier_community_round_trip() { + let json = r#"{ + "schema_version": 1, + "id": "gastown", + "name": "Gastown", + "tier": "community", + "author": "someone", + "issue_types": [ + {"key": "TASK", "schema_path": "TASK.json"} + ] + }"#; + let m = CollectionManifest::from_json(json).unwrap(); + assert_eq!(m.tier, CollectionTier::Community); + let serialized = m.to_json().unwrap(); + assert!(serialized.contains("\"tier\": \"community\"")); + let m2 = CollectionManifest::from_json(&serialized).unwrap(); + assert_eq!(m2.tier, CollectionTier::Community); + } + + #[test] + fn test_index_entry_tier_and_docs_path_default() { + let json = r#"{ + "schema_version": 1, + "collections": [ + {"id": "simple", "name": "Simple", "manifest_path": "simple/collection.json", "checksum": "abc"} + ] + }"#; + let index: CollectionIndex = serde_json::from_str(json).unwrap(); + let entry = &index.collections[0]; + assert_eq!(entry.tier, CollectionTier::Official); + assert!(entry.docs_path.is_none()); + } + + #[test] + fn test_index_entry_tier_and_docs_path_round_trip() { + let json = r#"{ + "schema_version": 1, + "collections": [ + {"id": "gastown", "name": "Gastown", "manifest_path": "gastown/collection.json", "checksum": "abc", "tier": "community", "docs_path": "gastown/"} + ] + }"#; + let index: CollectionIndex = serde_json::from_str(json).unwrap(); + let entry = &index.collections[0]; + assert_eq!(entry.tier, CollectionTier::Community); + assert_eq!(entry.docs_path.as_deref(), Some("gastown/")); + let round: CollectionIndex = + serde_json::from_str(&serde_json::to_string(&index).unwrap()).unwrap(); + assert_eq!(round.collections[0].tier, CollectionTier::Community); + assert_eq!(round.collections[0].docs_path.as_deref(), Some("gastown/")); + } + + #[test] + fn test_issue_type_entry_workflow_preview_path_defaults_to_none() { + let m = CollectionManifest::from_json(DEV_KANBAN_JSON).unwrap(); + assert!(m.issue_types[0].workflow_preview_path.is_none()); + } + + #[test] + fn test_issue_type_entry_workflow_preview_path_round_trip() { + let json = r#"{ + "key": "TASK", + "schema_path": "TASK.json", + "workflow_preview_path": "TASK.preview.workflow.js" + }"#; + let entry: IssueTypeEntry = serde_json::from_str(json).unwrap(); + assert_eq!( + entry.workflow_preview_path.as_deref(), + Some("TASK.preview.workflow.js") + ); + let round: IssueTypeEntry = + serde_json::from_str(&serde_json::to_string(&entry).unwrap()).unwrap(); + assert_eq!(round.workflow_preview_path, entry.workflow_preview_path); + } + + #[test] + fn test_kanban_defaults_default_to_none() { + let m = CollectionManifest::from_json(DEV_KANBAN_JSON).unwrap(); + assert!(m.kanban_defaults.is_none()); + } + + #[test] + fn test_kanban_defaults_round_trip() { + let json = r#"{ + "schema_version": 1, + "id": "dev_kanban", + "name": "Dev Kanban", + "issue_types": [ + {"key": "TASK", "schema_path": "TASK.json"} + ], + "kanban_defaults": { + "suggested_type_mappings": {"Story": "FEAT", "Bug": "FIX"} + } + }"#; + let m = CollectionManifest::from_json(json).unwrap(); + let defaults = m.kanban_defaults.as_ref().expect("kanban_defaults"); + assert_eq!( + defaults.suggested_type_mappings.get("Story"), + Some(&"FEAT".to_string()) + ); + let m2 = CollectionManifest::from_json(&m.to_json().unwrap()).unwrap(); + assert_eq!(m2.kanban_defaults.unwrap().suggested_type_mappings.len(), 2); + } } diff --git a/src/collections/mod.rs b/src/collections/mod.rs index 65ecceaf..69ee0e47 100644 --- a/src/collections/mod.rs +++ b/src/collections/mod.rs @@ -6,6 +6,11 @@ pub mod fetch; pub mod manifest; +// Community-collection validation: consumed by the community_collections +// integration test today and the docs generator in an upcoming change; +// nothing in the bin calls it, so its items read as dead there. +#[allow(dead_code)] +pub mod validate; /// A single embedded issuetype with JSON schema and markdown template #[derive(Debug, Clone)] @@ -96,7 +101,7 @@ pub static EMBEDDED_COLLECTIONS: &[EmbeddedCollection] = &[ }, ], }, - // Operator collection: ASSESS, SYNC, INIT, AGENT-SETUP, PROJECT-INIT + // Operator collection: ASSESS, SYNC, INIT, AGENT_SETUP, PROJECT_INIT EmbeddedCollection { name: "operator", manifest: include_str!("operator/collection.json"), @@ -117,64 +122,20 @@ pub static EMBEDDED_COLLECTIONS: &[EmbeddedCollection] = &[ template_md: include_str!("operator/INIT.md"), }, EmbeddedIssueType { - key: "AGENT-SETUP", - schema_json: include_str!("operator/AGENT-SETUP.json"), - template_md: include_str!("operator/AGENT-SETUP.md"), - }, - EmbeddedIssueType { - key: "PROJECT-INIT", - schema_json: include_str!("operator/PROJECT-INIT.json"), - template_md: include_str!("operator/PROJECT-INIT.md"), - }, - ], - }, - // Full collection: All 8 issuetypes - EmbeddedCollection { - name: "full", - manifest: include_str!("full/collection.json"), - issuetypes: &[ - EmbeddedIssueType { - key: "TASK", - schema_json: include_str!("full/TASK.json"), - template_md: include_str!("full/TASK.md"), - }, - EmbeddedIssueType { - key: "FEAT", - schema_json: include_str!("full/FEAT.json"), - template_md: include_str!("full/FEAT.md"), - }, - EmbeddedIssueType { - key: "FIX", - schema_json: include_str!("full/FIX.json"), - template_md: include_str!("full/FIX.md"), - }, - EmbeddedIssueType { - key: "SPIKE", - schema_json: include_str!("full/SPIKE.json"), - template_md: include_str!("full/SPIKE.md"), - }, - EmbeddedIssueType { - key: "INV", - schema_json: include_str!("full/INV.json"), - template_md: include_str!("full/INV.md"), - }, - EmbeddedIssueType { - key: "ASSESS", - schema_json: include_str!("full/ASSESS.json"), - template_md: include_str!("full/ASSESS.md"), - }, - EmbeddedIssueType { - key: "SYNC", - schema_json: include_str!("full/SYNC.json"), - template_md: include_str!("full/SYNC.md"), + key: "AGENT_SETUP", + schema_json: include_str!("operator/AGENT_SETUP.json"), + template_md: include_str!("operator/AGENT_SETUP.md"), }, EmbeddedIssueType { - key: "INIT", - schema_json: include_str!("full/INIT.json"), - template_md: include_str!("full/INIT.md"), + key: "PROJECT_INIT", + schema_json: include_str!("operator/PROJECT_INIT.json"), + template_md: include_str!("operator/PROJECT_INIT.md"), }, ], }, + // NOTE: `full` is deliberately not embedded as a collection. Its files + // (src/collections/full/) remain the source for the builtin + // TemplateType glyph/color maps in src/templates/mod.rs. // Ralph Loop collection: PRD, STORY, RLOOP EmbeddedCollection { name: "ralph_loop", @@ -309,7 +270,52 @@ mod tests { #[test] fn test_embedded_collections_count() { - assert_eq!(EMBEDDED_COLLECTIONS.len(), 8); + assert_eq!(EMBEDDED_COLLECTIONS.len(), 7); + } + + #[test] + fn test_full_collection_not_embedded() { + // `full` was demoted: its files remain as the static glyph/color-map + // source (src/templates/mod.rs) but it is not offered as a collection. + assert!(get_embedded_collection("full").is_none()); + } + + #[test] + fn test_embedded_tiers_match_provenance() { + use manifest::CollectionTier; + for collection in EMBEDDED_COLLECTIONS { + let m = collection.manifest_parsed().unwrap(); + let expected = match collection.name { + "ralph_loop" | "jr_orchestration" | "elves_overnight" => CollectionTier::Community, + _ => CollectionTier::Official, + }; + assert_eq!(m.tier, expected, "tier mismatch for {}", collection.name); + } + } + + #[test] + fn test_embedded_community_collections_have_attribution() { + use manifest::CollectionTier; + for collection in EMBEDDED_COLLECTIONS { + let m = collection.manifest_parsed().unwrap(); + if m.tier == CollectionTier::Community { + assert!(m.author.is_some(), "{} needs an author", collection.name); + assert!(m.url.is_some(), "{} needs a url", collection.name); + assert!(m.license.is_some(), "{} needs a license", collection.name); + } + } + } + + #[test] + fn test_all_embedded_collections_have_workflow_hints() { + for collection in EMBEDDED_COLLECTIONS { + let m = collection.manifest_parsed().unwrap(); + assert!( + m.workflow_hints.is_some(), + "{} needs workflow_hints so the docs hints table renders", + collection.name + ); + } } #[test] @@ -330,10 +336,6 @@ mod tests { assert_eq!(operator.name, "operator"); assert_eq!(operator.issuetypes.len(), 5); - let full = get_embedded_collection("full").unwrap(); - assert_eq!(full.name, "full"); - assert_eq!(full.issuetypes.len(), 8); - let ralph = get_embedded_collection("ralph_loop").unwrap(); assert_eq!(ralph.name, "ralph_loop"); assert_eq!(ralph.issuetypes.len(), 3); @@ -359,7 +361,7 @@ mod tests { assert!(names.contains(&"dev_kanban")); assert!(names.contains(&"devops_kanban")); assert!(names.contains(&"operator")); - assert!(names.contains(&"full")); + assert!(!names.contains(&"full")); } #[test] @@ -391,9 +393,12 @@ mod tests { } #[test] - fn test_agentic_loop_collections_have_valid_issuetypes() { - for name in ["ralph_loop", "jr_orchestration", "elves_overnight"] { - let collection = get_embedded_collection(name).unwrap(); + fn test_all_embedded_collections_have_valid_issuetypes() { + // Every embedded issuetype must pass validation, otherwise the disk + // loader silently drops it when the collection is scaffolded to + // .tickets/templates/ and reloaded. + for collection in EMBEDDED_COLLECTIONS { + let name = collection.name; for issue_type in collection.issuetypes { let schema = TemplateSchema::from_json(issue_type.schema_json) .unwrap_or_else(|e| panic!("{name}/{} schema must parse: {e}", issue_type.key)); diff --git a/src/collections/operator/AGENT-SETUP.json b/src/collections/operator/AGENT_SETUP.json similarity index 97% rename from src/collections/operator/AGENT-SETUP.json rename to src/collections/operator/AGENT_SETUP.json index 3826b811..0ff04e9b 100644 --- a/src/collections/operator/AGENT-SETUP.json +++ b/src/collections/operator/AGENT_SETUP.json @@ -1,6 +1,6 @@ { "$schema": "../../schemas/issuetype_schema.json", - "key": "AGENT-SETUP", + "key": "AGENT_SETUP", "name": "Agent Setup", "description": "Set up Claude agent configuration for a project", "mode": "paired", @@ -32,7 +32,7 @@ "name": "agent_tool", "description": "Target agent tool (claude, aider, etc.)", "type": "enum", - "values": ["claude", "aider", "gemini"], + "options": ["claude", "aider", "gemini"], "required": true, "default": "claude", "display_order": 2 diff --git a/src/collections/operator/AGENT-SETUP.md b/src/collections/operator/AGENT_SETUP.md similarity index 100% rename from src/collections/operator/AGENT-SETUP.md rename to src/collections/operator/AGENT_SETUP.md diff --git a/docs/collections/operator/PROJECT-INIT.json b/src/collections/operator/PROJECT_INIT.json similarity index 98% rename from docs/collections/operator/PROJECT-INIT.json rename to src/collections/operator/PROJECT_INIT.json index 66c72848..b9f5aafb 100644 --- a/docs/collections/operator/PROJECT-INIT.json +++ b/src/collections/operator/PROJECT_INIT.json @@ -1,6 +1,6 @@ { "$schema": "../../schemas/issuetype_schema.json", - "key": "PROJECT-INIT", + "key": "PROJECT_INIT", "name": "Project Initialization", "description": "Initialize project with Operator conventions", "mode": "autonomous", diff --git a/src/collections/operator/PROJECT-INIT.md b/src/collections/operator/PROJECT_INIT.md similarity index 100% rename from src/collections/operator/PROJECT-INIT.md rename to src/collections/operator/PROJECT_INIT.md diff --git a/src/collections/operator/collection.json b/src/collections/operator/collection.json index 50a914d6..cb46bacf 100644 --- a/src/collections/operator/collection.json +++ b/src/collections/operator/collection.json @@ -8,13 +8,58 @@ "author": "Operator!", "url": "https://github.com/untra/operator", "license": "MIT", - "tags": ["builtin", "automation"], + "tags": [ + "builtin", + "automation" + ], "issue_types": [ - { "key": "ASSESS", "schema_path": "ASSESS.json", "template_path": "ASSESS.md" }, - { "key": "SYNC", "schema_path": "SYNC.json", "template_path": "SYNC.md" }, - { "key": "INIT", "schema_path": "INIT.json", "template_path": "INIT.md" }, - { "key": "AGENT-SETUP", "schema_path": "AGENT-SETUP.json", "template_path": "AGENT-SETUP.md" }, - { "key": "PROJECT-INIT", "schema_path": "PROJECT-INIT.json", "template_path": "PROJECT-INIT.md" } + { + "key": "ASSESS", + "schema_path": "ASSESS.json", + "template_path": "ASSESS.md" + }, + { + "key": "SYNC", + "schema_path": "SYNC.json", + "template_path": "SYNC.md" + }, + { + "key": "INIT", + "schema_path": "INIT.json", + "template_path": "INIT.md" + }, + { + "key": "AGENT_SETUP", + "schema_path": "AGENT_SETUP.json", + "template_path": "AGENT_SETUP.md" + }, + { + "key": "PROJECT_INIT", + "schema_path": "PROJECT_INIT.json", + "template_path": "PROJECT_INIT.md" + } ], - "default_selected": ["ASSESS", "SYNC", "INIT"] + "workflow_hints": { + "loop_kind": "single_pass", + "memory_surfaces": [ + "ticket", + "catalog-info.yaml", + ".claude/agents/" + ], + "review_gates": [ + "human" + ], + "external_tools": [ + "git" + ], + "stop_conditions": [ + "setup_artifacts_written" + ], + "runner_semantics": "prompt_driven" + }, + "default_selected": [ + "ASSESS", + "SYNC", + "INIT" + ] } diff --git a/src/collections/ralph_loop/collection.json b/src/collections/ralph_loop/collection.json index 30b1b49c..8f951ba7 100644 --- a/src/collections/ralph_loop/collection.json +++ b/src/collections/ralph_loop/collection.json @@ -8,19 +8,57 @@ "author": "snarktank", "url": "https://github.com/snarktank/ralph", "license": "MIT", - "tags": ["agentic-loop", "prd", "stories", "ralph"], + "tier": "community", + "tags": [ + "agentic-loop", + "prd", + "stories", + "ralph" + ], "issue_types": [ - { "key": "PRD", "schema_path": "PRD.json", "template_path": "PRD.md" }, - { "key": "STORY", "schema_path": "STORY.json", "template_path": "STORY.md" }, - { "key": "RLOOP", "schema_path": "RLOOP.json", "template_path": "RLOOP.md" } + { + "key": "PRD", + "schema_path": "PRD.json", + "template_path": "PRD.md" + }, + { + "key": "STORY", + "schema_path": "STORY.json", + "template_path": "STORY.md" + }, + { + "key": "RLOOP", + "schema_path": "RLOOP.json", + "template_path": "RLOOP.md" + } ], "workflow_hints": { "loop_kind": "fresh_context_story_loop", - "memory_surfaces": [".tickets/workflows/{{ id }}/prd.json", ".tickets/workflows/{{ id }}/progress.txt", "AGENTS.md"], - "review_gates": ["plan_review", "test_suite", "story_completion_check"], - "external_tools": ["git", "project test command"], - "stop_conditions": ["all stories have passes=true", "blocked story documented", "quality gates fail repeatedly", "max_iterations reached (advisory; outer story loop is operator-queue-driven)"], + "memory_surfaces": [ + ".tickets/workflows/{{ id }}/prd.json", + ".tickets/workflows/{{ id }}/progress.txt", + "AGENTS.md" + ], + "review_gates": [ + "plan_review", + "test_suite", + "story_completion_check" + ], + "external_tools": [ + "git", + "project test command" + ], + "stop_conditions": [ + "all stories have passes=true", + "blocked story documented", + "quality gates fail repeatedly", + "max_iterations reached (advisory; outer story loop is operator-queue-driven)" + ], "runner_semantics": "prompt_driven" }, - "default_selected": ["PRD", "STORY", "RLOOP"] + "default_selected": [ + "PRD", + "STORY", + "RLOOP" + ] } diff --git a/src/collections/simple/collection.json b/src/collections/simple/collection.json index cfdb3ffc..dfa5151a 100644 --- a/src/collections/simple/collection.json +++ b/src/collections/simple/collection.json @@ -8,9 +8,29 @@ "author": "Operator!", "url": "https://github.com/untra/operator", "license": "MIT", - "tags": ["builtin"], + "tags": [ + "builtin" + ], "issue_types": [ - { "key": "TASK", "schema_path": "TASK.json", "template_path": "TASK.md" } + { + "key": "TASK", + "schema_path": "TASK.json", + "template_path": "TASK.md" + } ], - "default_selected": ["TASK"] + "workflow_hints": { + "loop_kind": "single_pass", + "memory_surfaces": [ + "ticket" + ], + "review_gates": [], + "external_tools": [], + "stop_conditions": [ + "task_complete" + ], + "runner_semantics": "prompt_driven" + }, + "default_selected": [ + "TASK" + ] } diff --git a/src/collections/validate.rs b/src/collections/validate.rs new file mode 100644 index 00000000..e32f538a --- /dev/null +++ b/src/collections/validate.rs @@ -0,0 +1,377 @@ +//! Validation for shareable collection directories. +//! +//! Community collections (`collections/community//`) are validated at +//! docs-generation time so a broken submission fails a PR's CI, never a +//! user's install. Rules are intentionally stricter than what the loader +//! tolerates for local collections. + +use anyhow::{bail, Context, Result}; +use std::path::Path; +use std::sync::OnceLock; + +use regex::Regex; + +use super::manifest::{CollectionManifest, CollectionTier, SCHEMA_VERSION}; +use crate::issuetypes::loader::load_issuetype_file; + +/// Collection ids: lowercase alphanumeric + underscore, 3-64 chars. +fn id_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"^[a-z0-9_]{3,64}$").expect("valid regex")) +} + +/// Issue type keys: uppercase start, then uppercase/digit/underscore, 2-16 chars. +fn key_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"^[A-Z][A-Z0-9_]{1,15}$").expect("valid regex")) +} + +/// Validate a manifest against the shareable-collection rules. +/// +/// `dir_name` is the name of the directory holding `collection.json`; the +/// manifest `id` must match it so hosted paths stay consistent. +pub fn validate_manifest(manifest: &CollectionManifest, dir_name: &str) -> Result<()> { + if !id_regex().is_match(&manifest.id) { + bail!( + "invalid collection id '{}': must match ^[a-z0-9_]{{3,64}}$", + manifest.id + ); + } + if manifest.id != dir_name { + bail!( + "collection id '{}' does not match its directory name '{dir_name}'", + manifest.id + ); + } + if manifest.schema_version != SCHEMA_VERSION { + bail!( + "unsupported schema_version {} (expected {SCHEMA_VERSION})", + manifest.schema_version + ); + } + if manifest.name.trim().is_empty() { + bail!("collection name must not be empty"); + } + if manifest.description.trim().is_empty() { + bail!("collection description must not be empty"); + } + if manifest.issue_types.is_empty() || manifest.issue_types.len() > 32 { + bail!( + "collection must contain between 1 and 32 issue types (found {})", + manifest.issue_types.len() + ); + } + + let mut seen = std::collections::HashSet::new(); + for entry in &manifest.issue_types { + if !key_regex().is_match(&entry.key) { + bail!( + "invalid issue type key '{}': must match ^[A-Z][A-Z0-9_]{{1,15}}$", + entry.key + ); + } + if !seen.insert(entry.key.as_str()) { + bail!("duplicate issue type key '{}'", entry.key); + } + validate_path(&entry.schema_path)?; + if let Some(template) = &entry.template_path { + validate_path(template)?; + } + if let Some(preview) = &entry.workflow_preview_path { + validate_path(preview)?; + } + } + + if manifest.tier == CollectionTier::Community { + for (value, field) in [ + (&manifest.license, "license"), + (&manifest.author, "author"), + (&manifest.url, "url"), + ] { + if value.as_deref().is_none_or(|v| v.trim().is_empty()) { + bail!("community collections require a {field}"); + } + } + } + + Ok(()) +} + +/// File references must be bare filenames next to the manifest — no +/// separators or traversal, matching the flat hosted/embedded layout. +fn validate_path(path: &str) -> Result<()> { + if path.is_empty() + || path.contains('/') + || path.contains('\\') + || path.contains("..") + || path.starts_with('.') + { + bail!("unsafe path '{path}': must be a bare relative filename"); + } + Ok(()) +} + +/// Parse and validate a collection directory: manifest rules, referenced +/// files exist, and every issuetype schema parses and validates. +pub fn validate_collection_dir(dir: &Path) -> Result { + let dir_name = dir + .file_name() + .and_then(|n| n.to_str()) + .with_context(|| format!("invalid collection directory: {}", dir.display()))?; + + let manifest_path = dir.join("collection.json"); + let manifest_json = std::fs::read_to_string(&manifest_path) + .with_context(|| format!("missing collection.json in {}", dir.display()))?; + let manifest = CollectionManifest::from_json(&manifest_json) + .with_context(|| format!("invalid collection.json in {}", dir.display()))?; + + validate_manifest(&manifest, dir_name)?; + + for entry in &manifest.issue_types { + let schema_path = dir.join(&entry.schema_path); + if !schema_path.is_file() { + bail!("missing schema file '{}'", entry.schema_path); + } + let issue_type = load_issuetype_file(&schema_path) + .with_context(|| format!("invalid issuetype schema '{}'", entry.schema_path))?; + if issue_type.key != entry.key { + bail!( + "schema key '{}' does not match manifest key '{}'", + issue_type.key, + entry.key + ); + } + if let Some(template) = &entry.template_path { + if !dir.join(template).is_file() { + bail!("missing template file '{template}'"); + } + } + } + + Ok(manifest) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + const VALID_TASK_JSON: &str = r#"{ + "key": "TASK", + "name": "Task", + "description": "A focused task", + "mode": "autonomous", + "glyph": ">", + "fields": [], + "steps": [{"name": "execute", "outputs": ["report"], "prompt": "Do the task."}] + }"#; + + fn manifest_json(id: &str) -> String { + format!( + r#"{{ + "schema_version": 1, + "id": "{id}", + "name": "Example", + "description": "An example collection", + "tier": "community", + "author": "someone", + "url": "https://example.com/repo", + "license": "MIT", + "issue_types": [ + {{"key": "TASK", "schema_path": "TASK.json", "template_path": "TASK.md"}} + ] + }}"# + ) + } + + fn write_collection(dir: &Path, id: &str) { + let cdir = dir.join(id); + fs::create_dir_all(&cdir).unwrap(); + fs::write(cdir.join("collection.json"), manifest_json(id)).unwrap(); + fs::write(cdir.join("TASK.json"), VALID_TASK_JSON).unwrap(); + fs::write(cdir.join("TASK.md"), "# Task: {{ summary }}\n").unwrap(); + } + + fn parse(json: &str) -> CollectionManifest { + CollectionManifest::from_json(json).unwrap() + } + + #[test] + fn test_valid_community_collection_dir_passes() { + let tmp = TempDir::new().unwrap(); + write_collection(tmp.path(), "example_loop"); + let manifest = validate_collection_dir(&tmp.path().join("example_loop")).unwrap(); + assert_eq!(manifest.id, "example_loop"); + assert_eq!(manifest.tier, CollectionTier::Community); + } + + #[test] + fn test_id_must_match_directory() { + let m = parse(&manifest_json("example_loop")); + let err = validate_manifest(&m, "other_dir").unwrap_err(); + assert!(err.to_string().contains("does not match its directory")); + } + + #[test] + fn test_invalid_id_rejected() { + for bad in ["ab", "Has-Hyphen", "UPPER", "a b"] { + let mut m = parse(&manifest_json("example_loop")); + m.id = bad.to_string(); + let err = validate_manifest(&m, bad).unwrap_err(); + assert!( + err.to_string().contains("invalid collection id"), + "id '{bad}' should be rejected, got: {err}" + ); + } + } + + #[test] + fn test_unsupported_schema_version_rejected() { + let mut m = parse(&manifest_json("example_loop")); + m.schema_version = 99; + let err = validate_manifest(&m, "example_loop").unwrap_err(); + assert!(err.to_string().contains("unsupported schema_version")); + } + + #[test] + fn test_empty_name_and_description_rejected() { + let mut m = parse(&manifest_json("example_loop")); + m.name = " ".to_string(); + let err = validate_manifest(&m, "example_loop").unwrap_err(); + assert!(err.to_string().contains("name must not be empty")); + + let mut m = parse(&manifest_json("example_loop")); + m.description = String::new(); + let err = validate_manifest(&m, "example_loop").unwrap_err(); + assert!(err.to_string().contains("description must not be empty")); + } + + #[test] + fn test_issue_type_count_bounds() { + let mut m = parse(&manifest_json("example_loop")); + m.issue_types.clear(); + let err = validate_manifest(&m, "example_loop").unwrap_err(); + assert!(err.to_string().contains("between 1 and 32")); + + let mut m = parse(&manifest_json("example_loop")); + let entry = m.issue_types[0].clone(); + m.issue_types = (0..33) + .map(|i| { + let mut e = entry.clone(); + e.key = format!("K{i}"); + e + }) + .collect(); + let err = validate_manifest(&m, "example_loop").unwrap_err(); + assert!(err.to_string().contains("between 1 and 32")); + } + + #[test] + fn test_invalid_and_duplicate_keys_rejected() { + let mut m = parse(&manifest_json("example_loop")); + m.issue_types[0].key = "bad-key".to_string(); + let err = validate_manifest(&m, "example_loop").unwrap_err(); + assert!(err.to_string().contains("invalid issue type key")); + + let mut m = parse(&manifest_json("example_loop")); + let dup = m.issue_types[0].clone(); + m.issue_types.push(dup); + let err = validate_manifest(&m, "example_loop").unwrap_err(); + assert!(err.to_string().contains("duplicate issue type key")); + } + + #[test] + fn test_unsafe_paths_rejected() { + for bad in ["../TASK.json", "/etc/passwd", "sub/TASK.json", ".hidden"] { + let mut m = parse(&manifest_json("example_loop")); + m.issue_types[0].schema_path = bad.to_string(); + let err = validate_manifest(&m, "example_loop").unwrap_err(); + assert!( + err.to_string().contains("unsafe path"), + "path '{bad}' should be rejected, got: {err}" + ); + } + } + + #[test] + fn test_community_requires_license_author_url() { + for field in ["license", "author", "url"] { + let mut m = parse(&manifest_json("example_loop")); + match field { + "license" => m.license = None, + "author" => m.author = Some(" ".to_string()), + _ => m.url = None, + } + let err = validate_manifest(&m, "example_loop").unwrap_err(); + assert!( + err.to_string() + .contains(&format!("community collections require a {field}")), + "missing {field} should be rejected, got: {err}" + ); + } + } + + #[test] + fn test_official_tier_does_not_require_attribution() { + let mut m = parse(&manifest_json("example_loop")); + m.tier = CollectionTier::Official; + m.license = None; + m.author = None; + m.url = None; + assert!(validate_manifest(&m, "example_loop").is_ok()); + } + + #[test] + fn test_dir_missing_manifest_rejected() { + let tmp = TempDir::new().unwrap(); + fs::create_dir_all(tmp.path().join("example_loop")).unwrap(); + let err = validate_collection_dir(&tmp.path().join("example_loop")).unwrap_err(); + assert!(err.to_string().contains("missing collection.json")); + } + + #[test] + fn test_dir_missing_schema_file_rejected() { + let tmp = TempDir::new().unwrap(); + write_collection(tmp.path(), "example_loop"); + fs::remove_file(tmp.path().join("example_loop/TASK.json")).unwrap(); + let err = validate_collection_dir(&tmp.path().join("example_loop")).unwrap_err(); + assert!(err.to_string().contains("missing schema file")); + } + + #[test] + fn test_dir_broken_schema_rejected() { + let tmp = TempDir::new().unwrap(); + write_collection(tmp.path(), "example_loop"); + // Steps are required: an issuetype without steps must fail validation. + fs::write( + tmp.path().join("example_loop/TASK.json"), + r#"{"key": "TASK", "name": "Task", "description": "d", "mode": "autonomous", "glyph": ">", "fields": [], "steps": []}"#, + ) + .unwrap(); + let err = validate_collection_dir(&tmp.path().join("example_loop")).unwrap_err(); + assert!(err.to_string().contains("invalid issuetype schema")); + } + + #[test] + fn test_dir_key_mismatch_rejected() { + let tmp = TempDir::new().unwrap(); + write_collection(tmp.path(), "example_loop"); + fs::write( + tmp.path().join("example_loop/TASK.json"), + VALID_TASK_JSON.replace("\"TASK\"", "\"OTHER\""), + ) + .unwrap(); + let err = validate_collection_dir(&tmp.path().join("example_loop")).unwrap_err(); + assert!(err.to_string().contains("does not match manifest key")); + } + + #[test] + fn test_dir_missing_template_rejected() { + let tmp = TempDir::new().unwrap(); + write_collection(tmp.path(), "example_loop"); + fs::remove_file(tmp.path().join("example_loop/TASK.md")).unwrap(); + let err = validate_collection_dir(&tmp.path().join("example_loop")).unwrap_err(); + assert!(err.to_string().contains("missing template file")); + } +} diff --git a/src/config/config_tests.rs b/src/config/config_tests.rs index f60d5551..fc28ae98 100644 --- a/src/config/config_tests.rs +++ b/src/config/config_tests.rs @@ -319,6 +319,7 @@ fn test_upsert_jira_project_inserts_new_workspace() { "OPERATOR_JIRA_API_KEY", "PROJ", "acct-123", + KanbanStatusMapping::default(), ); let ws = kanban @@ -343,6 +344,7 @@ fn test_upsert_jira_project_adds_to_existing_workspace_without_clobber() { "OPERATOR_JIRA_API_KEY", "EXISTING", "acct-existing", + KanbanStatusMapping::default(), ); // Add a second project to the same workspace @@ -352,6 +354,7 @@ fn test_upsert_jira_project_adds_to_existing_workspace_without_clobber() { "OPERATOR_JIRA_API_KEY", "NEWONE", "acct-new", + KanbanStatusMapping::default(), ); let ws = kanban.jira.get("acme.atlassian.net").unwrap(); @@ -369,6 +372,7 @@ fn test_upsert_jira_project_replaces_existing_project_entry() { "OPERATOR_JIRA_API_KEY", "PROJ", "acct-old", + KanbanStatusMapping::default(), ); // Upsert same project with new sync_user_id kanban.upsert_jira_project( @@ -377,6 +381,7 @@ fn test_upsert_jira_project_replaces_existing_project_entry() { "OPERATOR_JIRA_API_KEY", "PROJ", "acct-new", + KanbanStatusMapping::default(), ); let ws = kanban.jira.get("acme.atlassian.net").unwrap(); @@ -392,6 +397,7 @@ fn test_upsert_linear_project_inserts_new_workspace() { "OPERATOR_LINEAR_API_KEY", "ENG", "user-uuid-1", + KanbanStatusMapping::default(), ); let ws = kanban.linear.get("myworkspace").unwrap(); @@ -403,8 +409,20 @@ fn test_upsert_linear_project_inserts_new_workspace() { #[test] fn test_upsert_linear_project_adds_to_existing_workspace_without_clobber() { let mut kanban = KanbanConfig::default(); - kanban.upsert_linear_project("myworkspace", "OPERATOR_LINEAR_API_KEY", "ENG", "user-a"); - kanban.upsert_linear_project("myworkspace", "OPERATOR_LINEAR_API_KEY", "DESIGN", "user-b"); + kanban.upsert_linear_project( + "myworkspace", + "OPERATOR_LINEAR_API_KEY", + "ENG", + "user-a", + KanbanStatusMapping::default(), + ); + kanban.upsert_linear_project( + "myworkspace", + "OPERATOR_LINEAR_API_KEY", + "DESIGN", + "user-b", + KanbanStatusMapping::default(), + ); let ws = kanban.linear.get("myworkspace").unwrap(); assert_eq!(ws.projects.len(), 2); @@ -421,6 +439,7 @@ fn test_upsert_jira_does_not_touch_other_workspaces() { "OPERATOR_JIRA_API_KEY", "FIRST", "acct-1", + KanbanStatusMapping::default(), ); kanban.upsert_jira_project( "second.atlassian.net", @@ -428,6 +447,7 @@ fn test_upsert_jira_does_not_touch_other_workspaces() { "OPERATOR_JIRA_SECOND_API_KEY", "SECOND", "acct-2", + KanbanStatusMapping::default(), ); assert_eq!(kanban.jira.len(), 2); @@ -552,6 +572,106 @@ fn test_upsert_project_github() { assert_eq!(proj.sync_user_id, "12345678"); } +#[test] +fn test_status_mapping_defaults_all_none_from_legacy_toml() { + // Legacy configs carry a `sync_statuses` list; serde must ignore the + // unknown key and leave the new mapping empty. + let toml_str = r#" + sync_user_id = "acct-1" + sync_statuses = ["To Do", "In Progress", "Done"] + "#; + let cfg: ProjectSyncConfig = toml::from_str(toml_str).unwrap(); + assert!(cfg.status_mapping.is_empty()); + assert_eq!(cfg.sync_user_id, "acct-1"); +} + +#[test] +fn test_status_mapping_is_empty_and_mapped_count() { + let mut mapping = KanbanStatusMapping::default(); + assert!(mapping.is_empty()); + assert_eq!(mapping.mapped_count(), 0); + + mapping.doing = Some("In Progress".to_string()); + assert!(!mapping.is_empty()); + assert_eq!(mapping.mapped_count(), 1); + + mapping.todo = Some("To Do".to_string()); + mapping.done = Some("Done".to_string()); + assert_eq!(mapping.mapped_count(), 3); +} + +#[test] +fn test_project_sync_config_omits_empty_status_mapping_on_serialize() { + let cfg = ProjectSyncConfig { + sync_user_id: "acct-1".to_string(), + ..Default::default() + }; + let toml_str = toml::to_string(&cfg).unwrap(); + assert!(!toml_str.contains("status_mapping")); +} + +#[test] +fn test_project_sync_config_status_mapping_roundtrip() { + let cfg = ProjectSyncConfig { + sync_user_id: "acct-1".to_string(), + status_mapping: KanbanStatusMapping { + todo: Some("Backlog".to_string()), + doing: Some("In Progress".to_string()), + done: Some("Shipped".to_string()), + }, + ..Default::default() + }; + let toml_str = toml::to_string(&cfg).unwrap(); + let parsed: ProjectSyncConfig = toml::from_str(&toml_str).unwrap(); + assert_eq!(parsed.status_mapping, cfg.status_mapping); +} + +#[test] +fn test_pull_statuses_returns_todo_and_doing_some_values() { + let cfg = ProjectSyncConfig { + status_mapping: KanbanStatusMapping { + todo: Some("Backlog".to_string()), + doing: Some("In Progress".to_string()), + done: Some("Shipped".to_string()), + }, + ..Default::default() + }; + assert_eq!(cfg.pull_statuses(), vec!["Backlog", "In Progress"]); + + let partial = ProjectSyncConfig { + status_mapping: KanbanStatusMapping { + todo: Some("Backlog".to_string()), + ..Default::default() + }, + ..Default::default() + }; + assert_eq!(partial.pull_statuses(), vec!["Backlog"]); + + assert!(ProjectSyncConfig::default().pull_statuses().is_empty()); +} + +#[test] +fn test_upsert_jira_project_persists_status_mapping() { + let mut kanban = KanbanConfig::default(); + kanban.upsert_jira_project( + "acme.atlassian.net", + "user@acme.com", + "OPERATOR_JIRA_API_KEY", + "PROJ", + "acct-123", + KanbanStatusMapping { + todo: Some("To Do".to_string()), + doing: Some("In Progress".to_string()), + done: Some("Done".to_string()), + }, + ); + + let project = &kanban.jira["acme.atlassian.net"].projects["PROJ"]; + assert_eq!(project.status_mapping.todo.as_deref(), Some("To Do")); + assert_eq!(project.status_mapping.doing.as_deref(), Some("In Progress")); + assert_eq!(project.status_mapping.done.as_deref(), Some("Done")); +} + #[test] fn test_relay_config_default_auto_inject_is_false() { let config = Config::default(); diff --git a/src/config/kanban.rs b/src/config/kanban.rs index d35b4a0f..b4226495 100644 --- a/src/config/kanban.rs +++ b/src/config/kanban.rs @@ -152,6 +152,7 @@ impl KanbanConfig { api_key_env: &str, project_key: &str, sync_user_id: &str, + status_mapping: KanbanStatusMapping, ) { let entry = self.jira.entry(domain.to_string()).or_default(); entry.enabled = true; @@ -161,7 +162,7 @@ impl KanbanConfig { project_key.to_string(), ProjectSyncConfig { sync_user_id: sync_user_id.to_string(), - sync_statuses: Vec::new(), + status_mapping, collection_name: None, type_mappings: std::collections::HashMap::new(), bidirectional: false, @@ -181,6 +182,7 @@ impl KanbanConfig { api_key_env: &str, project_key: &str, sync_user_id: &str, + status_mapping: KanbanStatusMapping, ) { let entry = self.linear.entry(workspace.to_string()).or_default(); entry.enabled = true; @@ -189,7 +191,7 @@ impl KanbanConfig { project_key.to_string(), ProjectSyncConfig { sync_user_id: sync_user_id.to_string(), - sync_statuses: Vec::new(), + status_mapping, collection_name: None, type_mappings: std::collections::HashMap::new(), bidirectional: false, @@ -212,6 +214,7 @@ impl KanbanConfig { api_key_env: &str, project_key: &str, sync_user_id: &str, + status_mapping: KanbanStatusMapping, ) { let entry = self.github.entry(owner.to_string()).or_default(); entry.enabled = true; @@ -220,7 +223,7 @@ impl KanbanConfig { project_key.to_string(), ProjectSyncConfig { sync_user_id: sync_user_id.to_string(), - sync_statuses: Vec::new(), + status_mapping, collection_name: None, type_mappings: std::collections::HashMap::new(), bidirectional: false, @@ -246,23 +249,65 @@ impl KanbanConfig { &workspace.api_key_env, &project.project_key, &workspace.sync_user_id, + KanbanStatusMapping::default(), ), WorkspaceExtra::Linear => self.upsert_linear_project( &workspace.workspace_key, &workspace.api_key_env, &project.project_key, &workspace.sync_user_id, + KanbanStatusMapping::default(), ), WorkspaceExtra::Github => self.upsert_github_project( &workspace.workspace_key, &workspace.api_key_env, &project.project_key, &workspace.sync_user_id, + KanbanStatusMapping::default(), ), } } } +/// Explicit mapping from operator's strict todo/doing/done states to the +/// external board's column/status names. +/// +/// Drives bidirectional sync: issues are pulled from the `todo` column, +/// pushed to `doing` when a ticket is claimed, to `done` when completed, and +/// back to `todo` when requeued. Unset fields fall back per-transition +/// (`doing` → "In Progress", `done` → "Done"); requeue only pushes when +/// `todo` is explicitly mapped. +#[derive( + Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS, utoipa::ToSchema, +)] +#[ts(export)] +pub struct KanbanStatusMapping { + /// External column for operator "todo" (queued work; also the pull source) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub todo: Option, + /// External column for operator "doing" (claimed/launched tickets) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub doing: Option, + /// External column for operator "done" (completed tickets) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub done: Option, +} + +impl KanbanStatusMapping { + /// True when no column is mapped (used to omit the table from TOML). + pub fn is_empty(&self) -> bool { + self.todo.is_none() && self.doing.is_none() && self.done.is_none() + } + + /// Number of mapped columns. + pub fn mapped_count(&self) -> usize { + [&self.todo, &self.doing, &self.done] + .iter() + .filter(|s| s.is_some()) + .count() + } +} + /// Per-project/team sync configuration for a kanban provider #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, TS)] #[ts(export)] @@ -273,9 +318,9 @@ pub struct ProjectSyncConfig { /// - GitHub Projects: numeric GitHub `databaseId` (e.g., "12345678") #[serde(default)] pub sync_user_id: String, - /// Workflow statuses to sync (empty = default/first status only) - #[serde(default)] - pub sync_statuses: Vec, + /// Mapping of operator todo/doing/done to external board columns + #[serde(default, skip_serializing_if = "KanbanStatusMapping::is_empty")] + pub status_mapping: KanbanStatusMapping, /// Optional `IssueTypeCollection` name this project maps to. /// Not required for kanban onboarding or sync. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -290,3 +335,16 @@ pub struct ProjectSyncConfig { #[serde(default)] pub bidirectional: bool, } + +impl ProjectSyncConfig { + /// Statuses to pull from the external board: the mapped `todo` column + /// (queued work) plus `doing` (resume in-flight). Empty when unmapped — + /// providers then fall back to their default status filter. + pub fn pull_statuses(&self) -> Vec { + [&self.status_mapping.todo, &self.status_mapping.doing] + .into_iter() + .flatten() + .cloned() + .collect() + } +} diff --git a/src/docs_gen/collections_manifest.rs b/src/docs_gen/collections_manifest.rs index 6a722aa1..5ffd03e9 100644 --- a/src/docs_gen/collections_manifest.rs +++ b/src/docs_gen/collections_manifest.rs @@ -94,6 +94,9 @@ fn build_index() -> Result { tags: hosted.manifest.tags.clone(), manifest_path: format!("{}/collection.json", hosted.manifest.id), checksum: sha256_hex(json.as_bytes()), + tier: hosted.manifest.tier, + // Filled once per-collection docs pages are generated. + docs_path: None, }); } Ok(CollectionIndex { diff --git a/src/issuetypes/schema.rs b/src/issuetypes/schema.rs index 3eeb5e89..ad9b24a2 100644 --- a/src/issuetypes/schema.rs +++ b/src/issuetypes/schema.rs @@ -192,9 +192,9 @@ impl IssueType { /// Validation errors for issue types #[derive(Debug, Clone, PartialEq)] pub enum ValidationError { - /// Key format is invalid (must be uppercase letters only) + /// Key format is invalid (uppercase start, then uppercase/digits/underscores) InvalidKey(String), - /// Key length is invalid (must be 2-10 characters) + /// Key length is invalid (must be 2-16 characters) KeyLength(String), /// Glyph is invalid (must be 1-4 characters) InvalidGlyph(String), @@ -212,10 +212,13 @@ impl std::fmt::Display for ValidationError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ValidationError::InvalidKey(key) => { - write!(f, "Key '{key}' must be uppercase letters only") + write!( + f, + "Key '{key}' must start with an uppercase letter and contain only uppercase letters, digits, and underscores" + ) } ValidationError::KeyLength(key) => { - write!(f, "Key '{key}' must be 2-10 characters") + write!(f, "Key '{key}' must be 2-16 characters") } ValidationError::InvalidGlyph(glyph) => { write!(f, "Glyph '{glyph}' must be 1-4 characters") @@ -243,13 +246,17 @@ impl IssueType { pub fn validate(&self) -> Result<(), Vec> { let mut errors = Vec::new(); - // Check key format: uppercase letters only - if !self.key.chars().all(|c| c.is_ascii_uppercase()) { + // Check key format: uppercase start, then uppercase/digit/underscore. + // Hyphens are excluded: `-` separates key from number in ticket ids. + let mut chars = self.key.chars(); + let valid_start = chars.next().is_some_and(|c| c.is_ascii_uppercase()); + let valid_rest = chars.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_'); + if !(valid_start && valid_rest) { errors.push(ValidationError::InvalidKey(self.key.clone())); } - // Check key length: 2-10 characters - if self.key.len() < 2 || self.key.len() > 10 { + // Check key length: 2-16 characters + if self.key.len() < 2 || self.key.len() > 16 { errors.push(ValidationError::KeyLength(self.key.clone())); } @@ -399,7 +406,7 @@ mod tests { #[test] fn test_invalid_key_too_long() { let mut issue_type = create_valid_issuetype(); - issue_type.key = "VERYLONGKEY".to_string(); + issue_type.key = "ABCDEFGHIJKLMNOPQ".to_string(); // 17 chars let result = issue_type.validate(); assert!(result.is_err()); let errors = result.unwrap_err(); @@ -408,6 +415,43 @@ mod tests { .any(|e| matches!(e, ValidationError::KeyLength(_)))); } + #[test] + fn test_key_with_underscore_and_digits_valid() { + // Grammar ^[A-Z][A-Z0-9_]{1,15}$: shipped keys like AGENT_SETUP + // and PROJECT_INIT must validate. + for key in ["AGENT_SETUP", "PROJECT_INIT", "TASK2", "JR_REBASE_V2"] { + let mut issue_type = create_valid_issuetype(); + issue_type.key = key.to_string(); + assert!(issue_type.validate().is_ok(), "key '{key}' should be valid"); + } + } + + #[test] + fn test_key_must_start_with_uppercase_letter() { + for key in ["_TASK", "2TASK", "tASK"] { + let mut issue_type = create_valid_issuetype(); + issue_type.key = key.to_string(); + let errors = issue_type.validate().unwrap_err(); + assert!( + errors + .iter() + .any(|e| matches!(e, ValidationError::InvalidKey(_))), + "key '{key}' should be invalid" + ); + } + } + + #[test] + fn test_key_hyphen_invalid() { + // Hyphens conflict with the `{KEY}-{number}` ticket-id separator. + let mut issue_type = create_valid_issuetype(); + issue_type.key = "AGENT-SETUP".to_string(); + let errors = issue_type.validate().unwrap_err(); + assert!(errors + .iter() + .any(|e| matches!(e, ValidationError::InvalidKey(_)))); + } + #[test] fn test_invalid_glyph_empty() { let mut issue_type = create_valid_issuetype(); diff --git a/src/mcp/tickets.rs b/src/mcp/tickets.rs index 2fe79848..75d612ed 100644 --- a/src/mcp/tickets.rs +++ b/src/mcp/tickets.rs @@ -8,6 +8,29 @@ use serde_json::{json, Value}; use crate::queue::{Queue, Ticket}; use crate::rest::state::ApiState; +/// Kanban transitions pushed upstream after a successful local move. +enum KanbanTransition { + Claimed, + Completed, + Requeued, +} + +/// Fire-and-forget: mirror a ticket move to the upstream kanban board. +/// No-op unless bidirectional sync is configured (`state.kanban_sync`). +fn push_kanban_transition(state: &ApiState, ticket: &Ticket, transition: KanbanTransition) { + let Some(ks) = state.kanban_sync.clone() else { + return; + }; + let ticket = ticket.clone(); + tokio::spawn(async move { + match transition { + KanbanTransition::Claimed => ks.on_ticket_claimed(&ticket).await, + KanbanTransition::Completed => ks.on_ticket_completed(&ticket).await, + KanbanTransition::Requeued => ks.on_ticket_requeued(&ticket).await, + } + }); +} + fn ticket_to_json(t: &Ticket) -> Value { json!({ "id": t.id, @@ -76,12 +99,14 @@ pub async fn claim_ticket(args: Value, state: &ApiState) -> Result Result<(), String> { let queue = Queue::new(&config).map_err(|e| e.to_string())?; queue.claim_ticket(&ticket).map_err(|e| e.to_string()) }) .await .map_err(|e| e.to_string())??; + push_kanban_transition(state, &ticket_for_push, KanbanTransition::Claimed); Ok(json!({ "id": id_str, "moved_to": "in-progress" })) } @@ -93,12 +118,14 @@ pub async fn complete_ticket(args: Value, state: &ApiState) -> Result Result<(), String> { let queue = Queue::new(&config).map_err(|e| e.to_string())?; queue.complete_ticket(&ticket).map_err(|e| e.to_string()) }) .await .map_err(|e| e.to_string())??; + push_kanban_transition(state, &ticket_for_push, KanbanTransition::Completed); Ok(json!({ "id": id_str, "moved_to": "completed" })) } @@ -110,12 +137,14 @@ pub async fn return_to_queue(args: Value, state: &ApiState) -> Result Result<(), String> { let queue = Queue::new(&config).map_err(|e| e.to_string())?; queue.return_to_queue(&ticket).map_err(|e| e.to_string()) }) .await .map_err(|e| e.to_string())??; + push_kanban_transition(state, &ticket_for_push, KanbanTransition::Requeued); Ok(json!({ "id": id_str, "moved_to": "queue" })) } diff --git a/src/queue/ticket.rs b/src/queue/ticket.rs index 0517e129..9aa0a9ca 100644 --- a/src/queue/ticket.rs +++ b/src/queue/ticket.rs @@ -789,7 +789,7 @@ fn extract_frontmatter( fn parse_filename(filename: &str) -> Result<(String, String, String)> { // YYYYMMDD-HHMM-TYPE-PROJECT-description.md // Project names don't contain hyphens (gamesvc, global, etc.) - let re = Regex::new(r"^(\d{8}-\d{4})-([A-Z]+)-([a-z0-9]+)-")?; + let re = Regex::new(r"^(\d{8}-\d{4})-([A-Z][A-Z0-9_]*)-([a-z0-9]+)-")?; if let Some(caps) = re.captures(filename) { Ok(( @@ -878,6 +878,15 @@ mod tests { assert_eq!(proj, "gamesvc"); } + #[test] + fn test_parse_filename_underscore_key() { + let (ts, tt, proj) = + parse_filename("20260701-0900-AGENT_SETUP-gamesvc-configure-agents.md").unwrap(); + assert_eq!(ts, "20260701-0900"); + assert_eq!(tt, "AGENT_SETUP"); + assert_eq!(proj, "gamesvc"); + } + #[test] fn test_parse_filename_investigation() { let (ts, tt, proj) = parse_filename("20241221-1520-INV-global-500-errors.md").unwrap(); diff --git a/src/rest/dto/kanban.rs b/src/rest/dto/kanban.rs index d83b4814..37df5e2c 100644 --- a/src/rest/dto/kanban.rs +++ b/src/rest/dto/kanban.rs @@ -3,6 +3,8 @@ use serde::{Deserialize, Serialize}; use ts_rs::TS; use utoipa::ToSchema; +use crate::config::kanban::KanbanStatusMapping; + // ============================================================================= // External Issue Type DTOs (from kanban providers) // ============================================================================= @@ -270,6 +272,9 @@ pub struct WriteJiraConfigBody { pub api_key_env: String, pub project_key: String, pub sync_user_id: String, + /// Mapping of operator todo/doing/done to external board columns + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status_mapping: Option, } /// Body for writing a Linear project/team config section. @@ -280,6 +285,9 @@ pub struct WriteLinearConfigBody { pub api_key_env: String, pub project_key: String, pub sync_user_id: String, + /// Mapping of operator todo/doing/done to external board columns + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status_mapping: Option, } /// Body for writing a GitHub Projects v2 config section. @@ -296,6 +304,33 @@ pub struct WriteGithubConfigBody { pub project_key: String, /// Numeric GitHub `databaseId` of the user whose items to sync pub sync_user_id: String, + /// Mapping of operator todo/doing/done to external board columns + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status_mapping: 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)] +#[ts(export)] +pub struct ListKanbanStatusesRequest { + pub provider: KanbanProviderKind, + /// Project/team key to list statuses for + pub project_key: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub jira: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub linear: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub github: Option, +} + +/// Response wrapper for list-statuses: the external board's column names, +/// in board order, for populating todo/doing/done mapping dropdowns. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct ListKanbanStatusesResponse { + pub statuses: Vec, } /// Request to write or upsert a kanban config section. @@ -464,6 +499,64 @@ mod tests { assert!(!json.contains("\"github\":")); } + #[test] + fn test_list_statuses_request_deserializes_with_one_provider_body() { + let json = r#"{ + "provider": "jira", + "project_key": "PROJ", + "jira": { "domain": "acme.atlassian.net", "email": "a@b.com", "api_token": "t" } + }"#; + let req: ListKanbanStatusesRequest = serde_json::from_str(json).unwrap(); + assert_eq!(req.provider, KanbanProviderKind::Jira); + assert_eq!(req.project_key, "PROJ"); + assert!(req.jira.is_some()); + assert!(req.linear.is_none()); + assert!(req.github.is_none()); + } + + #[test] + fn test_write_config_body_roundtrips_status_mapping() { + let body = WriteJiraConfigBody { + domain: "acme.atlassian.net".to_string(), + email: "a@b.com".to_string(), + api_key_env: "OPERATOR_JIRA_API_KEY".to_string(), + project_key: "PROJ".to_string(), + sync_user_id: "acct-1".to_string(), + status_mapping: Some(KanbanStatusMapping { + todo: Some("To Do".to_string()), + doing: Some("In Progress".to_string()), + done: Some("Done".to_string()), + }), + }; + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains("\"status_mapping\":{")); + let parsed: WriteJiraConfigBody = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.status_mapping, body.status_mapping); + } + + #[test] + fn test_write_config_body_deserializes_without_status_mapping() { + // Older clients omit status_mapping — must default to None. + let json = r#"{ + "domain": "acme.atlassian.net", "email": "a@b.com", + "api_key_env": "OPERATOR_JIRA_API_KEY", + "project_key": "PROJ", "sync_user_id": "acct-1" + }"#; + let parsed: WriteJiraConfigBody = serde_json::from_str(json).unwrap(); + assert!(parsed.status_mapping.is_none()); + } + + #[test] + fn test_status_mapping_skips_none_columns_on_serialize() { + let mapping = KanbanStatusMapping { + todo: None, + doing: Some("In Progress".to_string()), + done: None, + }; + let json = serde_json::to_string(&mapping).unwrap(); + assert_eq!(json, r#"{"doing":"In Progress"}"#); + } + #[test] fn test_write_kanban_config_body_carries_env_name_not_secret() { // The config-write path stores only the env-var NAME (`api_key_env`) — it @@ -475,6 +568,7 @@ mod tests { api_key_env: "OPERATOR_JIRA_TOKEN".to_string(), project_key: "PROJ".to_string(), sync_user_id: "acct-1".to_string(), + status_mapping: None, }; let json = serde_json::to_string(&body).unwrap(); assert!(json.contains("\"api_key_env\":\"OPERATOR_JIRA_TOKEN\"")); @@ -546,6 +640,7 @@ mod tests { api_key_env: "OPERATOR_GITHUB_TOKEN".to_string(), project_key: "PVT_kwDOABcdefg".to_string(), sync_user_id: "123".to_string(), + status_mapping: None, }), }; let json = serde_json::to_string(&req).unwrap(); diff --git a/src/rest/mod.rs b/src/rest/mod.rs index 8a8024d0..0306ec49 100644 --- a/src/rest/mod.rs +++ b/src/rest/mod.rs @@ -131,10 +131,12 @@ fn documented_router() -> OpenApiRouter { // Kanban provider endpoints .routes(routes!(routes::kanban::provider_catalog)) .routes(routes!(routes::kanban::external_issue_types)) + .routes(routes!(routes::kanban::project_statuses)) .routes(routes!(routes::kanban::sync_issue_types)) - // Kanban onboarding endpoints (validate, list projects, write config, set env) + // Kanban onboarding endpoints (validate, list projects/statuses, write config, set env) .routes(routes!(routes::kanban_onboarding::validate_credentials)) .routes(routes!(routes::kanban_onboarding::list_projects)) + .routes(routes!(routes::kanban_onboarding::list_statuses)) .routes(routes!(routes::kanban_onboarding::write_config)) .routes(routes!(routes::kanban_onboarding::set_session_env)) // Skills endpoint diff --git a/src/rest/openapi.rs b/src/rest/openapi.rs index 56333e75..05c2cd6e 100644 --- a/src/rest/openapi.rs +++ b/src/rest/openapi.rs @@ -12,13 +12,14 @@ use crate::rest::dto::{ FieldResponse, HealthResponse, IntegrationCatalogEntryDto, IssueTypeResponse, IssueTypeSummary, KanbanBoardResponse, KanbanIssueTypeResponse, KanbanProviderCatalogEntry, KanbanSyncResponse, KanbanTicketCard, LaunchTicketRequest, LaunchTicketResponse, ListKanbanProjectsRequest, - ListKanbanProjectsResponse, ModelEntry, ModelServerKindEntry, ModelServerModelsResponse, - ModelServerResponse, ModelServersResponse, NextStepInfo, OperatorOutput, ProjectSummary, - QueueByType, QueueControlResponse, QueueStatusResponse, RejectReviewRequest, ReviewResponse, - SectionDto, SectionRowDto, SetDefaultLlmRequest, SetKanbanSessionEnvRequest, - SetKanbanSessionEnvResponse, SkillEntry, SkillsResponse, StatusResponse, StepCompleteRequest, - StepCompleteResponse, StepResponse, SyncKanbanIssueTypesResponse, TicketDetailResponse, - UpdateIssueTypeRequest, UpdateModelServerRequest, UpdateStepRequest, UpdateTicketStatusRequest, + ListKanbanProjectsResponse, ListKanbanStatusesRequest, ListKanbanStatusesResponse, ModelEntry, + ModelServerKindEntry, ModelServerModelsResponse, ModelServerResponse, ModelServersResponse, + NextStepInfo, OperatorOutput, ProjectSummary, QueueByType, QueueControlResponse, + QueueStatusResponse, RejectReviewRequest, ReviewResponse, SectionDto, SectionRowDto, + SetDefaultLlmRequest, SetKanbanSessionEnvRequest, SetKanbanSessionEnvResponse, SkillEntry, + SkillsResponse, StatusResponse, StepCompleteRequest, StepCompleteResponse, StepResponse, + SyncKanbanIssueTypesResponse, TicketDetailResponse, UpdateIssueTypeRequest, + UpdateModelServerRequest, UpdateStepRequest, UpdateTicketStatusRequest, UpdateTicketStatusResponse, ValidateKanbanCredentialsRequest, ValidateKanbanCredentialsResponse, WorkflowExportResponse, WorkflowFormatDto, WorkflowHintsDto, WorkflowPreviewResponse, WriteKanbanConfigRequest, WriteKanbanConfigResponse, @@ -142,6 +143,9 @@ use crate::rest::error::ErrorResponse; ValidateKanbanCredentialsResponse, ListKanbanProjectsRequest, ListKanbanProjectsResponse, + ListKanbanStatusesRequest, + ListKanbanStatusesResponse, + crate::config::kanban::KanbanStatusMapping, WriteKanbanConfigRequest, WriteKanbanConfigResponse, SetKanbanSessionEnvRequest, @@ -271,6 +275,16 @@ mod tests { } } + #[test] + fn test_openapi_documents_kanban_status_discovery_routes() { + // Both status-discovery surfaces must be mounted + documented: the + // ephemeral-creds onboarding POST and the stored-config GET. + let spec = ApiDoc::json().expect("generate spec"); + assert!(spec.contains("/api/v1/kanban/statuses")); + assert!(spec.contains("/api/v1/kanban/{provider}/{project_key}/statuses")); + assert!(spec.contains("KanbanStatusMapping")); + } + #[test] fn test_openapi_version_matches_cargo() { let spec = ApiDoc::json().expect("Failed to generate OpenAPI spec"); diff --git a/src/rest/routes/kanban.rs b/src/rest/routes/kanban.rs index 082d068f..41dfaf24 100644 --- a/src/rest/routes/kanban.rs +++ b/src/rest/routes/kanban.rs @@ -8,7 +8,7 @@ use crate::config::kanban::KanbanConfig; use crate::config::Config; use crate::rest::dto::{ ExternalIssueTypeSummary, KanbanIssueTypeResponse, KanbanProviderCatalogEntry, - SyncKanbanIssueTypesResponse, + ListKanbanStatusesResponse, SyncKanbanIssueTypesResponse, }; use crate::rest::error::ApiError; use crate::rest::state::ApiState; @@ -130,6 +130,45 @@ pub async fn external_issue_types( Ok(Json(summaries)) } +/// GET /`api/v1/kanban/:provider/:project_key/statuses` +/// +/// Returns the external board's workflow statuses/columns for an +/// already-configured provider/project, using the stored config's +/// credentials. Used by config UIs (e.g. the VS Code `ProjectRow`) to populate +/// the todo/doing/done mapping dropdowns after onboarding. +#[utoipa::path( + get, + path = "/api/v1/kanban/{provider}/{project_key}/statuses", + tag = "Kanban", + operation_id = "kanban_project_statuses", + params( + ("provider" = String, Path, description = "Kanban provider name (e.g. jira, linear, github)"), + ("project_key" = String, Path, description = "Provider project/team key") + ), + responses( + (status = 200, description = "Workflow statuses/columns for the project", body = ListKanbanStatusesResponse), + (status = 400, description = "Unknown provider/project"), + (status = 500, description = "Failed to fetch statuses from provider") + ) +)] +pub async fn project_statuses( + State(state): State, + Path((provider_name, project_key)): Path<(String, String)>, +) -> Result, ApiError> { + // Reload config from disk so freshly onboarded providers are visible + // without requiring a server restart. + let fresh_config = Config::load(None).unwrap_or_else(|_| (*state.config).clone()); + let provider = get_provider_from_config(&fresh_config.kanban, &provider_name, &project_key) + .map_err(|e| ApiError::BadRequest(e.to_string()))?; + + let statuses = provider + .list_statuses(&project_key) + .await + .map_err(|e| ApiError::InternalError(format!("Failed to fetch statuses: {e}")))?; + + Ok(Json(ListKanbanStatusesResponse { statuses })) +} + /// POST /`api/v1/kanban/:provider/:project_key/issuetypes/sync` /// /// Refreshes the local kanban issue type catalog from the provider. diff --git a/src/rest/routes/kanban_onboarding.rs b/src/rest/routes/kanban_onboarding.rs index 3771751b..3696ad2f 100644 --- a/src/rest/routes/kanban_onboarding.rs +++ b/src/rest/routes/kanban_onboarding.rs @@ -8,9 +8,10 @@ use axum::extract::State; use axum::Json; use crate::rest::dto::{ - ListKanbanProjectsRequest, ListKanbanProjectsResponse, SetKanbanSessionEnvRequest, - SetKanbanSessionEnvResponse, ValidateKanbanCredentialsRequest, - ValidateKanbanCredentialsResponse, WriteKanbanConfigRequest, WriteKanbanConfigResponse, + ListKanbanProjectsRequest, ListKanbanProjectsResponse, ListKanbanStatusesRequest, + ListKanbanStatusesResponse, SetKanbanSessionEnvRequest, SetKanbanSessionEnvResponse, + ValidateKanbanCredentialsRequest, ValidateKanbanCredentialsResponse, WriteKanbanConfigRequest, + WriteKanbanConfigResponse, }; use crate::rest::error::ApiError; use crate::rest::state::ApiState; @@ -61,6 +62,29 @@ pub async fn list_projects( Ok(Json(resp)) } +/// POST /`api/v1/kanban/statuses` +/// +/// List the workflow statuses/columns of a specific project using ephemeral +/// credentials, so onboarding UIs can offer real column names in the +/// todo/doing/done mapping dropdowns. No persistence side effects. +#[utoipa::path( + post, + path = "/api/v1/kanban/statuses", + tag = "Kanban", + operation_id = "kanban_list_statuses", + request_body = ListKanbanStatusesRequest, + responses( + (status = 200, description = "Workflow statuses/columns for the project", body = ListKanbanStatusesResponse) + ) +)] +pub async fn list_statuses( + State(_state): State, + Json(req): Json, +) -> Result, ApiError> { + let resp = kanban_onboarding::list_statuses(req).await?; + Ok(Json(resp)) +} + /// PUT /`api/v1/kanban/config` /// /// Write or upsert a kanban provider+project section into `config.toml`. diff --git a/src/rest/state.rs b/src/rest/state.rs index 5cde266e..f43b3581 100644 --- a/src/rest/state.rs +++ b/src/rest/state.rs @@ -119,7 +119,7 @@ mod tests { let mut project_sync = ProjectSyncConfig { sync_user_id: String::new(), - sync_statuses: Vec::new(), + status_mapping: crate::config::KanbanStatusMapping::default(), collection_name: None, type_mappings: HashMap::new(), bidirectional: true, diff --git a/src/schemas/issuetype_collection_schema.json b/src/schemas/issuetype_collection_schema.json index d889f9dc..f405b408 100644 --- a/src/schemas/issuetype_collection_schema.json +++ b/src/schemas/issuetype_collection_schema.json @@ -21,6 +21,24 @@ "url": { "type": ["string", "null"], "description": "Link to the collection's source (GitHub repo or project page)." }, "license": { "type": ["string", "null"], "description": "SPDX license id." }, "tags": { "type": "array", "items": { "type": "string" } }, + "tier": { + "type": "string", + "enum": ["official", "community"], + "default": "official", + "description": "Provenance tier: who authored and maintains the collection. Orthogonal to distribution (curated community-authored collections may ship embedded)." + }, + "kanban_defaults": { + "type": ["object", "null"], + "description": "Descriptive kanban onboarding defaults. v1 is metadata only: suggestions seed the onboarding mapping UI but do not drive sync behavior.", + "additionalProperties": false, + "properties": { + "suggested_type_mappings": { + "type": "object", + "additionalProperties": { "type": "string" }, + "description": "Suggested provider issue-type NAME -> collection issuetype key." + } + } + }, "compatibility": { "type": ["object", "null"], "additionalProperties": false, @@ -43,7 +61,8 @@ "schema_path": { "type": "string", "description": "Path to the issuetype JSON, relative to the manifest." }, "schema_checksum": { "type": "string", "description": "SHA-256 (lowercase hex) of the issuetype JSON bytes. Required for hosted manifests; omitted for embedded ones." }, "template_path": { "type": ["string", "null"], "description": "Optional path to the markdown template, relative to the manifest." }, - "template_checksum": { "type": ["string", "null"], "description": "SHA-256 (lowercase hex) of the markdown template bytes, if present." } + "template_checksum": { "type": ["string", "null"], "description": "SHA-256 (lowercase hex) of the markdown template bytes, if present." }, + "workflow_preview_path": { "type": ["string", "null"], "description": "Path to a pre-generated workflow preview (.js), relative to the manifest. Filled by the docs producer for visualization; excluded from checksum derivation." } } } }, diff --git a/src/schemas/ticket_metadata.schema.json b/src/schemas/ticket_metadata.schema.json index 786f7bfd..a2cafc5a 100644 --- a/src/schemas/ticket_metadata.schema.json +++ b/src/schemas/ticket_metadata.schema.json @@ -9,9 +9,9 @@ "properties": { "id": { "type": "string", - "description": "Kanban ticket ID (e.g., FEAT-1234). Also used for tmux session name derivation.", - "pattern": "^[A-Z]+-\\d+$", - "examples": ["FEAT-1234", "FIX-5678", "SPIKE-0001", "INV-0042", "TASK-9999"] + "description": "Kanban ticket ID (e.g., FEAT-1234). Also used for tmux session name derivation. Key grammar: uppercase start, then uppercase letters, digits, or underscores (hyphen is reserved as the key/number separator).", + "pattern": "^[A-Z][A-Z0-9_]*-\\d+$", + "examples": ["FEAT-1234", "FIX-5678", "SPIKE-0001", "INV-0042", "TASK-9999", "AGENT_SETUP-0001"] }, "status": { "type": "string", @@ -121,7 +121,7 @@ "properties": { "tmux_session_name": { "description": "Derived as: op-{id} (e.g., op-FEAT-1234)", - "pattern": "^op-[A-Z]+-\\d+$" + "pattern": "^op-[A-Z][A-Z0-9_]*-\\d+$" }, "git_branch": { "description": "Derived as: {key.lowercase}/{id}-{summary-slug} (e.g., feat/FEAT-1234-add-auth)" diff --git a/src/services/kanban_onboarding.rs b/src/services/kanban_onboarding.rs index 5339eef6..e0266966 100644 --- a/src/services/kanban_onboarding.rs +++ b/src/services/kanban_onboarding.rs @@ -14,9 +14,9 @@ use crate::config::Config; use crate::rest::dto::{ GithubProjectInfoDto, GithubValidationDetailsDto, JiraValidationDetailsDto, KanbanProjectInfo, KanbanProviderKind, LinearTeamInfoDto, LinearValidationDetailsDto, ListKanbanProjectsRequest, - ListKanbanProjectsResponse, SetKanbanSessionEnvRequest, SetKanbanSessionEnvResponse, - ValidateKanbanCredentialsRequest, ValidateKanbanCredentialsResponse, WriteKanbanConfigRequest, - WriteKanbanConfigResponse, + ListKanbanProjectsResponse, ListKanbanStatusesRequest, ListKanbanStatusesResponse, + SetKanbanSessionEnvRequest, SetKanbanSessionEnvResponse, ValidateKanbanCredentialsRequest, + ValidateKanbanCredentialsResponse, WriteKanbanConfigRequest, WriteKanbanConfigResponse, }; use crate::rest::error::ApiError; @@ -218,6 +218,53 @@ pub async fn list_projects( }) } +// ─── list_statuses ────────────────────────────────────────────────────────── + +/// Fetch the workflow statuses/columns of a specific project using ephemeral +/// creds, for populating the todo/doing/done mapping dropdowns during +/// onboarding. +pub async fn list_statuses( + req: ListKanbanStatusesRequest, +) -> Result { + use crate::api::providers::kanban::KanbanProvider; + + let statuses = match req.provider { + KanbanProviderKind::Jira => { + let creds = req.jira.ok_or_else(|| { + ApiError::BadRequest("Missing `jira` field for jira provider".to_string()) + })?; + let provider = JiraProvider::new(creds.domain, creds.email, creds.api_token); + provider + .list_statuses(&req.project_key) + .await + .map_err(|e| ApiError::BadRequest(provider_error_message(&e)))? + } + KanbanProviderKind::Linear => { + let creds = req.linear.ok_or_else(|| { + ApiError::BadRequest("Missing `linear` field for linear provider".to_string()) + })?; + let provider = LinearProvider::new(creds.api_key); + provider + .list_statuses(&req.project_key) + .await + .map_err(|e| ApiError::BadRequest(provider_error_message(&e)))? + } + KanbanProviderKind::Github => { + let creds = req.github.ok_or_else(|| { + ApiError::BadRequest("Missing `github` field for github provider".to_string()) + })?; + let provider = + GithubProjectsProvider::new(creds.token, "OPERATOR_GITHUB_TOKEN".to_string()); + provider + .list_statuses(&req.project_key) + .await + .map_err(|e| ApiError::BadRequest(provider_error_message(&e)))? + } + }; + + Ok(ListKanbanStatusesResponse { statuses }) +} + // ─── write_config ─────────────────────────────────────────────────────────── /// Write or upsert a kanban config section to `config.toml`. @@ -249,6 +296,7 @@ pub fn write_config( &body.api_key_env, &body.project_key, &body.sync_user_id, + body.status_mapping.unwrap_or_default(), ); format!("[kanban.jira.\"{}\"]", body.domain) } @@ -261,6 +309,7 @@ pub fn write_config( &body.api_key_env, &body.project_key, &body.sync_user_id, + body.status_mapping.unwrap_or_default(), ); format!("[kanban.linear.\"{}\"]", body.workspace_key) } @@ -273,6 +322,7 @@ pub fn write_config( &body.api_key_env, &body.project_key, &body.sync_user_id, + body.status_mapping.unwrap_or_default(), ); format!("[kanban.github.\"{}\"]", body.owner) } @@ -427,6 +477,7 @@ mod tests { api_key_env: "OPERATOR_JIRA_API_KEY".to_string(), project_key: "PROJ".to_string(), sync_user_id: "acct-123".to_string(), + status_mapping: None, }), linear: None, github: None, @@ -456,6 +507,7 @@ mod tests { api_key_env: "OPERATOR_LINEAR_API_KEY".to_string(), project_key: "ENG".to_string(), sync_user_id: "user-uuid-42".to_string(), + status_mapping: None, }), github: None, }; @@ -485,6 +537,7 @@ mod tests { api_key_env: "OPERATOR_JIRA_API_KEY".to_string(), project_key: "FIRST".to_string(), sync_user_id: "acct-1".to_string(), + status_mapping: None, }), linear: None, github: None, @@ -503,6 +556,7 @@ mod tests { api_key_env: "OPERATOR_JIRA_API_KEY".to_string(), project_key: "SECOND".to_string(), sync_user_id: "acct-2".to_string(), + status_mapping: None, }), linear: None, github: None, @@ -561,6 +615,7 @@ mod tests { api_key_env: "OPERATOR_GITHUB_TOKEN".to_string(), project_key: "PVT_kwDOABcdefg".to_string(), sync_user_id: "12345678".to_string(), + status_mapping: None, }), }; @@ -574,6 +629,54 @@ mod tests { assert!(contents.contains("12345678")); } + #[test] + fn test_write_config_persists_status_mapping() { + use crate::config::KanbanStatusMapping; + + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + + let req = WriteKanbanConfigRequest { + provider: KanbanProviderKind::Jira, + jira: Some(WriteJiraConfigBody { + domain: "acme.atlassian.net".to_string(), + email: "user@acme.com".to_string(), + api_key_env: "OPERATOR_JIRA_API_KEY".to_string(), + project_key: "PROJ".to_string(), + sync_user_id: "acct-123".to_string(), + status_mapping: Some(KanbanStatusMapping { + todo: Some("Backlog".to_string()), + doing: Some("In Progress".to_string()), + done: Some("Shipped".to_string()), + }), + }), + linear: None, + github: None, + }; + + write_config(req, Some(&path)).unwrap(); + + let reloaded: Config = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + let project = &reloaded.kanban.jira["acme.atlassian.net"].projects["PROJ"]; + assert_eq!(project.status_mapping.todo.as_deref(), Some("Backlog")); + assert_eq!(project.status_mapping.doing.as_deref(), Some("In Progress")); + assert_eq!(project.status_mapping.done.as_deref(), Some("Shipped")); + } + + #[test] + fn test_list_statuses_missing_creds_returns_bad_request() { + let req = ListKanbanStatusesRequest { + provider: KanbanProviderKind::Jira, + project_key: "PROJ".to_string(), + jira: None, + linear: None, + github: None, + }; + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(list_statuses(req)); + assert!(matches!(result, Err(ApiError::BadRequest(_)))); + } + #[test] fn test_validate_missing_jira_body_returns_bad_request() { let req = ValidateKanbanCredentialsRequest { diff --git a/src/services/kanban_sync.rs b/src/services/kanban_sync.rs index b025cfc5..78d92c98 100644 --- a/src/services/kanban_sync.rs +++ b/src/services/kanban_sync.rs @@ -16,7 +16,7 @@ use std::path::Path; use tracing::{debug, info, warn}; use crate::api::providers::kanban::{get_provider, ExternalIssue}; -use crate::config::{Config, ProjectSyncConfig}; +use crate::config::{Config, KanbanStatusMapping, ProjectSyncConfig}; use crate::issuetypes::kanban_type::KanbanIssueTypeRef; /// A collection that can be synced from a kanban provider @@ -30,8 +30,8 @@ pub struct SyncableCollection { pub collection_name: Option, /// User ID to sync issues for pub sync_user_id: String, - /// Statuses to sync (empty = default only) - pub sync_statuses: Vec, + /// Mapping of operator todo/doing/done to external board columns + pub status_mapping: KanbanStatusMapping, } /// Result of a sync operation @@ -90,7 +90,7 @@ impl KanbanSyncService { project_key: project_key.clone(), collection_name: project_config.collection_name.clone(), sync_user_id: project_config.sync_user_id.clone(), - sync_statuses: project_config.sync_statuses.clone(), + status_mapping: project_config.status_mapping.clone(), }); } } @@ -105,7 +105,7 @@ impl KanbanSyncService { project_key: project_key.clone(), collection_name: project_config.collection_name.clone(), sync_user_id: project_config.sync_user_id.clone(), - sync_statuses: project_config.sync_statuses.clone(), + status_mapping: project_config.status_mapping.clone(), }); } } @@ -120,7 +120,7 @@ impl KanbanSyncService { project_key: project_key.clone(), collection_name: project_config.collection_name.clone(), sync_user_id: project_config.sync_user_id.clone(), - sync_statuses: project_config.sync_statuses.clone(), + status_mapping: project_config.status_mapping.clone(), }); } } @@ -157,7 +157,7 @@ impl KanbanSyncService { .list_issues( project_key, &project_config.sync_user_id, - &project_config.sync_statuses, + &project_config.pull_statuses(), ) .await .context("Failed to fetch issues from provider")?; diff --git a/src/startup/mod.rs b/src/startup/mod.rs index 18837baf..30acfe1f 100644 --- a/src/startup/mod.rs +++ b/src/startup/mod.rs @@ -190,8 +190,8 @@ pub static SETUP_STEPS: &[SetupStepInfo] = &[ description: "Optionally create tickets to bootstrap your projects", help_text: "Create startup tickets to help initialize your projects:\n\ - **ASSESS tickets**: Scan projects for catalog-info.yaml, create if missing\n\ - - **AGENT-SETUP tickets**: Configure Claude agents for each project\n\ - - **PROJECT-INIT tickets**: Run both ASSESS and AGENT-SETUP for each project\n\n\ + - **AGENT_SETUP tickets**: Configure Claude agents for each project\n\ + - **PROJECT_INIT tickets**: Run both ASSESS and AGENT_SETUP for each project\n\n\ These tickets are optional and help automate common setup tasks.", navigation: "↑/↓ or j/k to navigate, Space to toggle, Enter to continue, Esc to go back", }, diff --git a/src/startup/templates.rs b/src/startup/templates.rs index a3254a42..b4447bf1 100644 --- a/src/startup/templates.rs +++ b/src/startup/templates.rs @@ -261,21 +261,20 @@ mod tests { scaffold_all_collections(&templates_path).unwrap(); - // All 5 collections should exist + // Every embedded collection should exist assert!(templates_path.join("simple").exists()); assert!(templates_path.join("dev_kanban").exists()); assert!(templates_path.join("devops_kanban").exists()); assert!(templates_path.join("operator").exists()); - assert!(templates_path.join("full").exists()); - - // full should have all 8 issuetypes - assert!(templates_path.join("full/TASK.json").exists()); - assert!(templates_path.join("full/FEAT.json").exists()); - assert!(templates_path.join("full/FIX.json").exists()); - assert!(templates_path.join("full/SPIKE.json").exists()); - assert!(templates_path.join("full/INV.json").exists()); - assert!(templates_path.join("full/ASSESS.json").exists()); - assert!(templates_path.join("full/SYNC.json").exists()); - assert!(templates_path.join("full/INIT.json").exists()); + assert!(templates_path.join("ralph_loop").exists()); + assert!(templates_path.join("jr_orchestration").exists()); + assert!(templates_path.join("elves_overnight").exists()); + + // `full` was demoted from the embedded set and must not scaffold + assert!(!templates_path.join("full").exists()); + + // Underscore-keyed operator types scaffold correctly + assert!(templates_path.join("operator/AGENT_SETUP.json").exists()); + assert!(templates_path.join("operator/PROJECT_INIT.json").exists()); } } diff --git a/src/templates/schema.rs b/src/templates/schema.rs index 632fbef0..10d1ba08 100644 --- a/src/templates/schema.rs +++ b/src/templates/schema.rs @@ -622,9 +622,17 @@ impl TemplateSchema { pub fn validate(&self) -> Result<(), Vec> { let mut errors = Vec::new(); - // Check key format - if !self.key.chars().all(|c| c.is_ascii_uppercase()) { - errors.push(format!("Key '{}' must be uppercase letters only", self.key)); + // Check key format: uppercase start, then uppercase/digit/underscore + // (mirrors IssueType::validate; hyphens conflict with ticket-id `-`) + let mut key_chars = self.key.chars(); + let valid_start = key_chars.next().is_some_and(|c| c.is_ascii_uppercase()); + let valid_rest = + key_chars.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_'); + if !(valid_start && valid_rest) { + errors.push(format!( + "Key '{}' must start with an uppercase letter and contain only uppercase letters, digits, and underscores", + self.key + )); } // Check that all required fields (except 'id' with auto=id) have defaults diff --git a/src/ui/dialogs/sync_confirm.rs b/src/ui/dialogs/sync_confirm.rs index e17076f2..8f6313b7 100644 --- a/src/ui/dialogs/sync_confirm.rs +++ b/src/ui/dialogs/sync_confirm.rs @@ -25,7 +25,7 @@ impl From<&crate::services::SyncableCollection> for SyncableCollectionDisplay { provider: collection.provider.clone(), project_key: collection.project_key.clone(), collection_name: collection.collection_name.clone(), - status_count: collection.sync_statuses.len(), + status_count: collection.status_mapping.mapped_count(), } } } @@ -223,11 +223,11 @@ impl SyncConfirmDialog { ), }; - // Status count suffix + // Mapped-column suffix let status_suffix = if collection.status_count > 0 { - format!(" ({} statuses)", collection.status_count) + format!(" ({}/3 columns mapped)", collection.status_count) } else { - " (default)".to_string() + " (no column mapping)".to_string() }; lines.push(Line::from(vec![ @@ -302,7 +302,10 @@ mod tests { project_key: "PROJ".to_string(), collection_name: Some("jira-proj".to_string()), sync_user_id: "user123".to_string(), - sync_statuses: vec!["To Do".to_string()], + status_mapping: crate::config::KanbanStatusMapping { + todo: Some("To Do".to_string()), + ..Default::default() + }, }]; dialog.show(collections); diff --git a/src/ui/kanban_view.rs b/src/ui/kanban_view.rs index 66064a8b..f899c452 100644 --- a/src/ui/kanban_view.rs +++ b/src/ui/kanban_view.rs @@ -23,7 +23,7 @@ pub struct KanbanCollectionInfo { /// User ID configured for sync (will be displayed when sync UI is expanded) #[allow(dead_code)] pub sync_user_id: String, - /// Number of statuses configured + /// Number of mapped todo/doing/done columns pub status_count: usize, } @@ -34,7 +34,7 @@ impl From<&SyncableCollection> for KanbanCollectionInfo { project_key: collection.project_key.clone(), collection_name: collection.collection_name.clone(), sync_user_id: collection.sync_user_id.clone(), - status_count: collection.sync_statuses.len(), + status_count: collection.status_mapping.mapped_count(), } } } @@ -288,14 +288,14 @@ impl KanbanView { Style::default().fg(Color::DarkGray), ); - // Status count + // Mapped-column count let status_info = if collection.status_count > 0 { Span::styled( - format!("({} statuses)", collection.status_count), + format!("({}/3 columns mapped)", collection.status_count), Style::default().fg(Color::DarkGray), ) } else { - Span::styled("(default)", Style::default().fg(Color::DarkGray)) + Span::styled("(no column mapping)", Style::default().fg(Color::DarkGray)) }; let line = Line::from(vec![provider_badge, project, collection_name, status_info]); @@ -368,7 +368,10 @@ mod tests { project_key: "PROJ".to_string(), collection_name: Some("jira-proj".to_string()), sync_user_id: "user123".to_string(), - sync_statuses: vec!["To Do".to_string()], + status_mapping: crate::config::KanbanStatusMapping { + todo: Some("To Do".to_string()), + ..Default::default() + }, }]; view.show(collections); @@ -390,14 +393,14 @@ mod tests { project_key: "PROJ1".to_string(), collection_name: Some("jira-proj1".to_string()), sync_user_id: "user1".to_string(), - sync_statuses: vec![], + status_mapping: crate::config::KanbanStatusMapping::default(), }, SyncableCollection { provider: "linear".to_string(), project_key: "ENG".to_string(), collection_name: Some("linear-eng".to_string()), sync_user_id: "user2".to_string(), - sync_statuses: vec![], + status_mapping: crate::config::KanbanStatusMapping::default(), }, ]; @@ -428,7 +431,7 @@ mod tests { project_key: "PROJ".to_string(), collection_name: Some("jira-proj".to_string()), sync_user_id: "user123".to_string(), - sync_statuses: vec![], + status_mapping: crate::config::KanbanStatusMapping::default(), }]; view.show(collections); diff --git a/src/ui/setup/mod.rs b/src/ui/setup/mod.rs index d8cdaf78..6f229825 100644 --- a/src/ui/setup/mod.rs +++ b/src/ui/setup/mod.rs @@ -56,7 +56,7 @@ pub struct SetupScreen { pub task_optional_fields: Vec, /// List state for field configuration selection pub(crate) field_state: ListState, - /// Startup ticket options (ASSESS, AGENT-SETUP, PROJECT-INIT) + /// Startup ticket options (ASSESS, `AGENT_SETUP`, `PROJECT_INIT`) pub startup_ticket_options: Vec, /// List state for startup ticket selection pub(crate) startup_state: ListState, diff --git a/src/ui/setup/types.rs b/src/ui/setup/types.rs index 0ffeb3bd..187d1007 100644 --- a/src/ui/setup/types.rs +++ b/src/ui/setup/types.rs @@ -176,14 +176,14 @@ impl StartupTicketOption { }, StartupTicketOption { key: "agent_setup", - name: "AGENT-SETUP tickets", + name: "AGENT_SETUP tickets", description: "Configure Claude agents for each project", enabled: false, }, StartupTicketOption { key: "project_init", - name: "PROJECT-INIT tickets", - description: "Run both ASSESS and AGENT-SETUP for each project", + name: "PROJECT_INIT tickets", + description: "Run both ASSESS and AGENT_SETUP for each project", enabled: false, }, ] diff --git a/tests/community_collections.rs b/tests/community_collections.rs new file mode 100644 index 00000000..fd03801f --- /dev/null +++ b/tests/community_collections.rs @@ -0,0 +1,45 @@ +//! CI gate for community-contributed collections. +//! +//! Every directory under `collections/community/` must be a valid shareable +//! collection: correct manifest, community-tier attribution (license, author, +//! url), and parseable issuetype schemas. A broken submission fails here, +//! never at a user's install. + +use std::path::PathBuf; + +use operator::collections::manifest::CollectionTier; +use operator::collections::validate::validate_collection_dir; + +fn community_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("collections") + .join("community") +} + +#[test] +fn test_all_community_collections_are_valid() { + let dir = community_dir(); + if !dir.is_dir() { + // Green when the community tier is empty. + return; + } + + let mut validated = 0usize; + for entry in std::fs::read_dir(&dir).expect("read collections/community") { + let path = entry.expect("dir entry").path(); + if !path.is_dir() { + continue; + } + let manifest = validate_collection_dir(&path) + .unwrap_or_else(|e| panic!("{} is invalid: {e:#}", path.display())); + assert_eq!( + manifest.tier, + CollectionTier::Community, + "{}: community-hosted collections must declare tier: community", + manifest.id + ); + validated += 1; + } + + println!("validated {validated} community collection(s)"); +} diff --git a/vscode-extension/src/api-client.ts b/vscode-extension/src/api-client.ts index cddde505..85964391 100644 --- a/vscode-extension/src/api-client.ts +++ b/vscode-extension/src/api-client.ts @@ -610,6 +610,27 @@ export class OperatorApiClient { return (await response.json()) as ExternalIssueTypeSummary[]; } + /** + * Get the external board's workflow statuses/columns for a configured + * provider/project — populates the todo/doing/done mapping dropdowns. + */ + async getKanbanStatuses(provider: string, projectKey: string): Promise { + const response = await fetch( + `${this.baseUrl}/api/v1/kanban/${encodeURIComponent(provider)}/${encodeURIComponent(projectKey)}/statuses` + ); + + if (!response.ok) { + const error = (await response.json().catch(() => ({ + error: 'unknown', + message: `HTTP ${response.status}: ${response.statusText}`, + }))) as ApiError; + throw new Error(error.message); + } + + const body = (await response.json()) as { statuses: string[] }; + return body.statuses; + } + /** * Sync kanban issue types from a provider for a project. * Triggers a fresh fetch from the external provider and persists to the local catalog. diff --git a/vscode-extension/src/config-panel.ts b/vscode-extension/src/config-panel.ts index 8ecdc3bb..86c73295 100644 --- a/vscode-extension/src/config-panel.ts +++ b/vscode-extension/src/config-panel.ts @@ -508,6 +508,32 @@ export class ConfigPanel { } break; } + + case 'getKanbanStatuses': { + const provider = message.provider as string; + const projectKey = message.projectKey as string; + try { + const workDir = resolveWorkingDirectory(); + const ticketsDir = workDir ? path.join(workDir, '.tickets') : undefined; + const apiUrl = await discoverApiUrl(ticketsDir); + const client = new OperatorApiClient(apiUrl); + const statuses = await client.getKanbanStatuses(provider, projectKey); + void this._panel.webview.postMessage({ + type: 'kanbanStatusesLoaded', + provider, + projectKey, + statuses, + }); + } catch (err) { + void this._panel.webview.postMessage({ + type: 'kanbanStatusesError', + provider, + projectKey, + error: err instanceof Error ? err.message : 'Failed to load kanban statuses', + }); + } + break; + } } } @@ -600,7 +626,7 @@ export const KANBAN_PROVIDER_SLUGS: string[] = Object.keys(KANBAN_PROVIDERS); /** Project-level fields written into the first project sub-table by shorthand. */ const KANBAN_PROJECT_LEVEL_KEYS = [ - 'sync_statuses', + 'status_mapping', 'collection_name', 'sync_user_id', 'type_mappings', diff --git a/vscode-extension/test/suite/config-panel.test.ts b/vscode-extension/test/suite/config-panel.test.ts index 7fc09dd1..f688a6eb 100644 --- a/vscode-extension/test/suite/config-panel.test.ts +++ b/vscode-extension/test/suite/config-panel.test.ts @@ -132,6 +132,28 @@ suite('Config Panel Kanban Providers', () => { assert.ok(proj, `Expected project "PROJ" sub-table for ${slug}`); assert.strictEqual(proj.sync_user_id, 'user-123'); }); + + test(`${slug}: status_mapping object round-trips via projects path and shorthand`, () => { + const mapping = { todo: 'To Do', doing: 'In Progress', done: 'Done' }; + + // Explicit projects..status_mapping path (ProjectRow write path) + const kanban: Record = {}; + applyKanbanProviderField(kanban, slug, 'projects.PROJ.status_mapping', mapping); + let providerMap = kanban[slug] as Record; + let instance = providerMap[meta.defaultInstanceKey] as Record; + let proj = (instance.projects as Record).PROJ as Record; + assert.deepStrictEqual(proj.status_mapping, mapping); + + // Shorthand project-level key (single-project config forms) + const kanban2: Record = {}; + applyKanbanProviderField(kanban2, slug, 'status_mapping', mapping); + providerMap = kanban2[slug] as Record; + instance = providerMap[meta.defaultInstanceKey] as Record; + const projects = instance.projects as Record; + const firstKey = Object.keys(projects)[0]!; + proj = projects[firstKey] as Record; + assert.deepStrictEqual(proj.status_mapping, mapping); + }); } }); diff --git a/vscode-extension/webview-ui/App.tsx b/vscode-extension/webview-ui/App.tsx index 0fe834dc..0e72fba4 100644 --- a/vscode-extension/webview-ui/App.tsx +++ b/vscode-extension/webview-ui/App.tsx @@ -31,6 +31,7 @@ export function App() { const [issueTypes, setIssueTypes] = useState([]); const [collections, setCollections] = useState([]); const [externalIssueTypes, setExternalIssueTypes] = useState>(new Map()); + const [kanbanStatuses, setKanbanStatuses] = useState>(new Map()); useEffect(() => { const cleanup = onMessage((msg: ExtensionToWebviewMessage) => { @@ -87,6 +88,17 @@ export function App() { // External issue type lookup failed; the mapping panel renders an // empty/unmapped state, so no extra handling is required here. break; + case 'kanbanStatusesLoaded': + setKanbanStatuses(prev => { + const next = new Map(prev); + next.set(`${msg.provider}/${msg.projectKey}`, msg.statuses); + return next; + }); + break; + case 'kanbanStatusesError': + // Status discovery failed; the dropdowns fall back to free-form + // entry of the current mapping values, so no extra handling here. + break; } }); @@ -146,6 +158,10 @@ export function App() { postMessage({ type: 'getExternalIssueTypes', provider, domain, projectKey }); }, []); + const handleGetKanbanStatuses = useCallback((provider: string, projectKey: string) => { + postMessage({ type: 'getKanbanStatuses', provider, projectKey }); + }, []); + const handleOpenOperatorUi = useCallback((route: 'issuetypes' | 'projects') => { postMessage({ type: 'openOperatorUi', route }); }, []); @@ -176,6 +192,8 @@ export function App() { collections={collections} externalIssueTypes={externalIssueTypes} onGetExternalIssueTypes={handleGetExternalIssueTypes} + kanbanStatuses={kanbanStatuses} + onGetKanbanStatuses={handleGetKanbanStatuses} onOpenOperatorUi={handleOpenOperatorUi} /> ) : ( @@ -229,7 +247,7 @@ function deepMerge>(target: T, source: T): T { const DEFAULT_JIRA: JiraConfig = { enabled: false, api_key_env: 'OPERATOR_JIRA_API_KEY', email: '', projects: {} }; const DEFAULT_LINEAR: LinearConfig = { enabled: false, api_key_env: 'OPERATOR_LINEAR_API_KEY', projects: {} }; -const DEFAULT_PROJECT_SYNC: ProjectSyncConfig = { sync_user_id: '', sync_statuses: [], collection_name: null, type_mappings: {}, bidirectional: false }; +const DEFAULT_PROJECT_SYNC: ProjectSyncConfig = { sync_user_id: '', status_mapping: {}, collection_name: null, type_mappings: {}, bidirectional: false }; /** Apply an update to the config object by section/key path */ function applyUpdate( @@ -271,7 +289,7 @@ function applyUpdate( } else if (key === 'domain' && typeof value === 'string' && value !== domain) { delete jiraMap[domain]; jiraMap[value] = ws; - } else if (key === 'project_key' || key === 'sync_statuses' || key === 'collection_name' || key === 'sync_user_id' || key === 'type_mappings') { + } else if (key === 'project_key' || key === 'status_mapping' || key === 'collection_name' || key === 'sync_user_id' || key === 'type_mappings') { const projects = { ...ws.projects }; const pKeys = Object.keys(projects); const pKey = pKeys[0] ?? 'default'; @@ -316,7 +334,7 @@ function applyUpdate( } else if (key === 'team_id' && typeof value === 'string' && value !== teamId) { delete linearMap[teamId]; linearMap[value] = ws; - } else if (key === 'sync_statuses' || key === 'collection_name' || key === 'sync_user_id' || key === 'type_mappings') { + } else if (key === 'status_mapping' || key === 'collection_name' || key === 'sync_user_id' || key === 'type_mappings') { const projects = { ...ws.projects }; const pKeys = Object.keys(projects); const pKey = pKeys[0] ?? 'default'; diff --git a/vscode-extension/webview-ui/components/ConfigPage.tsx b/vscode-extension/webview-ui/components/ConfigPage.tsx index c97f4fea..41650902 100644 --- a/vscode-extension/webview-ui/components/ConfigPage.tsx +++ b/vscode-extension/webview-ui/components/ConfigPage.tsx @@ -37,6 +37,8 @@ interface ConfigPageProps { collections: CollectionResponse[]; externalIssueTypes: Map; onGetExternalIssueTypes: (provider: string, domain: string, projectKey: string) => void; + kanbanStatuses: Map; + onGetKanbanStatuses: (provider: string, projectKey: string) => void; onOpenOperatorUi: (route: 'issuetypes' | 'projects') => void; } @@ -58,6 +60,8 @@ export function ConfigPage({ collections, externalIssueTypes, onGetExternalIssueTypes, + kanbanStatuses, + onGetKanbanStatuses, onOpenOperatorUi, }: ConfigPageProps) { const scrollRef = useRef(null); @@ -144,6 +148,8 @@ export function ConfigPage({ collections={collections} externalIssueTypes={externalIssueTypes} onGetExternalIssueTypes={onGetExternalIssueTypes} + kanbanStatuses={kanbanStatuses} + onGetKanbanStatuses={onGetKanbanStatuses} onOpenOperatorUi={onOpenOperatorUi} /> void; onGetExternalIssueTypes: (provider: string, domain: string, projectKey: string) => void; + onGetKanbanStatuses: (provider: string, projectKey: string) => void; onViewIssueType: () => void; sectionKey: string; } +const OPERATOR_STATES = [ + { field: 'todo', label: 'Todo', helper: 'Pulled into the queue; requeue pushes back here' }, + { field: 'doing', label: 'Doing', helper: 'Pushed when a ticket is launched/claimed' }, + { field: 'done', label: 'Done', helper: 'Pushed when a ticket completes' }, +] as const; + export function ProjectRow({ provider, domain, @@ -35,14 +43,24 @@ export function ProjectRow({ collections, issueTypes, externalTypes, + statuses, onUpdate, onGetExternalIssueTypes, + onGetKanbanStatuses, onViewIssueType, sectionKey, }: ProjectRowProps) { const [expanded, setExpanded] = useState(false); const mappingCount = Object.keys(project.type_mappings ?? {}).length; + const statusMapping: KanbanStatusMapping = project.status_mapping ?? {}; + + // Lazily discover the board's real columns the first time the row expands. + useEffect(() => { + if (expanded && statuses === undefined) { + onGetKanbanStatuses(provider, projectKey); + } + }, [expanded, statuses, provider, projectKey, onGetKanbanStatuses]); const handleMappingChange = (externalName: string, operatorKey: string | '') => { const newMappings = { ...(project.type_mappings ?? {}) }; @@ -54,6 +72,25 @@ export function ProjectRow({ onUpdate(sectionKey, `projects.${projectKey}.type_mappings`, newMappings); }; + const handleStatusMappingChange = (field: 'todo' | 'doing' | 'done', column: string) => { + const next: KanbanStatusMapping = { ...statusMapping }; + if (column === '') { + delete next[field]; + } else { + next[field] = column; + } + onUpdate(sectionKey, `projects.${projectKey}.status_mapping`, next); + }; + + /** Discovered columns plus the currently-mapped value (so a stale mapping stays visible). */ + const optionsFor = (current: string | null | undefined): string[] => { + const opts = [...(statuses ?? [])]; + if (current && !opts.includes(current)) { + opts.push(current); + } + return opts; + }; + return ( e.stopPropagation()}> - {(project.sync_statuses ?? []).map((status) => ( - + {OPERATOR_STATES.filter(({ field }) => statusMapping[field]).map(({ field, label }) => ( + ))} @@ -106,19 +148,32 @@ export function ProjectRow({ - { - const statuses = e.target.value.split(',').map((s) => s.trim()).filter(Boolean); - onUpdate(sectionKey, `projects.${projectKey}.sync_statuses`, statuses); - }} - placeholder="To Do, In Progress" - fullWidth - sx={{ mb: 1 }} - helperText="Workflow statuses to sync (comma-separated)" - /> + + Column Mapping — map operator's todo/doing/done to this board's columns + + + {OPERATOR_STATES.map(({ field, label, helper }) => ( + + {label} + + + ))} + ; onGetExternalIssueTypes: (provider: string, domain: string, projectKey: string) => void; + kanbanStatuses: Map; + onGetKanbanStatuses: (provider: string, projectKey: string) => void; onViewIssueType: () => void; } @@ -50,6 +52,8 @@ export function ProviderCard({ issueTypes, externalIssueTypes, onGetExternalIssueTypes, + kanbanStatuses, + onGetKanbanStatuses, onViewIssueType, }: ProviderCardProps) { const [apiToken, setApiToken] = useState(''); @@ -227,8 +231,10 @@ export function ProviderCard({ collections={collections} issueTypes={issueTypes} externalTypes={externalIssueTypes.get(`${type}/${key}`)} + statuses={kanbanStatuses.get(`${type}/${key}`)} onUpdate={onUpdate} onGetExternalIssueTypes={onGetExternalIssueTypes} + onGetKanbanStatuses={onGetKanbanStatuses} onViewIssueType={onViewIssueType} sectionKey={sectionKey} /> diff --git a/vscode-extension/webview-ui/components/sections/KanbanProvidersSection.tsx b/vscode-extension/webview-ui/components/sections/KanbanProvidersSection.tsx index 2cc85cb8..b948b062 100644 --- a/vscode-extension/webview-ui/components/sections/KanbanProvidersSection.tsx +++ b/vscode-extension/webview-ui/components/sections/KanbanProvidersSection.tsx @@ -30,6 +30,8 @@ interface KanbanProvidersSectionProps { collections: CollectionResponse[]; externalIssueTypes: Map; onGetExternalIssueTypes: (provider: string, domain: string, projectKey: string) => void; + kanbanStatuses: Map; + onGetKanbanStatuses: (provider: string, projectKey: string) => void; onOpenOperatorUi: (route: 'issuetypes' | 'projects') => void; } @@ -50,6 +52,8 @@ export function KanbanProvidersSection({ collections, externalIssueTypes, onGetExternalIssueTypes, + kanbanStatuses, + onGetKanbanStatuses, onOpenOperatorUi, }: KanbanProvidersSectionProps) { // Iterate all Jira domains @@ -92,6 +96,8 @@ export function KanbanProvidersSection({ issueTypes={issueTypes} externalIssueTypes={externalIssueTypes} onGetExternalIssueTypes={onGetExternalIssueTypes} + kanbanStatuses={kanbanStatuses} + onGetKanbanStatuses={onGetKanbanStatuses} onViewIssueType={handleViewIssueType} /> )) @@ -108,6 +114,8 @@ export function KanbanProvidersSection({ issueTypes={issueTypes} externalIssueTypes={externalIssueTypes} onGetExternalIssueTypes={onGetExternalIssueTypes} + kanbanStatuses={kanbanStatuses} + onGetKanbanStatuses={onGetKanbanStatuses} onViewIssueType={handleViewIssueType} /> )} @@ -128,6 +136,8 @@ export function KanbanProvidersSection({ issueTypes={issueTypes} externalIssueTypes={externalIssueTypes} onGetExternalIssueTypes={onGetExternalIssueTypes} + kanbanStatuses={kanbanStatuses} + onGetKanbanStatuses={onGetKanbanStatuses} onViewIssueType={handleViewIssueType} /> )) @@ -144,6 +154,8 @@ export function KanbanProvidersSection({ issueTypes={issueTypes} externalIssueTypes={externalIssueTypes} onGetExternalIssueTypes={onGetExternalIssueTypes} + kanbanStatuses={kanbanStatuses} + onGetKanbanStatuses={onGetKanbanStatuses} onViewIssueType={handleViewIssueType} /> )} diff --git a/vscode-extension/webview-ui/types/messages.ts b/vscode-extension/webview-ui/types/messages.ts index 80f1d05f..a97d3f47 100644 --- a/vscode-extension/webview-ui/types/messages.ts +++ b/vscode-extension/webview-ui/types/messages.ts @@ -69,6 +69,7 @@ export type WebviewToExtensionMessage = | { type: 'getCollections' } | { type: 'activateCollection'; name: string } | { type: 'getExternalIssueTypes'; provider: string; domain: string; projectKey: string } + | { type: 'getKanbanStatuses'; provider: string; projectKey: string } | { type: 'createIssueType'; request: import('../../src/generated/CreateIssueTypeRequest').CreateIssueTypeRequest } | { type: 'updateIssueType'; key: string; request: import('../../src/generated/UpdateIssueTypeRequest').UpdateIssueTypeRequest } | { type: 'deleteIssueType'; key: string } @@ -100,6 +101,8 @@ export type ExtensionToWebviewMessage = | { type: 'collectionsError'; error: string } | { type: 'externalIssueTypesLoaded'; provider: string; projectKey: string; types: ExternalIssueTypeSummary[] } | { type: 'externalIssueTypesError'; provider: string; projectKey: string; error: string } + | { type: 'kanbanStatusesLoaded'; provider: string; projectKey: string; statuses: string[] } + | { type: 'kanbanStatusesError'; provider: string; projectKey: string; error: string } | { type: 'issueTypeCreated'; issueType: IssueTypeResponse } | { type: 'issueTypeUpdated'; issueType: IssueTypeResponse } | { type: 'issueTypeDeleted'; key: string } From be658272a9cc68bd4128eaf4a3460fa8913e208f Mon Sep 17 00:00:00 2001 From: untra Date: Wed, 1 Jul 2026 19:17:39 -0600 Subject: [PATCH 06/11] pr 2 store unification --- src/app/mod.rs | 9 +- src/app/tickets.rs | 20 +- src/issuetypes/loader.rs | 138 --------- src/issuetypes/mod.rs | 83 +----- src/startup/templates.rs | 406 +++++++++++++++++++++++++-- src/ui/dashboard.rs | 7 +- src/ui/sections/workflows_section.rs | 3 +- 7 files changed, 412 insertions(+), 254 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index f8b07885..28792293 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -212,11 +212,10 @@ impl App { // Initialize REST API server lifecycle manager let rest_api_server = RestApiServer::new(config.clone(), config.rest_api.port); - // Initialize issue type registry - let mut issue_type_registry = IssueTypeRegistry::new(); - if let Err(e) = issue_type_registry.load_all(&config.tickets_path()) { - tracing::warn!("Failed to load issue types: {}", e); - } + // Initialize issue type registry via the canonical loader shared with + // the REST API and CLI (collection-scoped .tickets/templates/ store). + let mut issue_type_registry = + crate::startup::templates::load_registry(&config.tickets_path()); // Activate configured collection if specified if let Some(ref active) = config.templates.active_collection { diff --git a/src/app/tickets.rs b/src/app/tickets.rs index b9afe9dc..176d28dc 100644 --- a/src/app/tickets.rs +++ b/src/app/tickets.rs @@ -97,18 +97,11 @@ impl App { }) .unwrap_or_default(); for (manifest, files) in &hosted { - let dir = tickets_path.join("templates").join(&manifest.id); - fs::create_dir_all(&dir)?; - fs::write( - dir.join("collection.json"), - format!("{}\n", manifest.to_json()?), + crate::startup::templates::write_fetched_collection( + &tickets_path.join("templates"), + manifest, + files, )?; - for (key, schema_json, template_md) in files { - fs::write(dir.join(format!("{key}.json")), schema_json)?; - if let Some(md) = template_md { - fs::write(dir.join(format!("{key}.md")), md)?; - } - } } if let [single] = hosted.as_slice() { self.config.templates.active_collection = Some(single.0.id.clone()); @@ -129,10 +122,7 @@ impl App { // Reload the issue type registry so the chosen collection is active // without requiring a restart (mirrors App::new's load path). - let mut registry = crate::issuetypes::IssueTypeRegistry::new(); - if let Err(e) = registry.load_all(&tickets_path) { - tracing::warn!("Failed to reload issue types after setup: {}", e); - } + let mut registry = crate::startup::templates::load_registry(&tickets_path); if let Some(ref active) = self.config.templates.active_collection { if let Err(e) = registry.activate_collection(active) { tracing::warn!("Failed to activate collection '{}': {}", active, e); diff --git a/src/issuetypes/loader.rs b/src/issuetypes/loader.rs index 8584a53b..7101fd45 100644 --- a/src/issuetypes/loader.rs +++ b/src/issuetypes/loader.rs @@ -74,65 +74,6 @@ fn template_schema_to_issuetype(schema: TemplateSchema, source: IssueTypeSource) } } -/// Load user-defined issue types from a directory -/// -/// Scans for *.json files in the directory and attempts to parse each as an `IssueType`. -/// Invalid files are logged as warnings and skipped. -pub fn load_user_types(path: &Path) -> Result> { - let mut types = HashMap::new(); - - if !path.exists() { - debug!( - "User issuetypes directory does not exist: {}", - path.display() - ); - return Ok(types); - } - - let entries = fs::read_dir(path) - .with_context(|| format!("Failed to read issuetypes directory: {}", path.display()))?; - - for entry in entries { - let entry = entry?; - let file_path = entry.path(); - - // Skip directories and non-JSON files - if file_path.is_dir() || file_path.extension().is_none_or(|e| e != "json") { - continue; - } - - // Skip imports directory - if file_path - .file_stem() - .is_some_and(|s| s == "imports" || s == "collections") - { - continue; - } - - match load_issuetype_file(&file_path) { - Ok(mut issue_type) => { - // Ensure source is marked as User - issue_type.source = IssueTypeSource::User; - debug!( - "Loaded user issue type: {} from {}", - issue_type.key, - file_path.display() - ); - types.insert(issue_type.key.clone(), issue_type); - } - Err(e) => { - warn!( - "Failed to load issue type from {}: {}", - file_path.display(), - e - ); - } - } - } - - Ok(types) -} - /// Load imported issue types from the imports subdirectory /// /// Structure: imports/{provider}/{project}/*.json @@ -580,13 +521,6 @@ mod tests { assert_eq!(feat.source, IssueTypeSource::Builtin); } - #[test] - fn test_load_user_types_empty_dir() { - let temp_dir = TempDir::new().unwrap(); - let types = load_user_types(temp_dir.path()).unwrap(); - assert!(types.is_empty()); - } - #[test] fn test_load_collection_metadata_reads_collection_json() { let temp_dir = TempDir::new().unwrap(); @@ -624,78 +558,6 @@ mod tests { assert_eq!(meta.type_order, vec!["FIX", "INV"]); } - #[test] - fn test_load_user_types_nonexistent_dir() { - let types = load_user_types(Path::new("/nonexistent/path")).unwrap(); - assert!(types.is_empty()); - } - - #[test] - fn test_load_user_types_with_file() { - let temp_dir = TempDir::new().unwrap(); - let json = r#"{ - "key": "STORY", - "name": "User Story", - "description": "A user story", - "mode": "autonomous", - "glyph": "S", - "fields": [ - {"name": "id", "description": "ID", "type": "string", "required": true, "auto": "id"} - ], - "steps": [ - {"name": "execute", "outputs": [], "prompt": "Execute", "allowed_tools": ["*"]} - ] - }"#; - fs::write(temp_dir.path().join("STORY.json"), json).unwrap(); - - let types = load_user_types(temp_dir.path()).unwrap(); - assert_eq!(types.len(), 1); - assert!(types.contains_key("STORY")); - - let story = types.get("STORY").unwrap(); - assert_eq!(story.name, "User Story"); - assert_eq!(story.source, IssueTypeSource::User); - } - - #[test] - fn test_load_user_types_skips_invalid() { - let temp_dir = TempDir::new().unwrap(); - - // Valid file - let valid_json = r#"{ - "key": "VALID", - "name": "Valid", - "description": "Valid type", - "mode": "autonomous", - "glyph": "V", - "fields": [ - {"name": "id", "description": "ID", "type": "string", "required": true, "auto": "id"} - ], - "steps": [ - {"name": "execute", "outputs": [], "prompt": "Execute", "allowed_tools": ["*"]} - ] - }"#; - fs::write(temp_dir.path().join("VALID.json"), valid_json).unwrap(); - - // Invalid file (lowercase key) - let invalid_json = r#"{ - "key": "invalid", - "name": "Invalid", - "description": "Invalid type", - "mode": "autonomous", - "glyph": "I", - "fields": [], - "steps": [ - {"name": "execute", "outputs": [], "prompt": "Execute", "allowed_tools": ["*"]} - ] - }"#; - fs::write(temp_dir.path().join("invalid.json"), invalid_json).unwrap(); - - let types = load_user_types(temp_dir.path()).unwrap(); - assert_eq!(types.len(), 1); - assert!(types.contains_key("VALID")); - } - #[test] fn test_load_collections() { let temp_dir = TempDir::new().unwrap(); diff --git a/src/issuetypes/mod.rs b/src/issuetypes/mod.rs index 474f13a4..53287ae1 100644 --- a/src/issuetypes/mod.rs +++ b/src/issuetypes/mod.rs @@ -1,23 +1,14 @@ -//! # Partially Integrated Module: Dynamic Issue Type Registry +//! # Dynamic Issue Type Registry //! -//! **Status**: Complete implementation, partially integrated +//! Registry for loading, managing, and querying issue types with support +//! for collections and kanban-provider imports. //! -//! **Purpose**: Dynamic registry system for loading, managing, and querying issue types -//! with support for user-defined types, collections, and preset configurations. -//! -//! **Current Integration**: -//! - Schema definitions used internally by `templates` module -//! - Builtin collections (simple, `dev_kanban`, `devops_kanban`) defined -//! - Registry loading and validation implemented -//! -//! **Not Yet Integrated**: -//! - Dynamic registry not exposed to TUI for runtime switching -//! - User-defined issue types from `.tickets/operator/issuetypes/` not loaded -//! - Collection switching not available in UI -//! -//! **Integration Point**: `templates/mod.rs` for runtime loading, `ui/create_dialog.rs` for selection -//! -//! **Milestone**: TBD - When custom issue type workflows are prioritized +//! Every surface (TUI, REST API, CLI) builds its registry through the single +//! canonical loader `crate::startup::templates::load_registry`, which reads +//! the collection-scoped `.tickets/templates//` store, migrates +//! legacy flat user types into a `custom` collection, loads kanban imports +//! from `.tickets/operator/issuetypes/imports/`, and honors a legacy +//! `collections.toml`. //! //! ## Components //! @@ -26,13 +17,10 @@ //! - [`IssueTypeRegistry`]: Central manager for all issue types and collections //! - [`BuiltinPreset`]: Predefined collection configurations //! -//! ## Usage When Fully Integrated +//! ## Usage //! //! ```rust,ignore -//! use crate::issuetypes::IssueTypeRegistry; -//! -//! let mut registry = IssueTypeRegistry::new(); -//! registry.load_all(&tickets_path)?; +//! let mut registry = crate::startup::templates::load_registry(&tickets_path); //! registry.activate_collection("devops_kanban")?; //! //! for issue_type in registry.active_types() { @@ -99,24 +87,6 @@ impl IssueTypeRegistry { Ok(()) } - /// Load user-defined issue types from a directory - pub fn load_user_types(&mut self, path: &Path) -> Result<()> { - let user_types = loader::load_user_types(path)?; - let count = user_types.len(); - - for (key, issue_type) in user_types { - if self.types.contains_key(&key) { - debug!("User type '{}' overrides builtin", key); - } - self.types.insert(key, issue_type); - } - - if count > 0 { - info!("Loaded {} user-defined issue types", count); - } - Ok(()) - } - /// Load imported issue types from imports directory pub fn load_imports(&mut self, imports_path: &Path) -> Result<()> { let imported = loader::load_imported_types(imports_path)?; @@ -164,37 +134,6 @@ impl IssueTypeRegistry { Ok(()) } - /// Load all issue types and collections from standard paths - /// - /// Standard paths: - /// - `.tickets/operator/issuetypes/` for user types - /// - `.tickets/operator/issuetypes/imports/` for imported types - /// - `.tickets/operator/issuetypes/collections.toml` for collections - pub fn load_all(&mut self, tickets_path: &Path) -> Result<()> { - // First load builtins - self.load_builtins()?; - - let issuetypes_path = tickets_path.join("operator/issuetypes"); - if issuetypes_path.exists() { - // Load user types - self.load_user_types(&issuetypes_path)?; - - // Load imports - let imports_path = issuetypes_path.join("imports"); - if imports_path.exists() { - self.load_imports(&imports_path)?; - } - - // Load collections - let collections_path = issuetypes_path.join("collections.toml"); - if collections_path.exists() { - self.load_collections(&collections_path)?; - } - } - - Ok(()) - } - /// Load issue types and collections from directory structure /// /// Flattened directory structure (no issues/ subfolder): diff --git a/src/startup/templates.rs b/src/startup/templates.rs index b4447bf1..9954ba6a 100644 --- a/src/startup/templates.rs +++ b/src/startup/templates.rs @@ -8,18 +8,29 @@ use std::fs; use std::path::Path; use tracing::info; +use crate::collections::manifest::{ + CollectionManifest, CollectionTier, IssueTypeEntry, SCHEMA_VERSION, +}; use crate::collections::{ get_embedded_collection, EmbeddedCollection, EMBEDDED_COLLECTIONS, EMBEDDED_SCHEMAS, }; use crate::issuetypes::IssueTypeRegistry; +/// Marker file written into the legacy user-types directory once its +/// contents have been migrated into `.tickets/templates/custom/`. +const MIGRATION_MARKER: &str = "MIGRATED.md"; + /// Build an `IssueTypeRegistry` for a workspace using the canonical loading /// priority, so every surface (REST API, CLI, TUI) resolves the same issue /// types from the same place: /// -/// 1. Load from `.tickets/templates/` (collection-scoped structure). -/// 2. If empty, initialize default templates from embedded files, then reload. -/// 3. Fall back to embedded builtins if filesystem loading fails or yields none. +/// 1. Initialize default templates from embedded files if `.tickets/templates/` +/// is missing or empty. +/// 2. Migrate legacy user types (`.tickets/operator/issuetypes/*.json`) into a +/// `custom` collection, once (non-destructive, marker-guarded). +/// 3. Load from `.tickets/templates/` (collection-scoped structure), falling +/// back to embedded builtins if that fails or yields nothing. +/// 4. Load kanban-provider imports and honor a legacy `collections.toml`. pub fn load_registry(tickets_path: &Path) -> IssueTypeRegistry { let mut registry = IssueTypeRegistry::new(); let templates_path = tickets_path.join("templates"); @@ -29,7 +40,24 @@ pub fn load_registry(tickets_path: &Path) -> IssueTypeRegistry { tracing::warn!("Failed to ensure schema files: {}", e); } - match registry.load_from_templates_dir(&templates_path) { + if let Err(e) = init_default_templates(&templates_path) { + tracing::warn!("Failed to initialize default templates: {}", e); + } + + if let Err(e) = migrate_legacy_user_types(tickets_path, &templates_path) { + tracing::warn!("Failed to migrate legacy user types: {}", e); + } + + load_templates_or_builtins(&mut registry, &templates_path); + load_legacy_extras(&mut registry, tickets_path); + + registry +} + +/// Load the collection-scoped templates directory, falling back to embedded +/// builtins when it fails or yields nothing. +fn load_templates_or_builtins(registry: &mut IssueTypeRegistry, templates_path: &Path) { + match registry.load_from_templates_dir(templates_path) { Ok(()) if registry.type_count() > 0 => { info!( "Loaded {} issue types from templates directory", @@ -37,19 +65,9 @@ pub fn load_registry(tickets_path: &Path) -> IssueTypeRegistry { ); } Ok(()) => { - // Templates directory empty or absent — initialize defaults. - info!("Templates directory empty, initializing defaults..."); - if let Err(e) = init_default_templates(&templates_path) { - tracing::warn!("Failed to initialize default templates: {}", e); - } else if let Err(e) = registry.load_from_templates_dir(&templates_path) { - tracing::warn!("Failed to load initialized templates: {}", e); - } - - if registry.type_count() == 0 { - info!("Falling back to embedded builtin types"); - if let Err(e) = registry.load_builtins() { - tracing::warn!("Failed to load builtin issue types: {}", e); - } + info!("Falling back to embedded builtin types"); + if let Err(e) = registry.load_builtins() { + tracing::warn!("Failed to load builtin issue types: {}", e); } } Err(e) => { @@ -59,8 +77,160 @@ pub fn load_registry(tickets_path: &Path) -> IssueTypeRegistry { } } } +} - registry +/// Load the legacy extras that live outside the collection store: kanban +/// imports (provider/project-scoped, deliberately not shareable) and the +/// deprecated key-grouping `collections.toml`. +fn load_legacy_extras(registry: &mut IssueTypeRegistry, tickets_path: &Path) { + let legacy_path = tickets_path.join("operator/issuetypes"); + + let imports_path = legacy_path.join("imports"); + if imports_path.is_dir() { + if let Err(e) = registry.load_imports(&imports_path) { + tracing::warn!("Failed to load imported issue types: {}", e); + } + } + + let collections_toml = legacy_path.join("collections.toml"); + if collections_toml.is_file() { + if let Err(e) = registry.load_collections(&collections_toml) { + tracing::warn!("Failed to load collections.toml: {}", e); + } + } +} + +/// Write a fetched (or synthesized) collection into its collection-scoped +/// directory: `templates//collection.json` + `.json`/`.md`. +/// +/// `files` entries are `(key, schema_json, optional template_md)` — the shape +/// hosted fetches produce. +pub fn write_fetched_collection( + templates_path: &Path, + manifest: &CollectionManifest, + files: &[(String, String, Option)], +) -> Result<()> { + let dir = templates_path.join(&manifest.id); + fs::create_dir_all(&dir) + .with_context(|| format!("Failed to create collection directory: {}", dir.display()))?; + + fs::write( + dir.join("collection.json"), + format!("{}\n", manifest.to_json()?), + )?; + + for (key, schema_json, template_md) in files { + fs::write(dir.join(format!("{key}.json")), schema_json)?; + if let Some(md) = template_md { + fs::write(dir.join(format!("{key}.md")), md)?; + } + } + + info!( + "Wrote collection '{}' with {} issue types", + manifest.id, + files.len() + ); + Ok(()) +} + +/// One-time, non-destructive migration of legacy flat user types +/// (`.tickets/operator/issuetypes/*.json`) into a collection-scoped +/// `templates/custom/` collection. +/// +/// Skipped when the marker file exists or `templates/custom/` is already +/// present. Originals are kept; a `MIGRATED.md` marker records the move. +fn migrate_legacy_user_types(tickets_path: &Path, templates_path: &Path) -> Result<()> { + let legacy = tickets_path.join("operator/issuetypes"); + if !legacy.is_dir() || legacy.join(MIGRATION_MARKER).exists() { + return Ok(()); + } + let custom_dir = templates_path.join("custom"); + if custom_dir.exists() { + // Never overwrite an existing custom collection. + return Ok(()); + } + + let mut files: Vec<(String, String, Option)> = Vec::new(); + for entry in fs::read_dir(&legacy)? { + let path = entry?.path(); + if path.is_dir() || path.extension().is_none_or(|e| e != "json") { + continue; + } + if path + .file_stem() + .is_some_and(|s| s == "collection" || s == "issuetype_schema") + { + continue; + } + match crate::issuetypes::loader::load_issuetype_file(&path) { + Ok(issue_type) => { + let schema_json = fs::read_to_string(&path)?; + let template_md = fs::read_to_string(path.with_extension("md")).ok(); + files.push((issue_type.key, schema_json, template_md)); + } + Err(e) => { + tracing::warn!( + "Skipping legacy user type {} during migration: {}", + path.display(), + e + ); + } + } + } + + if files.is_empty() { + return Ok(()); + } + files.sort_by(|a, b| a.0.cmp(&b.0)); + + let manifest = CollectionManifest { + schema_version: SCHEMA_VERSION, + id: "custom".to_string(), + name: "Custom".to_string(), + description: "User-defined issue types migrated from .tickets/operator/issuetypes/" + .to_string(), + version: "1.0.0".to_string(), + publisher: None, + author: None, + url: None, + license: None, + tags: vec!["custom".to_string()], + compatibility: None, + tier: CollectionTier::default(), + kanban_defaults: None, + issue_types: files + .iter() + .map(|(key, _, md)| IssueTypeEntry { + key: key.clone(), + schema_path: format!("{key}.json"), + schema_checksum: String::new(), + template_path: md.as_ref().map(|_| format!("{key}.md")), + template_checksum: None, + workflow_preview_path: None, + }) + .collect(), + workflow_hints: None, + default_selected: files.iter().map(|(key, _, _)| key.clone()).collect(), + checksum: None, + }; + + write_fetched_collection(templates_path, &manifest, &files)?; + + fs::write( + legacy.join(MIGRATION_MARKER), + "# Migrated\n\nThe issue types in this directory were copied into\n\ + `.tickets/templates/custom/` (the collection-scoped store that all\n\ + operator surfaces read). Edit them there; these originals are kept\n\ + for reference and are no longer loaded. Delete this file to re-run\n\ + the migration.\n", + )?; + + info!( + "Migrated {} legacy user type(s) into templates/custom/", + files.len() + ); + Ok(()) } /// Initialize the templates directory with default collections @@ -79,7 +249,9 @@ pub fn load_registry(tickets_path: &Path) -> IssueTypeRegistry { /// └── ... /// ``` pub fn init_default_templates(templates_path: &Path) -> Result<()> { - if templates_path.exists() { + let has_entries = templates_path.exists() + && fs::read_dir(templates_path).is_ok_and(|mut d| d.next().is_some()); + if has_entries { info!( "Templates directory already exists: {}", templates_path.display() @@ -187,8 +359,204 @@ pub fn ensure_schemas(tickets_path: &Path) -> Result<()> { #[cfg(test)] mod tests { use super::*; + use crate::collections::manifest::{CollectionManifest, IssueTypeEntry}; + use std::path::PathBuf; use tempfile::TempDir; + const CUSTOM_TYPE_JSON: &str = r#"{ + "key": "GAST", + "name": "Gastown", + "description": "A custom user type", + "mode": "autonomous", + "glyph": "g", + "fields": [], + "steps": [{"name": "execute", "outputs": ["report"], "prompt": "Do it."}] + }"#; + + fn fetched_manifest(id: &str) -> CollectionManifest { + CollectionManifest::from_json(&format!( + r#"{{ + "schema_version": 1, + "id": "{id}", + "name": "Fetched", + "description": "A fetched collection", + "issue_types": [ + {{"key": "GAST", "schema_path": "GAST.json", "template_path": "GAST.md"}} + ] + }}"# + )) + .unwrap() + } + + fn legacy_dir(tickets_path: &std::path::Path) -> PathBuf { + tickets_path.join("operator/issuetypes") + } + + #[test] + fn test_write_fetched_collection_round_trips_through_loader() { + let temp_dir = TempDir::new().unwrap(); + let templates_path = temp_dir.path().join("templates"); + + let manifest = fetched_manifest("fetched_loop"); + let files = vec![( + "GAST".to_string(), + CUSTOM_TYPE_JSON.to_string(), + Some("# Gastown: {{ summary }}\n".to_string()), + )]; + write_fetched_collection(&templates_path, &manifest, &files).unwrap(); + + assert!(templates_path.join("fetched_loop/collection.json").exists()); + assert!(templates_path.join("fetched_loop/GAST.json").exists()); + assert!(templates_path.join("fetched_loop/GAST.md").exists()); + + // The written layout must load through the canonical loader. + let mut registry = IssueTypeRegistry::new(); + registry.load_from_templates_dir(&templates_path).unwrap(); + assert!(registry.get("GAST").is_some()); + assert!(registry.get_collection("fetched_loop").is_some()); + } + + #[test] + fn test_write_fetched_collection_without_template_md() { + let temp_dir = TempDir::new().unwrap(); + let templates_path = temp_dir.path().join("templates"); + + let mut manifest = fetched_manifest("fetched_loop"); + manifest.issue_types = vec![IssueTypeEntry { + key: "GAST".to_string(), + schema_path: "GAST.json".to_string(), + schema_checksum: String::new(), + template_path: None, + template_checksum: None, + workflow_preview_path: None, + }]; + let files = vec![("GAST".to_string(), CUSTOM_TYPE_JSON.to_string(), None)]; + write_fetched_collection(&templates_path, &manifest, &files).unwrap(); + + assert!(templates_path.join("fetched_loop/GAST.json").exists()); + assert!(!templates_path.join("fetched_loop/GAST.md").exists()); + } + + #[test] + fn test_load_registry_migrates_legacy_user_types() { + let temp_dir = TempDir::new().unwrap(); + let tickets_path = temp_dir.path(); + let legacy = legacy_dir(tickets_path); + std::fs::create_dir_all(&legacy).unwrap(); + std::fs::write(legacy.join("gast.json"), CUSTOM_TYPE_JSON).unwrap(); + std::fs::write(legacy.join("gast.md"), "# Gastown\n").unwrap(); + + let registry = load_registry(tickets_path); + + // Migrated into a collection-scoped `custom` collection, canonical names. + let custom = tickets_path.join("templates/custom"); + assert!(custom.join("collection.json").exists()); + assert!(custom.join("GAST.json").exists()); + assert!(custom.join("GAST.md").exists()); + // Non-destructive: originals stay, marker written. + assert!(legacy.join("gast.json").exists()); + assert!(legacy.join("MIGRATED.md").exists()); + // And the type is served by the unified registry. + assert!(registry.get("GAST").is_some()); + assert!(registry.get_collection("custom").is_some()); + } + + #[test] + fn test_load_registry_migration_is_idempotent() { + let temp_dir = TempDir::new().unwrap(); + let tickets_path = temp_dir.path(); + let legacy = legacy_dir(tickets_path); + std::fs::create_dir_all(&legacy).unwrap(); + std::fs::write(legacy.join("gast.json"), CUSTOM_TYPE_JSON).unwrap(); + + let _ = load_registry(tickets_path); + let first = + std::fs::read_to_string(tickets_path.join("templates/custom/GAST.json")).unwrap(); + + // Second run: no error, no re-write. + let registry = load_registry(tickets_path); + assert!(registry.get("GAST").is_some()); + + // Marker blocks re-migration even if the migrated copy is deleted. + std::fs::remove_dir_all(tickets_path.join("templates/custom")).unwrap(); + let registry = load_registry(tickets_path); + assert!(!tickets_path.join("templates/custom").exists()); + assert!(registry.get("GAST").is_none()); + let _ = first; + } + + #[test] + fn test_migration_skips_invalid_legacy_files() { + let temp_dir = TempDir::new().unwrap(); + let tickets_path = temp_dir.path(); + let legacy = legacy_dir(tickets_path); + std::fs::create_dir_all(&legacy).unwrap(); + std::fs::write(legacy.join("gast.json"), CUSTOM_TYPE_JSON).unwrap(); + std::fs::write(legacy.join("broken.json"), "{not valid json").unwrap(); + + let registry = load_registry(tickets_path); + + // Valid type migrated; broken file skipped without aborting. + assert!(tickets_path.join("templates/custom/GAST.json").exists()); + assert!(registry.get("GAST").is_some()); + assert!(legacy.join(MIGRATION_MARKER).exists()); + } + + #[test] + fn test_load_registry_skips_migration_when_custom_exists() { + let temp_dir = TempDir::new().unwrap(); + let tickets_path = temp_dir.path(); + let legacy = legacy_dir(tickets_path); + std::fs::create_dir_all(&legacy).unwrap(); + std::fs::write(legacy.join("gast.json"), CUSTOM_TYPE_JSON).unwrap(); + + // A pre-existing custom collection must not be overwritten. + let custom = tickets_path.join("templates/custom"); + std::fs::create_dir_all(&custom).unwrap(); + std::fs::write( + custom.join("collection.json"), + r#"{"schema_version": 1, "id": "custom", "name": "Custom", "issue_types": []}"#, + ) + .unwrap(); + + let _ = load_registry(tickets_path); + assert!(!custom.join("GAST.json").exists()); + } + + #[test] + fn test_load_registry_loads_kanban_imports() { + let temp_dir = TempDir::new().unwrap(); + let tickets_path = temp_dir.path(); + let imports = legacy_dir(tickets_path).join("imports/jira/myproj"); + std::fs::create_dir_all(&imports).unwrap(); + std::fs::write(imports.join("GAST.json"), CUSTOM_TYPE_JSON).unwrap(); + + let registry = load_registry(tickets_path); + // Imports register under {PROJECT}_{KEY} and stay out of collections. + assert!(registry.get("MYPROJ_GAST").is_some()); + } + + #[test] + fn test_load_registry_honors_legacy_collections_toml() { + let temp_dir = TempDir::new().unwrap(); + let tickets_path = temp_dir.path(); + let legacy = legacy_dir(tickets_path); + std::fs::create_dir_all(&legacy).unwrap(); + std::fs::write( + legacy.join("collections.toml"), + r#" +[collections.mygroup] +name = "mygroup" +description = "Legacy grouping" +types = ["TASK", "FEAT"] +"#, + ) + .unwrap(); + + let registry = load_registry(tickets_path); + assert!(registry.get_collection("mygroup").is_some()); + } + #[test] fn test_init_default_templates() { let temp_dir = TempDir::new().unwrap(); diff --git a/src/ui/dashboard.rs b/src/ui/dashboard.rs index 37207c90..ef3a68fe 100644 --- a/src/ui/dashboard.rs +++ b/src/ui/dashboard.rs @@ -149,10 +149,9 @@ impl Dashboard { /// Load active issue types from the registry. Touches the filesystem, so the /// result is cached on the `Dashboard` rather than recomputed each render. fn load_issue_types(config: &Config) -> Vec { - let mut registry = crate::issuetypes::IssueTypeRegistry::new(); - // `load_all` always loads builtins first, so the list is non-empty even - // when no user types or templates are present. - let _ = registry.load_all(Path::new(&config.paths.tickets)); + // The canonical loader scaffolds defaults / falls back to embedded + // builtins, so the list is non-empty even on a fresh workspace. + let registry = crate::startup::templates::load_registry(Path::new(&config.paths.tickets)); registry .all_types() .map(|it| IssueTypeInfo { diff --git a/src/ui/sections/workflows_section.rs b/src/ui/sections/workflows_section.rs index 2312bb9a..f329e56d 100644 --- a/src/ui/sections/workflows_section.rs +++ b/src/ui/sections/workflows_section.rs @@ -27,7 +27,8 @@ impl StatusSection for WorkflowsSection { } fn prerequisites(&self) -> &[SectionId] { - &[SectionId::Connections] + // Info-only: export formats are always available, no connections needed. + &[] } fn health(&self, _snapshot: &StatusSnapshot) -> SectionHealth { From 651cee8ee12a9de55a9c0aea2d79af94ab68114d Mon Sep 17 00:00:00 2001 From: untra Date: Sat, 1 Aug 2026 06:11:14 -0600 Subject: [PATCH 07/11] issue types are among a collection ; clarifying focused effort --- bindings/CreateIssueTypeRequest.ts | 6 +- bindings/IssueTypeResponse.ts | 6 +- bindings/IssueTypeSummary.ts | 6 +- docs/schemas/metadata.md | 8 + docs/schemas/openapi.json | 61 ++- src/agents/launcher/interpolation.rs | 1 + src/agents/launcher/step_config.rs | 1 + src/agents/launcher/tests.rs | 1 + src/agents/launcher/worktree_setup.rs | 1 + src/agents/sync.rs | 6 + src/api/kanban_sync.rs | 1 + src/app/tests.rs | 1 + src/issuetypes/collection.rs | 161 +------ src/issuetypes/loader.rs | 129 +----- src/issuetypes/mod.rs | 553 ++++++++++++++++++++---- src/mcp/tools.rs | 29 +- src/queue/creator.rs | 77 ++++ src/queue/ticket.rs | 44 ++ src/rest/dto/issue_types.rs | 36 ++ src/rest/dto/mod.rs | 1 + src/rest/routes/issuetypes.rs | 298 +++++++++++-- src/rest/routes/launch.rs | 1 + src/rest/routes/steps.rs | 23 +- src/rest/state.rs | 22 - src/schemas/ticket_metadata.schema.json | 6 + src/services/kanban_sync.rs | 104 +++-- src/steps/manager.rs | 1 + src/steps/session.rs | 1 + src/ui/collection_dialog.rs | 70 +-- src/ui/dialogs/confirm.rs | 1 + src/workflow_gen/agnt.rs | 1 + src/workflow_gen/command.rs | 61 ++- src/workflow_gen/export.rs | 1 + src/workflow_gen/mod.rs | 1 + 34 files changed, 1221 insertions(+), 499 deletions(-) diff --git a/bindings/CreateIssueTypeRequest.ts b/bindings/CreateIssueTypeRequest.ts index 58eca141..52e85cb8 100644 --- a/bindings/CreateIssueTypeRequest.ts +++ b/bindings/CreateIssueTypeRequest.ts @@ -5,4 +5,8 @@ import type { CreateStepRequest } from "./CreateStepRequest"; /** * Request to create a new issue type */ -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, }; diff --git a/bindings/IssueTypeResponse.ts b/bindings/IssueTypeResponse.ts index 23adaab3..f764158e 100644 --- a/bindings/IssueTypeResponse.ts +++ b/bindings/IssueTypeResponse.ts @@ -5,4 +5,8 @@ import type { StepResponse } from "./StepResponse"; /** * Response for a single issue type */ -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, }; diff --git a/bindings/IssueTypeSummary.ts b/bindings/IssueTypeSummary.ts index ea40e6f6..3ca38fae 100644 --- a/bindings/IssueTypeSummary.ts +++ b/bindings/IssueTypeSummary.ts @@ -3,4 +3,8 @@ /** * Summary response for listing issue types */ -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, }; diff --git a/docs/schemas/metadata.md b/docs/schemas/metadata.md index 92abfbf9..c7aa5df7 100644 --- a/docs/schemas/metadata.md +++ b/docs/schemas/metadata.md @@ -27,6 +27,7 @@ Schema for operator-tracked ticket metadata in YAML frontmatter. This schema doc | --- | --- | --- | --- | | `id` | `string` | Yes | Kanban ticket ID (e.g., FEAT-1234). Also used for tmux session name derivation. Key grammar: uppercase start, then uppercase letters, digits, or underscores (hyphen is reserved as the key/number separator). | | `status` | `string` | Yes | Operator workflow status | +| `collection` | `string` | No | Issuetype collection the ticket's type resolves within. Stamped at creation (active collection) or kanban sync (the project sync's collection). Absent on legacy tickets — resolution falls back to the active collection, then a deterministic search. | | `step` | `string` | No | Current workflow step name (e.g., plan, build, code, test, deploy) | | `priority` | `string` | No | Ticket priority level | | `project` | `string` | No | Target project name (subdirectory in projects root) | @@ -53,6 +54,13 @@ Schema for operator-tracked ticket metadata in YAML frontmatter. This schema doc - **Default**: `"queued"` - **Allowed Values**: `queued`, `running`, `awaiting`, `completed` +### collection + +- **Description**: Issuetype collection the ticket's type resolves within. Stamped at creation (active collection) or kanban sync (the project sync's collection). Absent on legacy tickets — resolution falls back to the active collection, then a deterministic search. +- **Type**: `string` +- **Pattern**: `^[a-z0-9_]{3,64}$` +- **Examples**: `dev_kanban`, `ralph_loop`, `custom` + ### step - **Description**: Current workflow step name (e.g., plan, build, code, test, deploy) diff --git a/docs/schemas/openapi.json b/docs/schemas/openapi.json index 6b088b2a..e161e984 100644 --- a/docs/schemas/openapi.json +++ b/docs/schemas/openapi.json @@ -738,11 +738,25 @@ "tags": [ "Issue Types" ], - "summary": "List all issue types", + "summary": "List issue types (all collections deduped, or one collection via `?collection=`)", "operationId": "issuetypes_list", + "parameters": [ + { + "name": "collection", + "in": "query", + "description": "Collection to scope the lookup to (defaults to resolution-order lookup)", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + } + ], "responses": { "200": { - "description": "List of all issue types", + "description": "List of issue types", "content": { "application/json": { "schema": { @@ -753,6 +767,16 @@ } } } + }, + "404": { + "description": "Unknown collection", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } }, @@ -822,6 +846,18 @@ "schema": { "type": "string" } + }, + { + "name": "collection", + "in": "query", + "description": "Collection to scope the lookup to (defaults to resolution-order lookup)", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } } ], "responses": { @@ -3081,6 +3117,13 @@ "steps" ], "properties": { + "collection": { + "type": [ + "string", + "null" + ], + "description": "Target collection (defaults to the active collection)" + }, "color": { "type": [ "string", @@ -3854,6 +3897,13 @@ "steps" ], "properties": { + "collection": { + "type": [ + "string", + "null" + ], + "description": "Owning collection under resolution-order lookup" + }, "color": { "type": [ "string", @@ -3908,6 +3958,13 @@ "stepCount" ], "properties": { + "collection": { + "type": [ + "string", + "null" + ], + "description": "Owning collection under resolution-order lookup" + }, "color": { "type": [ "string", diff --git a/src/agents/launcher/interpolation.rs b/src/agents/launcher/interpolation.rs index 89a61eb7..b3eb2216 100644 --- a/src/agents/launcher/interpolation.rs +++ b/src/agents/launcher/interpolation.rs @@ -300,6 +300,7 @@ mod tests { external_id: None, external_url: None, external_provider: None, + collection: None, } } diff --git a/src/agents/launcher/step_config.rs b/src/agents/launcher/step_config.rs index 980fa199..6a47d96b 100644 --- a/src/agents/launcher/step_config.rs +++ b/src/agents/launcher/step_config.rs @@ -176,6 +176,7 @@ mod tests { external_id: None, external_url: None, external_provider: None, + collection: None, } } diff --git a/src/agents/launcher/tests.rs b/src/agents/launcher/tests.rs index ef674c6e..e9ea915c 100644 --- a/src/agents/launcher/tests.rs +++ b/src/agents/launcher/tests.rs @@ -325,6 +325,7 @@ fn make_test_ticket(project: &str) -> Ticket { external_id: None, external_url: None, external_provider: None, + collection: None, } } diff --git a/src/agents/launcher/worktree_setup.rs b/src/agents/launcher/worktree_setup.rs index 4ee3d016..dcbbed69 100644 --- a/src/agents/launcher/worktree_setup.rs +++ b/src/agents/launcher/worktree_setup.rs @@ -348,6 +348,7 @@ mod tests { external_id: None, external_url: None, external_provider: None, + collection: None, } } diff --git a/src/agents/sync.rs b/src/agents/sync.rs index 27a05c3e..05316902 100644 --- a/src/agents/sync.rs +++ b/src/agents/sync.rs @@ -871,6 +871,7 @@ mod tests { external_id: None, external_url: None, external_provider: None, + collection: None, }; let action = sync.determine_action(&ticket, "op-FEAT-123", &health); @@ -908,6 +909,7 @@ mod tests { external_id: None, external_url: None, external_provider: None, + collection: None, }; let action = sync.determine_action(&ticket, "op-FEAT-123", &health); @@ -945,6 +947,7 @@ mod tests { external_id: None, external_url: None, external_provider: None, + collection: None, }; let action = sync.determine_action(&ticket, "op-FEAT-456", &health); @@ -984,6 +987,7 @@ mod tests { external_id: None, external_url: None, external_provider: None, + collection: None, }; let action = sync.determine_action(&ticket, "op-FEAT-789", &health); @@ -1023,6 +1027,7 @@ mod tests { external_id: None, external_url: None, external_provider: None, + collection: None, }; let action = sync.determine_action(&ticket, "op-FEAT-123", &health); @@ -1061,6 +1066,7 @@ mod tests { external_id: None, external_url: None, external_provider: None, + collection: None, }; let action = sync.determine_action(&ticket, "op-FEAT-123", &health); diff --git a/src/api/kanban_sync.rs b/src/api/kanban_sync.rs index 2399f93c..1165865c 100644 --- a/src/api/kanban_sync.rs +++ b/src/api/kanban_sync.rs @@ -369,6 +369,7 @@ mod tests { external_id: None, external_url: None, external_provider: None, + collection: None, step_delegators: std::collections::HashMap::default(), }; sync.on_ticket_requeued(&ticket).await; diff --git a/src/app/tests.rs b/src/app/tests.rs index bd3a3072..fb91f3f5 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -699,6 +699,7 @@ Test content external_id: None, external_url: None, external_provider: None, + collection: None, }; // Return to queue diff --git a/src/issuetypes/collection.rs b/src/issuetypes/collection.rs index b3d016aa..49b76efb 100644 --- a/src/issuetypes/collection.rs +++ b/src/issuetypes/collection.rs @@ -41,6 +41,12 @@ pub struct IssueTypeCollection { /// Publisher identifier (from a hosted manifest) #[serde(default)] pub publisher: Option, + /// Author attribution (from a hosted manifest) + #[serde(default)] + pub author: Option, + /// Provenance tier (official when absent) + #[serde(default)] + pub tier: crate::collections::manifest::CollectionTier, } impl IssueTypeCollection { @@ -54,19 +60,25 @@ impl IssueTypeCollection { workflow_hints: None, version: None, publisher: None, + author: None, + tier: crate::collections::manifest::CollectionTier::default(), } } - /// Attach manifest metadata (workflow hints, version, publisher). + /// Attach manifest metadata (workflow hints, version, publisher, author, tier). pub fn with_manifest_metadata( mut self, workflow_hints: Option, version: Option, publisher: Option, + author: Option, + tier: crate::collections::manifest::CollectionTier, ) -> Self { self.workflow_hints = workflow_hints; self.version = version; self.publisher = publisher; + self.author = author; + self.tier = tier; self } @@ -114,90 +126,6 @@ impl IssueTypeCollection { } } -/// Built-in collection presets -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BuiltinPreset { - /// Simple: TASK only - Simple, - /// Dev Kanban: TASK, FEAT, FIX - DevKanban, - /// DevOps Kanban: TASK, SPIKE, INV, FEAT, FIX - DevopsKanban, - /// Operator: ASSESS, SYNC, INIT (automation operations) - Operator, - /// Full: DevOps + Operator types - Full, -} - -impl BuiltinPreset { - /// Get all builtin presets - pub fn all() -> &'static [BuiltinPreset] { - &[ - BuiltinPreset::Simple, - BuiltinPreset::DevKanban, - BuiltinPreset::DevopsKanban, - BuiltinPreset::Operator, - BuiltinPreset::Full, - ] - } - - /// Get the collection name for this preset - pub fn name(&self) -> &'static str { - match self { - BuiltinPreset::Simple => "simple", - BuiltinPreset::DevKanban => "dev_kanban", - BuiltinPreset::DevopsKanban => "devops_kanban", - BuiltinPreset::Operator => "operator", - BuiltinPreset::Full => "full", - } - } - - /// Get the description for this preset - pub fn description(&self) -> &'static str { - match self { - BuiltinPreset::Simple => "Simple workflow with TASK only", - BuiltinPreset::DevKanban => "Developer kanban with TASK, FEAT, FIX", - BuiltinPreset::DevopsKanban => "DevOps kanban with TASK, SPIKE, INV, FEAT, FIX", - BuiltinPreset::Operator => "Operator automation tasks: ASSESS, SYNC, INIT", - BuiltinPreset::Full => "Full workflow: all types combined", - } - } - - /// Convert to an `IssueTypeCollection` - pub fn into_collection(self) -> IssueTypeCollection { - match self { - BuiltinPreset::Simple => { - IssueTypeCollection::new("simple", self.description()).with_types(["TASK"]) - } - BuiltinPreset::DevKanban => IssueTypeCollection::new("dev_kanban", self.description()) - .with_types(["TASK", "FEAT", "FIX"]), - BuiltinPreset::DevopsKanban => { - IssueTypeCollection::new("devops_kanban", self.description()) - .with_types(["TASK", "FEAT", "FIX", "SPIKE", "INV"]) - } - BuiltinPreset::Operator => IssueTypeCollection::new("operator", self.description()) - .with_types(["ASSESS", "SYNC", "INIT"]), - BuiltinPreset::Full => { - IssueTypeCollection::new("full", self.description()).with_types([ - "TASK", "FEAT", "FIX", "SPIKE", "INV", "ASSESS", "SYNC", "INIT", - ]) - } - } - } - - /// Parse preset name to variant - pub fn from_name(name: &str) -> Option { - match name.to_lowercase().as_str() { - "simple" => Some(BuiltinPreset::Simple), - "dev_kanban" | "devkanban" => Some(BuiltinPreset::DevKanban), - "devops_kanban" | "devopskanban" => Some(BuiltinPreset::DevopsKanban), - "operator" => Some(BuiltinPreset::Operator), - "full" => Some(BuiltinPreset::Full), - _ => None, - } - } -} - /// Wrapper struct for parsing collections.toml #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct CollectionsFile { @@ -250,69 +178,6 @@ mod tests { assert_eq!(collection.priority_index("SPIKE"), usize::MAX); } - #[test] - fn test_builtin_simple() { - let collection = BuiltinPreset::Simple.into_collection(); - assert_eq!(collection.name, "simple"); - assert_eq!(collection.types, vec!["TASK"]); - } - - #[test] - fn test_builtin_dev_kanban() { - let collection = BuiltinPreset::DevKanban.into_collection(); - assert_eq!(collection.name, "dev_kanban"); - assert_eq!(collection.types, vec!["TASK", "FEAT", "FIX"]); - } - - #[test] - fn test_builtin_devops_kanban() { - let collection = BuiltinPreset::DevopsKanban.into_collection(); - assert_eq!(collection.name, "devops_kanban"); - assert_eq!( - collection.types, - vec!["TASK", "FEAT", "FIX", "SPIKE", "INV"] - ); - } - - #[test] - fn test_builtin_from_name() { - assert_eq!( - BuiltinPreset::from_name("simple"), - Some(BuiltinPreset::Simple) - ); - assert_eq!( - BuiltinPreset::from_name("dev_kanban"), - Some(BuiltinPreset::DevKanban) - ); - assert_eq!( - BuiltinPreset::from_name("devops_kanban"), - Some(BuiltinPreset::DevopsKanban) - ); - assert_eq!( - BuiltinPreset::from_name("operator"), - Some(BuiltinPreset::Operator) - ); - assert_eq!(BuiltinPreset::from_name("full"), Some(BuiltinPreset::Full)); - assert_eq!(BuiltinPreset::from_name("unknown"), None); - } - - #[test] - fn test_builtin_operator() { - let collection = BuiltinPreset::Operator.into_collection(); - assert_eq!(collection.name, "operator"); - assert_eq!(collection.types, vec!["ASSESS", "SYNC", "INIT"]); - } - - #[test] - fn test_builtin_full() { - let collection = BuiltinPreset::Full.into_collection(); - assert_eq!(collection.name, "full"); - assert_eq!( - collection.types, - vec!["TASK", "FEAT", "FIX", "SPIKE", "INV", "ASSESS", "SYNC", "INIT"] - ); - } - #[test] fn test_collections_file_parse() { let toml = r#" diff --git a/src/issuetypes/loader.rs b/src/issuetypes/loader.rs index 7101fd45..972578cc 100644 --- a/src/issuetypes/loader.rs +++ b/src/issuetypes/loader.rs @@ -8,8 +8,6 @@ use tracing::{debug, info, warn}; use super::collection::{CollectionsFile, IssueTypeCollection}; use super::schema::{IssueType, IssueTypeSource}; -use crate::templates::schema::TemplateSchema; -use crate::templates::TemplateType; /// A loaded collection with its issue types #[derive(Debug, Clone)] @@ -28,50 +26,10 @@ pub struct LoadedCollection { pub version: Option, /// Publisher (from collection.json, if present) pub publisher: Option, -} - -/// Load all built-in issue types -pub fn load_builtins() -> Result> { - let mut types = HashMap::new(); - - for template_type in TemplateType::all() { - let schema_json = template_type.schema(); - match TemplateSchema::from_json(schema_json) { - Ok(schema) => { - let issue_type = template_schema_to_issuetype(schema, IssueTypeSource::Builtin); - debug!("Loaded builtin issue type: {}", issue_type.key); - types.insert(issue_type.key.clone(), issue_type); - } - Err(e) => { - warn!( - "Failed to parse builtin template {}: {}", - template_type.as_str(), - e - ); - } - } - } - - Ok(types) -} - -/// Convert a `TemplateSchema` to an `IssueType` -fn template_schema_to_issuetype(schema: TemplateSchema, source: IssueTypeSource) -> IssueType { - IssueType { - key: schema.key, - name: schema.name, - description: schema.description, - mode: schema.mode, - glyph: schema.glyph, - color: schema.color, - project_required: schema.project_required, - fields: schema.fields, - steps: schema.steps, - agent_prompt: schema.agent_prompt, - agent: schema.agent, - source, - external_id: None, - } + /// Author attribution (from collection.json, if present) + pub author: Option, + /// Provenance tier (from collection.json; official when absent) + pub tier: crate::collections::manifest::CollectionTier, } /// Load imported issue types from the imports subdirectory @@ -225,27 +183,6 @@ pub fn load_collections(path: &Path) -> Result, -) -> (Vec, Vec) { - let mut valid = Vec::new(); - let mut missing = Vec::new(); - - for type_key in &collection.types { - if available_types.contains_key(type_key) { - valid.push(type_key.clone()); - } else { - missing.push(type_key.clone()); - } - } - - (valid, missing) -} - /// Load collections from directory structure /// /// Structure (flattened - no issues/ subfolder): @@ -337,6 +274,8 @@ pub fn load_collections_from_dir( workflow_hints: meta.workflow_hints, version: meta.version, publisher: meta.publisher, + author: meta.author, + tier: meta.tier, }, ); } @@ -408,6 +347,8 @@ struct CollectionMetadata { workflow_hints: Option, version: Option, publisher: Option, + author: Option, + tier: crate::collections::manifest::CollectionTier, } /// Load optional collection metadata from `collection.json` (preferred) or the @@ -434,6 +375,8 @@ fn load_collection_metadata( workflow_hints: manifest.workflow_hints, version: (!manifest.version.is_empty()).then_some(manifest.version), publisher: manifest.publisher, + author: manifest.author, + tier: manifest.tier, }; } } @@ -469,6 +412,8 @@ fn load_collection_metadata( workflow_hints: None, version: None, publisher: None, + author: None, + tier: crate::collections::manifest::CollectionTier::default(), }; } } @@ -492,6 +437,8 @@ fn load_collection_metadata( workflow_hints: None, version: None, publisher: None, + author: None, + tier: crate::collections::manifest::CollectionTier::default(), } } @@ -507,20 +454,6 @@ mod tests { use super::*; use tempfile::TempDir; - #[test] - fn test_load_builtins() { - let types = load_builtins().unwrap(); - assert!(types.contains_key("FEAT")); - assert!(types.contains_key("FIX")); - assert!(types.contains_key("TASK")); - assert!(types.contains_key("SPIKE")); - assert!(types.contains_key("INV")); - - // Verify source is set correctly - let feat = types.get("FEAT").unwrap(); - assert_eq!(feat.source, IssueTypeSource::Builtin); - } - #[test] fn test_load_collection_metadata_reads_collection_json() { let temp_dir = TempDir::new().unwrap(); @@ -583,40 +516,6 @@ types = ["FEAT", "FIX"] assert!(collections.is_empty()); } - #[test] - fn test_validate_collection_types() { - let mut available = HashMap::new(); - available.insert( - "FEAT".to_string(), - IssueType::new_imported( - "FEAT".to_string(), - "Feature".to_string(), - String::new(), - "builtin".to_string(), - String::new(), - None, - ), - ); - available.insert( - "FIX".to_string(), - IssueType::new_imported( - "FIX".to_string(), - "Fix".to_string(), - String::new(), - "builtin".to_string(), - String::new(), - None, - ), - ); - - let collection = - IssueTypeCollection::new("test", "").with_types(["FEAT", "STORY", "FIX", "MISSING"]); - - let (valid, missing) = validate_collection_types(&collection, &available); - assert_eq!(valid, vec!["FEAT", "FIX"]); - assert_eq!(missing, vec!["STORY", "MISSING"]); - } - #[test] fn test_load_imported_types() { let temp_dir = TempDir::new().unwrap(); diff --git a/src/issuetypes/mod.rs b/src/issuetypes/mod.rs index 53287ae1..7cb11df3 100644 --- a/src/issuetypes/mod.rs +++ b/src/issuetypes/mod.rs @@ -15,7 +15,6 @@ //! - [`IssueType`]: Dynamic issue type definitions (extends `TemplateSchema`) //! - [`IssueTypeCollection`]: Named groupings of issue types with priority ordering //! - [`IssueTypeRegistry`]: Central manager for all issue types and collections -//! - [`BuiltinPreset`]: Predefined collection configurations //! //! ## Usage //! @@ -35,7 +34,7 @@ pub mod kanban_type; pub mod loader; pub mod schema; -pub use collection::{BuiltinPreset, IssueTypeCollection}; +pub use collection::IssueTypeCollection; pub use schema::IssueType; use anyhow::Result; @@ -43,13 +42,29 @@ use std::collections::HashMap; use std::path::Path; use tracing::{debug, info, warn}; -/// Central registry for all issue types and collections +/// One namespaced collection: its display metadata plus the issue types it +/// physically owns. Legacy key-groupings (collections.toml, `activate_custom`) +/// have an empty `types` map and resolve their keys across other collections. #[derive(Debug, Clone)] -pub struct IssueTypeRegistry { - /// All registered issue types by key +struct CollectionEntry { + meta: IssueTypeCollection, types: HashMap, - /// Named collections - collections: HashMap, +} + +/// Central registry for all issue types and collections. +/// +/// Storage is **namespaced by collection**: an issue type key is unique only +/// within its collection, so the same key can carry different definitions in +/// different collections. Kanban-provider imports live in a reserved +/// namespace outside the collection map (not shareable, never listed). +#[derive(Debug, Clone)] +pub struct IssueTypeRegistry { + /// Collections by name, each owning its types. + collections: HashMap, + /// Collection load order (deterministic resolve fallback). + order: Vec, + /// Kanban-provider imports, keyed `{PROJECT}_{KEY}`. + imports: HashMap, /// Currently active collection name active_collection: String, } @@ -64,36 +79,67 @@ impl IssueTypeRegistry { /// Create a new empty registry pub fn new() -> Self { Self { - types: HashMap::new(), collections: HashMap::new(), + order: Vec::new(), + imports: HashMap::new(), active_collection: "dev_kanban".to_string(), } } - /// Load built-in issue types - pub fn load_builtins(&mut self) -> Result<()> { - let builtins = loader::load_builtins()?; - for (key, issue_type) in builtins { - self.types.insert(key, issue_type); + /// Get (or create) a collection entry, tracking load order. + fn entry_mut(&mut self, name: &str) -> &mut CollectionEntry { + if !self.collections.contains_key(name) { + self.order.push(name.to_string()); + self.collections.insert( + name.to_string(), + CollectionEntry { + meta: IssueTypeCollection::new(name, ""), + types: HashMap::new(), + }, + ); } + self.collections.get_mut(name).expect("entry just ensured") + } - // Add builtin collections - for preset in BuiltinPreset::all() { - let collection = preset.into_collection(); - self.collections.insert(collection.name.clone(), collection); + /// Load built-in issue types from the embedded collection manifests. + pub fn load_builtins(&mut self) -> Result<()> { + for embedded in crate::collections::EMBEDDED_COLLECTIONS { + let manifest = embedded.manifest_parsed()?; + let mut types = HashMap::new(); + for it in embedded.issuetypes { + let mut issue_type = IssueType::from_json(it.schema_json)?; + issue_type.source = schema::IssueTypeSource::Builtin; + types.insert(issue_type.key.clone(), issue_type); + } + let meta = IssueTypeCollection::new(&manifest.id, &manifest.description) + .with_types(manifest.type_keys().iter().map(String::as_str)) + .with_manifest_metadata( + manifest.workflow_hints.clone(), + (!manifest.version.is_empty()).then(|| manifest.version.clone()), + manifest.publisher.clone(), + manifest.author.clone(), + manifest.tier, + ); + let entry = self.entry_mut(&manifest.id); + entry.meta = meta; + entry.types = types; } - info!("Loaded {} builtin issue types", self.types.len()); + info!( + "Loaded {} embedded collections ({} issue types)", + self.collections.len(), + self.type_count() + ); Ok(()) } - /// Load imported issue types from imports directory + /// Load imported issue types (reserved namespace, not a collection). pub fn load_imports(&mut self, imports_path: &Path) -> Result<()> { let imported = loader::load_imported_types(imports_path)?; let count = imported.len(); for (key, issue_type) in imported { - self.types.insert(key, issue_type); + self.imports.insert(key, issue_type); } if count > 0 { @@ -102,30 +148,18 @@ impl IssueTypeRegistry { Ok(()) } - /// Load collections from collections.toml + /// Register a single imported type under its prefixed key. + pub fn register_import(&mut self, prefixed_key: &str, issue_type: IssueType) { + self.imports.insert(prefixed_key.to_string(), issue_type); + } + + /// Load collections from collections.toml (legacy key-groupings) pub fn load_collections(&mut self, path: &Path) -> Result<()> { let collections = loader::load_collections(path)?; let count = collections.len(); - for (name, collection) in collections { - // Validate collection types, warn about missing ones - let (valid, missing) = loader::validate_collection_types(&collection, &self.types); - if !missing.is_empty() { - warn!( - "Collection '{}' references unknown types: {:?}", - name, missing - ); - } - - if valid.is_empty() { - warn!("Collection '{}' has no valid types, skipping", name); - continue; - } - - // Update collection to only include valid types - let mut validated_collection = collection; - validated_collection.types = valid; - self.collections.insert(name, validated_collection); + for (_, collection) in collections { + self.register_collection(collection)?; } if count > 0 { @@ -153,22 +187,20 @@ impl IssueTypeRegistry { /// /// Each collection is self-contained with its own issue types. pub fn load_from_templates_dir(&mut self, templates_path: &Path) -> Result<()> { - let loaded = loader::load_collections_from_dir(templates_path)?; + let mut loaded: Vec<_> = loader::load_collections_from_dir(templates_path)? + .into_iter() + .collect(); if loaded.is_empty() { debug!("No collections found in templates directory"); return Ok(()); } + // HashMap iteration order is random; sort for a deterministic + // load order (the resolve fallback depends on it). + loaded.sort_by(|a, b| a.0.cmp(&b.0)); - // Register each collection and its types for (name, loaded_collection) in loaded { - // Add all issue types from this collection - for (key, issue_type) in loaded_collection.types { - self.types.insert(key, issue_type); - } - - // Create and register the collection - let collection = IssueTypeCollection::new(&name, &loaded_collection.description) + let meta = IssueTypeCollection::new(&name, &loaded_collection.description) .with_types( loaded_collection .type_order @@ -179,40 +211,26 @@ impl IssueTypeRegistry { loaded_collection.workflow_hints, loaded_collection.version, loaded_collection.publisher, + loaded_collection.author, + loaded_collection.tier, ); - self.collections.insert(name, collection); + let entry = self.entry_mut(&name); + entry.meta = meta; + entry.types = loaded_collection.types; } info!( "Loaded {} issue types in {} collections from templates directory", - self.types.len(), + self.type_count(), self.collections.len() ); Ok(()) } - /// Activate a builtin preset - pub fn activate_preset(&mut self, preset: BuiltinPreset) -> Result<()> { - let name = preset.name(); - if self.collections.contains_key(name) { - self.active_collection = name.to_string(); - info!("Activated collection preset: {}", name); - Ok(()) - } else { - anyhow::bail!("Preset '{name}' not found in collections") - } - } - /// Activate a named collection pub fn activate_collection(&mut self, name: &str) -> Result<()> { - // First check if it's a builtin preset name - if let Some(preset) = BuiltinPreset::from_name(name) { - return self.activate_preset(preset); - } - - // Otherwise check user collections if self.collections.contains_key(name) { self.active_collection = name.to_string(); info!("Activated collection: {}", name); @@ -222,12 +240,12 @@ impl IssueTypeRegistry { } } - /// Activate a custom collection of types + /// Activate a custom grouping of types (keys resolve across collections) pub fn activate_custom(&mut self, type_keys: &[String]) -> Result<()> { - // Validate that all types exist + // Validate that all keys resolve somewhere let mut valid_keys = Vec::new(); for key in type_keys { - if self.types.contains_key(key) { + if self.resolve(None, key).is_some() { valid_keys.push(key.clone()); } else { warn!( @@ -241,11 +259,13 @@ impl IssueTypeRegistry { anyhow::bail!("No valid types in custom collection"); } - // Create or update the "custom" collection - let collection = IssueTypeCollection::new("custom", "Custom collection") + // Create or update the "custom" grouping (owns no types itself) + let meta = IssueTypeCollection::new("custom", "Custom collection") .with_types(valid_keys.iter().map(std::string::String::as_str)); + let entry = self.entry_mut("custom"); + entry.meta = meta; + entry.types.clear(); - self.collections.insert("custom".to_string(), collection); self.active_collection = "custom".to_string(); info!( "Activated custom collection with {} types", @@ -254,19 +274,138 @@ impl IssueTypeRegistry { Ok(()) } - /// Get an issue type by key + /// Get an issue type by key within a specific collection only. + pub fn get_in(&self, collection: &str, key: &str) -> Option<&IssueType> { + self.collections.get(collection)?.types.get(key) + } + + /// Resolve an issue type by key with collection precedence: + /// explicit collection → active collection → deterministic search across + /// collections in load order → kanban imports. Misses at an earlier level + /// fall through to the next (logged at debug). + pub fn resolve(&self, collection: Option<&str>, key: &str) -> Option<&IssueType> { + if let Some(name) = collection { + if let Some(it) = self.get_in(name, key) { + return Some(it); + } + debug!("key '{key}' not in collection '{name}', falling back"); + } + if let Some(it) = self.get_in(&self.active_collection, key) { + return Some(it); + } + for name in &self.order { + if name == &self.active_collection { + continue; + } + if let Some(it) = self.get_in(name, key) { + debug!("key '{key}' resolved via load-order fallback to '{name}'"); + return Some(it); + } + } + self.imports.get(key) + } + + /// Get an issue type by key (resolution-order lookup). pub fn get(&self, key: &str) -> Option<&IssueType> { - self.types.get(key) + self.resolve(None, key) } - /// Get all registered issue types + /// All issue types, deduplicated by key in resolution order: the active + /// collection's definitions win, then other collections in load order, + /// then imports. pub fn all_types(&self) -> impl Iterator { - self.types.values() + self.types_with_collections().map(|(_, it)| it) + } + + /// All issue types with their owning collection, deduplicated by key in + /// resolution order (imports report the reserved `imports` namespace). + pub fn types_with_collections(&self) -> impl Iterator { + let mut seen = std::collections::HashSet::new(); + let mut out: Vec<(&str, &IssueType)> = Vec::new(); + + // Active collection first, then the rest in load order. + let mut names: Vec<&String> = Vec::new(); + if self.collections.contains_key(&self.active_collection) { + names.push(&self.active_collection); + } + names.extend(self.order.iter().filter(|n| **n != self.active_collection)); + + for name in names { + let Some(entry) = self.collections.get(name) else { + continue; + }; + // Meta order first, then any stragglers deterministically. + for key in &entry.meta.types { + if let Some(it) = entry.types.get(key) { + if seen.insert(key.clone()) { + out.push((name.as_str(), it)); + } + } + } + let mut rest: Vec<&String> = entry + .types + .keys() + .filter(|k| !entry.meta.contains(k)) + .collect(); + rest.sort(); + for key in rest { + if seen.insert(key.clone()) { + out.push((name.as_str(), &entry.types[key])); + } + } + } + + let mut import_keys: Vec<&String> = self.imports.keys().collect(); + import_keys.sort(); + for key in import_keys { + if seen.insert(key.clone()) { + out.push(("imports", &self.imports[key])); + } + } + + out.into_iter() + } + + /// The types of a single collection, in its display order. Keys the + /// collection doesn't own (legacy groupings) resolve across the others. + /// `None` when the collection doesn't exist. + pub fn types_in(&self, collection: &str) -> Option> { + let entry = self.collections.get(collection)?; + Some( + entry + .meta + .types + .iter() + .filter_map(|key| entry.types.get(key).or_else(|| self.resolve(None, key))) + .collect(), + ) + } + + /// The collection that owns `key` under resolution-order lookup + /// (`imports` for kanban-imported types). + pub fn collection_of(&self, key: &str) -> Option<&str> { + if self.get_in(&self.active_collection, key).is_some() { + return self + .collections + .get_key_value(&self.active_collection) + .map(|(name, _)| name.as_str()); + } + for name in &self.order { + if name == &self.active_collection { + continue; + } + if self.get_in(name, key).is_some() { + return Some(name.as_str()); + } + } + self.imports.contains_key(key).then_some("imports") } /// Get the active collection pub fn active_collection(&self) -> Option<&IssueTypeCollection> { - self.collections.get(&self.active_collection) + self.collections + .get(&self.active_collection) + .map(|e| &e.meta) } /// Get the name of the active collection @@ -274,16 +413,19 @@ impl IssueTypeRegistry { &self.active_collection } - /// Get all issue types in the active collection (ordered) + /// Get all issue types in the active collection (ordered). Keys the + /// active collection doesn't own (legacy groupings) resolve across the + /// other collections. pub fn active_types(&self) -> Vec<&IssueType> { - let Some(collection) = self.active_collection() else { + let Some(entry) = self.collections.get(&self.active_collection) else { return vec![]; }; - collection + entry + .meta .types .iter() - .filter_map(|key| self.types.get(key)) + .filter_map(|key| entry.types.get(key).or_else(|| self.resolve(None, key))) .collect() } @@ -298,18 +440,21 @@ impl IssueTypeRegistry { .map_or(usize::MAX, |c| c.priority_index(key)) } - /// Get all available collections + /// Get all available collections in load order pub fn all_collections(&self) -> impl Iterator { - self.collections.values() + self.order + .iter() + .filter_map(|name| self.collections.get(name)) + .map(|entry| &entry.meta) } /// Get a collection by name pub fn get_collection(&self, name: &str) -> Option<&IssueTypeCollection> { - self.collections.get(name) + self.collections.get(name).map(|e| &e.meta) } - /// Register a new issue type - pub fn register(&mut self, issue_type: IssueType) -> Result<()> { + /// Register a new issue type into a specific collection. + pub fn register_in(&mut self, collection: &str, issue_type: IssueType) -> Result<()> { issue_type.validate().map_err(|errors| { let msgs: Vec = errors .iter() @@ -319,14 +464,40 @@ impl IssueTypeRegistry { })?; let key = issue_type.key.clone(); - self.types.insert(key.clone(), issue_type); - debug!("Registered issue type: {}", key); + let entry = self.entry_mut(collection); + entry.types.insert(key.clone(), issue_type); + if !entry.meta.contains(&key) { + entry.meta.types.push(key.clone()); + } + debug!("Registered issue type: {collection}/{key}"); Ok(()) } - /// Register a new collection + /// Register a new issue type into the active collection. + pub fn register(&mut self, issue_type: IssueType) -> Result<()> { + let active = self.active_collection.clone(); + self.register_in(&active, issue_type) + } + + /// Remove an issue type from a specific collection. Returns whether the + /// key was present. + pub fn remove_from(&mut self, collection: &str, key: &str) -> bool { + let Some(entry) = self.collections.get_mut(collection) else { + return false; + }; + let removed = entry.types.remove(key).is_some(); + entry.meta.types.retain(|k| k != key); + removed + } + + /// Register a new collection grouping (owns no types itself; its keys + /// resolve across the other collections). pub fn register_collection(&mut self, collection: IssueTypeCollection) -> Result<()> { - let (_valid, missing) = loader::validate_collection_types(&collection, &self.types); + let missing: Vec<&String> = collection + .types + .iter() + .filter(|key| self.resolve(None, key).is_none()) + .collect(); if !missing.is_empty() { warn!( "Collection '{}' references unknown types: {:?}", @@ -335,14 +506,20 @@ impl IssueTypeRegistry { } let name = collection.name.clone(); - self.collections.insert(name.clone(), collection); + let entry = self.entry_mut(&name); + entry.meta = collection; debug!("Registered collection: {}", name); Ok(()) } - /// Get the number of registered types + /// Get the number of registered types across all collections and imports + /// (same key in two collections counts twice). pub fn type_count(&self) -> usize { - self.types.len() + self.collections + .values() + .map(|e| e.types.len()) + .sum::() + + self.imports.len() } /// Get the number of registered collections @@ -355,6 +532,17 @@ impl IssueTypeRegistry { mod tests { use super::*; + fn variant(key: &str, name: &str) -> IssueType { + IssueType::new_imported( + key.to_string(), + name.to_string(), + "test".to_string(), + "test".to_string(), + "test".to_string(), + None, + ) + } + #[test] fn test_registry_new() { let registry = IssueTypeRegistry::new(); @@ -362,6 +550,179 @@ mod tests { assert_eq!(registry.collection_count(), 0); } + #[test] + fn test_same_key_coexists_across_collections() { + let mut registry = IssueTypeRegistry::new(); + registry + .register_in("alpha", variant("TASK", "Alpha Task")) + .unwrap(); + registry + .register_in("beta", variant("TASK", "Beta Task")) + .unwrap(); + + assert_eq!(registry.get_in("alpha", "TASK").unwrap().name, "Alpha Task"); + assert_eq!(registry.get_in("beta", "TASK").unwrap().name, "Beta Task"); + assert_eq!(registry.type_count(), 2); + } + + #[test] + fn test_resolve_precedence_explicit_then_active_then_load_order() { + let mut registry = IssueTypeRegistry::new(); + registry + .register_in("alpha", variant("TASK", "Alpha Task")) + .unwrap(); + registry + .register_in("beta", variant("TASK", "Beta Task")) + .unwrap(); + registry + .register_in("beta", variant("ONLY", "Beta Only")) + .unwrap(); + + // Explicit collection wins. + assert_eq!( + registry.resolve(Some("beta"), "TASK").unwrap().name, + "Beta Task" + ); + + // Active collection next. + registry.activate_collection("alpha").unwrap(); + assert_eq!(registry.resolve(None, "TASK").unwrap().name, "Alpha Task"); + assert_eq!(registry.get("TASK").unwrap().name, "Alpha Task"); + + // Deterministic load-order fallback for keys outside the active collection. + assert_eq!(registry.resolve(None, "ONLY").unwrap().name, "Beta Only"); + + // Explicit miss falls back rather than failing. + assert_eq!( + registry.resolve(Some("alpha"), "ONLY").unwrap().name, + "Beta Only" + ); + } + + #[test] + fn test_all_types_dedups_by_resolution_order() { + let mut registry = IssueTypeRegistry::new(); + registry + .register_in("alpha", variant("TASK", "Alpha Task")) + .unwrap(); + registry + .register_in("beta", variant("TASK", "Beta Task")) + .unwrap(); + registry + .register_in("beta", variant("ONLY", "Beta Only")) + .unwrap(); + registry.activate_collection("beta").unwrap(); + + let names: Vec<&str> = registry.all_types().map(|t| t.name.as_str()).collect(); + // One entry per key; active collection's definition wins the dedup. + assert_eq!(names.iter().filter(|n| n.contains("Task")).count(), 1); + assert!(names.contains(&"Beta Task")); + assert!(names.contains(&"Beta Only")); + } + + #[test] + fn test_imports_are_not_a_collection() { + let mut registry = IssueTypeRegistry::new(); + registry.register_import("MYPROJ_STORY", variant("STORY", "Imported Story")); + + // Resolvable via the normal lookup path... + assert!(registry.get("MYPROJ_STORY").is_some()); + // ...but not listed as a collection. + assert!(registry.get_collection("imports").is_none()); + assert_eq!(registry.collection_count(), 0); + } + + #[test] + fn test_load_builtins_registers_embedded_collections() { + let mut registry = IssueTypeRegistry::new(); + registry.load_builtins().unwrap(); + + // Every embedded collection becomes a namespaced entry. + for name in [ + "simple", + "dev_kanban", + "devops_kanban", + "operator", + "ralph_loop", + "jr_orchestration", + "elves_overnight", + ] { + assert!(registry.get_collection(name).is_some(), "missing {name}"); + } + // `full` was demoted and presets are retired. + assert!(registry.get_collection("full").is_none()); + // Underscore keys from the operator collection resolve. + assert!(registry.get("AGENT_SETUP").is_some()); + assert!(registry.get_in("operator", "PROJECT_INIT").is_some()); + } + + #[test] + fn test_activate_collection_has_no_preset_fallback() { + let mut registry = IssueTypeRegistry::new(); + registry.load_builtins().unwrap(); + assert!(registry.activate_collection("full").is_err()); + assert!(registry.activate_collection("dev_kanban").is_ok()); + } + + #[test] + fn test_collection_of_reports_owner_in_resolution_order() { + let mut registry = IssueTypeRegistry::new(); + registry + .register_in("alpha", variant("TASK", "Alpha Task")) + .unwrap(); + registry + .register_in("beta", variant("TASK", "Beta Task")) + .unwrap(); + registry + .register_in("beta", variant("ONLY", "Beta Only")) + .unwrap(); + registry.register_import("MYPROJ_STORY", variant("STORY", "Imported")); + registry.activate_collection("beta").unwrap(); + + assert_eq!(registry.collection_of("TASK"), Some("beta")); + assert_eq!(registry.collection_of("ONLY"), Some("beta")); + registry.activate_collection("alpha").unwrap(); + assert_eq!(registry.collection_of("TASK"), Some("alpha")); + assert_eq!(registry.collection_of("MYPROJ_STORY"), Some("imports")); + assert_eq!(registry.collection_of("NOPE"), None); + } + + #[test] + fn test_types_with_collections_carries_owner() { + let mut registry = IssueTypeRegistry::new(); + registry + .register_in("alpha", variant("TASK", "Alpha Task")) + .unwrap(); + registry + .register_in("beta", variant("ONLY", "Beta Only")) + .unwrap(); + registry.activate_collection("alpha").unwrap(); + + let pairs: Vec<(String, String)> = registry + .types_with_collections() + .map(|(c, t)| (c.to_string(), t.key.clone())) + .collect(); + assert!(pairs.contains(&("alpha".to_string(), "TASK".to_string()))); + assert!(pairs.contains(&("beta".to_string(), "ONLY".to_string()))); + } + + #[test] + fn test_legacy_group_resolves_types_across_collections() { + let mut registry = IssueTypeRegistry::new(); + registry.load_builtins().unwrap(); + + // collections.toml-style grouping: references keys owned elsewhere. + let group = + IssueTypeCollection::new("mygroup", "Legacy grouping").with_types(["TASK", "FEAT"]); + registry.register_collection(group).unwrap(); + registry.activate_collection("mygroup").unwrap(); + + let active = registry.active_types(); + assert_eq!(active.len(), 2); + assert_eq!(active[0].key, "TASK"); + assert_eq!(active[1].key, "FEAT"); + } + #[test] fn test_registry_load_builtins() { let mut registry = IssueTypeRegistry::new(); diff --git a/src/mcp/tools.rs b/src/mcp/tools.rs index 5d75f1f7..1e44ed31 100644 --- a/src/mcp/tools.rs +++ b/src/mcp/tools.rs @@ -4,7 +4,7 @@ //! Each tool calls the handler directly (no internal HTTP round-trip). //! Write tools are gated behind `[mcp].expose_ticket_write_tools`. -use axum::extract::{Path, State}; +use axum::extract::{Path, Query, State}; use axum::Json; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -274,16 +274,35 @@ pub async fn execute_tool(name: &str, args: Value, state: &ApiState) -> Result { - let resp = routes::issuetypes::list(State(state.clone())).await; - serde_json::to_value(&*resp).map_err(|e| e.to_string()) + let collection = args + .get("collection") + .and_then(|v| v.as_str()) + .map(std::string::ToString::to_string); + let result = routes::issuetypes::list( + State(state.clone()), + Query(routes::issuetypes::CollectionQuery { collection }), + ) + .await; + match result { + Ok(resp) => serde_json::to_value(&*resp).map_err(|e| e.to_string()), + Err(_e) => Err("Unknown collection".to_string()), + } } "operator_get_issue_type" => { let key = args .get("key") .and_then(|v| v.as_str()) .ok_or_else(|| "Missing required parameter: key".to_string())?; - let result = - routes::issuetypes::get_one(State(state.clone()), Path(key.to_string())).await; + let collection = args + .get("collection") + .and_then(|v| v.as_str()) + .map(std::string::ToString::to_string); + let result = routes::issuetypes::get_one( + State(state.clone()), + Path(key.to_string()), + Query(routes::issuetypes::CollectionQuery { collection }), + ) + .await; match result { Ok(resp) => serde_json::to_value(&*resp).map_err(|e| e.to_string()), Err(_e) => Err(format!("Issue type '{key}' not found")), diff --git a/src/queue/creator.rs b/src/queue/creator.rs index 6a625bab..06952cee 100644 --- a/src/queue/creator.rs +++ b/src/queue/creator.rs @@ -15,6 +15,8 @@ use crate::templates::TemplateType; /// Creates new tickets from templates pub struct TicketCreator { queue_path: PathBuf, + /// Active issuetype collection, stamped into created tickets' frontmatter + collection: Option, } impl TicketCreator { @@ -23,6 +25,7 @@ impl TicketCreator { let tickets_path = config.tickets_path(); Self { queue_path: tickets_path.join("queue"), + collection: config.templates.active_collection.clone(), } } @@ -48,6 +51,7 @@ impl TicketCreator { let template = template_type.template_content(); let content = render_template(template, values)?; + let content = stamp_collection(&content, self.collection.as_deref()); fs::create_dir_all(&self.queue_path).context("Failed to create queue directory")?; fs::write(&filepath, &content).context("Failed to write ticket file")?; @@ -176,10 +180,82 @@ pub fn split_required_optional( (required, optional) } +/// Inject `collection: ` into a rendered ticket's YAML frontmatter. +/// +/// No-op when no collection is set, the content has no frontmatter, or the +/// template already writes its own `collection:` field. +fn stamp_collection(content: &str, collection: Option<&str>) -> String { + let Some(collection) = collection else { + return content.to_string(); + }; + let Some(rest) = content.strip_prefix("---\n") else { + return content.to_string(); + }; + let Some(end) = rest.find("\n---") else { + return content.to_string(); + }; + let has_field = rest[..end] + .lines() + .any(|line| line.starts_with("collection:")); + if has_field { + return content.to_string(); + } + format!("---\ncollection: {collection}\n{rest}") +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn test_stamp_collection_injects_into_frontmatter() { + let content = "---\nid: TASK-0001\nstatus: queued\n---\n\n# Task: hello\n"; + let stamped = stamp_collection(content, Some("ralph_loop")); + assert!(stamped.starts_with("---\ncollection: ralph_loop\nid: TASK-0001\n")); + assert!(stamped.ends_with("# Task: hello\n")); + } + + #[test] + fn test_stamp_collection_none_is_noop() { + let content = "---\nid: TASK-0001\n---\n\n# Task\n"; + assert_eq!(stamp_collection(content, None), content); + } + + #[test] + fn test_stamp_collection_respects_existing_field() { + let content = "---\nid: TASK-0001\ncollection: custom\n---\n\n# Task\n"; + assert_eq!(stamp_collection(content, Some("ralph_loop")), content); + } + + #[test] + fn test_stamp_collection_no_frontmatter_is_noop() { + let content = "# Task: no frontmatter\n"; + assert_eq!(stamp_collection(content, Some("ralph_loop")), content); + } + + #[test] + fn test_create_ticket_headless_stamps_collection() { + let temp_dir = tempfile::tempdir().unwrap(); + let creator = TicketCreator { + queue_path: temp_dir.path().join("queue"), + collection: Some("dev_kanban".to_string()), + }; + let mut values = HashMap::new(); + values.insert("id".to_string(), "TASK-0001".to_string()); + values.insert("status".to_string(), "queued".to_string()); + values.insert("project".to_string(), "operator".to_string()); + values.insert("summary".to_string(), "stamp me".to_string()); + + let path = creator + .create_ticket_headless(TemplateType::Task, &values) + .unwrap(); + let written = fs::read_to_string(path).unwrap(); + assert!( + written.contains("collection: dev_kanban"), + "created ticket should carry the active collection: {written}" + ); + } + #[test] fn test_render_template() { let template = "ID: {{ id }}\nProject: {{ project }}\nSummary: {{ summary }}"; @@ -212,6 +288,7 @@ mod tests { fn test_generate_default_values() { let creator = TicketCreator { queue_path: PathBuf::from("/tmp"), + collection: None, }; let values = creator.generate_default_values(TemplateType::Feature, "myproject"); diff --git a/src/queue/ticket.rs b/src/queue/ticket.rs index 9aa0a9ca..e3375772 100644 --- a/src/queue/ticket.rs +++ b/src/queue/ticket.rs @@ -66,6 +66,8 @@ pub struct Ticket { pub external_url: Option, /// Provider name for the external issue (e.g., "jira", "linear") pub external_provider: Option, + /// Issuetype collection this ticket's type resolves within + pub collection: Option, /// Delegator name used per completed step (`step_name` → `delegator_name`). /// Populated when a step is launched; used for bidirectional kanban activity logs. pub step_delegators: HashMap, @@ -100,6 +102,7 @@ impl Ticket { external_id, external_url, external_provider, + collection, ) = if let Some((frontmatter, sessions, step_delegators, llm_task, body)) = extract_frontmatter(&content) { @@ -123,6 +126,7 @@ impl Ticket { let external_id = frontmatter.get("external_id").cloned(); let external_url = frontmatter.get("external_url").cloned(); let external_provider = frontmatter.get("external_provider").cloned(); + let collection = frontmatter.get("collection").cloned(); // Extract summary from body (after frontmatter) let summary = extract_summary(body); ( @@ -139,6 +143,7 @@ impl Ticket { external_id, external_url, external_provider, + collection, ) } else { // Legacy parsing using regex for inline metadata @@ -163,6 +168,7 @@ impl Ticket { None, None, None, + None, ) }; @@ -186,6 +192,7 @@ impl Ticket { external_id, external_url, external_provider, + collection, }) } @@ -1027,6 +1034,43 @@ status: queued ); } + #[test] + fn test_ticket_parses_collection_frontmatter() { + let content = r"--- +id: RLOOP-0001 +status: queued +collection: ralph_loop +--- + +# Ralph Loop: iterate +"; + let temp_dir = tempfile::tempdir().unwrap(); + let ticket_path = temp_dir + .path() + .join("20260701-0900-RLOOP-operator-iterate.md"); + std::fs::write(&ticket_path, content).unwrap(); + + let ticket = Ticket::from_file(&ticket_path).unwrap(); + assert_eq!(ticket.collection.as_deref(), Some("ralph_loop")); + } + + #[test] + fn test_ticket_collection_defaults_to_none() { + let content = r"--- +id: TASK-0001 +status: queued +--- + +# Task: no collection +"; + let temp_dir = tempfile::tempdir().unwrap(); + let ticket_path = temp_dir.path().join("20260701-0900-TASK-operator-none.md"); + std::fs::write(&ticket_path, content).unwrap(); + + let ticket = Ticket::from_file(&ticket_path).unwrap(); + assert!(ticket.collection.is_none()); + } + #[test] fn test_ticket_id_does_not_duplicate_type() { // The ticket.id field should be the full ID like "FEAT-1234" diff --git a/src/rest/dto/issue_types.rs b/src/rest/dto/issue_types.rs index 9b6ecc83..4d0e968e 100644 --- a/src/rest/dto/issue_types.rs +++ b/src/rest/dto/issue_types.rs @@ -26,10 +26,23 @@ pub struct IssueTypeResponse { pub color: Option, pub project_required: bool, pub source: String, + /// Owning collection under resolution-order lookup + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub collection: Option, pub fields: Vec, pub steps: Vec, } +impl IssueTypeResponse { + /// Build from an issue type plus its owning collection. + pub fn with_collection(it: &IssueType, collection: Option<&str>) -> Self { + let mut resp = Self::from(it); + resp.collection = collection.map(std::string::ToString::to_string); + resp + } +} + impl From<&IssueType> for IssueTypeResponse { fn from(it: &IssueType) -> Self { Self { @@ -44,6 +57,7 @@ impl From<&IssueType> for IssueTypeResponse { color: it.color.clone(), project_required: it.project_required, source: it.source_display(), + collection: None, fields: it.fields.iter().map(FieldResponse::from).collect(), steps: it.steps.iter().map(StepResponse::from).collect(), } @@ -65,9 +79,22 @@ pub struct IssueTypeSummary { #[ts(optional)] pub color: Option, pub source: String, + /// Owning collection under resolution-order lookup + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub collection: Option, pub step_count: usize, } +impl IssueTypeSummary { + /// Build from an issue type plus its owning collection. + pub fn with_collection(it: &IssueType, collection: Option<&str>) -> Self { + let mut resp = Self::from(it); + resp.collection = collection.map(std::string::ToString::to_string); + resp + } +} + impl From<&IssueType> for IssueTypeSummary { fn from(it: &IssueType) -> Self { Self { @@ -81,6 +108,7 @@ impl From<&IssueType> for IssueTypeSummary { glyph: it.glyph.clone(), color: it.color.clone(), source: it.source_display(), + collection: None, step_count: it.steps.len(), } } @@ -103,6 +131,10 @@ pub struct CreateIssueTypeRequest { #[serde(default)] pub fields: Vec, pub steps: Vec, + /// Target collection (defaults to the active collection) + #[serde(default)] + #[ts(optional)] + pub collection: Option, } fn default_mode() -> String { @@ -516,6 +548,7 @@ mod tests { glyph: "F".to_string(), color: Some("cyan".to_string()), source: "user".to_string(), + collection: None, step_count: 3, }; let json = serde_json::to_string(&summary).unwrap(); @@ -533,6 +566,7 @@ mod tests { glyph: "F".to_string(), color: None, source: "user".to_string(), + collection: None, step_count: 0, }; let json = serde_json::to_string(&summary).unwrap(); @@ -677,6 +711,8 @@ mod tests { }), Some("1.0.0".to_string()), Some("untra".to_string()), + None, + crate::collections::manifest::CollectionTier::Official, ); let resp = CollectionResponse::from_collection(&collection, true); assert_eq!(resp.version.as_deref(), Some("1.0.0")); diff --git a/src/rest/dto/mod.rs b/src/rest/dto/mod.rs index 1afc7151..efd0a6fb 100644 --- a/src/rest/dto/mod.rs +++ b/src/rest/dto/mod.rs @@ -38,6 +38,7 @@ mod tests { glyph: "T".to_string(), color: None, project_required: true, + collection: None, fields: vec![], steps: vec![CreateStepRequest { name: "execute".to_string(), diff --git a/src/rest/routes/issuetypes.rs b/src/rest/routes/issuetypes.rs index 90ff6c8b..c7db6006 100644 --- a/src/rest/routes/issuetypes.rs +++ b/src/rest/routes/issuetypes.rs @@ -1,9 +1,15 @@ //! Issue type CRUD endpoints. +//! +//! All endpoints are collection-aware: keys are unique only within a +//! collection, an optional `?collection=` scopes lookups, and writes land in +//! the collection-scoped store (`.tickets/templates//`). use axum::{ - extract::{Path, State}, + extract::{Path, Query, State}, Json, }; +use serde::Deserialize; +use utoipa::IntoParams; use crate::issuetypes::schema::IssueTypeSource; use crate::rest::dto::{ @@ -12,20 +18,46 @@ use crate::rest::dto::{ use crate::rest::error::{ApiError, ErrorResponse}; use crate::rest::state::ApiState; -/// List all issue types +/// Optional collection scope for issuetype lookups. +#[derive(Debug, Deserialize, IntoParams)] +pub struct CollectionQuery { + /// Collection to scope the lookup to (defaults to resolution-order lookup) + pub collection: Option, +} + +/// The reserved kanban-imports namespace: readable, never writable via CRUD. +const IMPORTS_NAMESPACE: &str = "imports"; + +/// List issue types (all collections deduped, or one collection via `?collection=`) #[utoipa::path( operation_id = "issuetypes_list", get, path = "/api/v1/issuetypes", tag = "Issue Types", + params(CollectionQuery), responses( - (status = 200, description = "List of all issue types", body = Vec) + (status = 200, description = "List of issue types", body = Vec), + (status = 404, description = "Unknown collection", body = ErrorResponse) ) )] -pub async fn list(State(state): State) -> Json> { +pub async fn list( + State(state): State, + Query(query): Query, +) -> Result>, ApiError> { let registry = state.registry.read().await; - let types: Vec = registry.all_types().map(IssueTypeSummary::from).collect(); - Json(types) + let types: Vec = match query.collection { + Some(name) => registry + .types_in(&name) + .ok_or_else(|| ApiError::NotFound(format!("Collection '{name}' not found")))? + .into_iter() + .map(|it| IssueTypeSummary::with_collection(it, Some(&name))) + .collect(), + None => registry + .types_with_collections() + .map(|(collection, it)| IssueTypeSummary::with_collection(it, Some(collection))) + .collect(), + }; + Ok(Json(types)) } /// Get a single issue type by key @@ -35,7 +67,8 @@ pub async fn list(State(state): State) -> Json> path = "/api/v1/issuetypes/{key}", tag = "Issue Types", params( - ("key" = String, Path, description = "Issue type key (e.g., FEAT, FIX)") + ("key" = String, Path, description = "Issue type key (e.g., FEAT, FIX)"), + CollectionQuery ), responses( (status = 200, description = "Issue type details", body = IssueTypeResponse), @@ -45,13 +78,19 @@ pub async fn list(State(state): State) -> Json> pub async fn get_one( State(state): State, Path(key): Path, + Query(query): Query, ) -> Result, ApiError> { + let key = key.to_uppercase(); let registry = state.registry.read().await; let issue_type = registry - .get(&key.to_uppercase()) + .resolve(query.collection.as_deref(), &key) .ok_or_else(|| ApiError::NotFound(format!("Issue type '{key}' not found")))?; - Ok(Json(IssueTypeResponse::from(issue_type))) + let owner = match &query.collection { + Some(name) if registry.get_in(name, &key).is_some() => Some(name.as_str()), + _ => registry.collection_of(&key), + }; + Ok(Json(IssueTypeResponse::with_collection(issue_type, owner))) } /// Create a new issue type @@ -71,6 +110,7 @@ pub async fn create( State(state): State, Json(request): Json, ) -> Result, ApiError> { + let requested_collection = request.collection.clone(); let issue_type = request.into_issue_type(); // Validate the issue type @@ -82,32 +122,43 @@ pub async fn create( ApiError::ValidationError(msgs.join("; ")) })?; - // Check if key already exists - { + // Resolve target collection (default: active) and check for a duplicate + // within it — the same key in another collection is fine. + let target = { let registry = state.registry.read().await; - if registry.get(&issue_type.key).is_some() { + let target = + requested_collection.unwrap_or_else(|| registry.active_collection_name().to_string()); + if target == IMPORTS_NAMESPACE { + return Err(ApiError::ValidationError( + "The 'imports' namespace is provider-managed".to_string(), + )); + } + if registry.get_in(&target, &issue_type.key).is_some() { return Err(ApiError::Conflict(format!( - "Issue type '{}' already exists", + "Issue type '{}' already exists in collection '{target}'", issue_type.key ))); } - } + target + }; - // Persist to filesystem - state.ensure_issuetypes_dir().await?; - let filepath = state - .issuetypes_path() - .join(format!("{}.json", issue_type.key)); + // Persist to the collection-scoped store + let dir = state.templates_path().join(&target); + tokio::fs::create_dir_all(&dir).await?; + let filepath = dir.join(format!("{}.json", issue_type.key)); let json = issue_type.to_json()?; tokio::fs::write(&filepath, json).await?; // Register in memory let mut registry = state.registry.write().await; registry - .register(issue_type.clone()) + .register_in(&target, issue_type.clone()) .map_err(|e| ApiError::InternalError(format!("Failed to register issue type: {e}")))?; - Ok(Json(IssueTypeResponse::from(&issue_type))) + Ok(Json(IssueTypeResponse::with_collection( + &issue_type, + Some(&target), + ))) } /// Update an existing issue type @@ -130,17 +181,20 @@ pub async fn create( pub async fn update( State(state): State, Path(key): Path, + Query(query): Query, Json(request): Json, ) -> Result, ApiError> { let key = key.to_uppercase(); - // Get existing issue type - let mut issue_type = { + // Get existing issue type and its owning collection + let (mut issue_type, owner) = { let registry = state.registry.read().await; - registry - .get(&key) + let owner = owning_collection(®istry, &key, query.collection.as_deref())?; + let issue_type = registry + .get_in(&owner, &key) .ok_or_else(|| ApiError::NotFound(format!("Issue type '{key}' not found")))? - .clone() + .clone(); + (issue_type, owner) }; // Check if it's a builtin @@ -189,18 +243,45 @@ pub async fn update( ApiError::ValidationError(msgs.join("; ")) })?; - // Persist to filesystem - let filepath = state.issuetypes_path().join(format!("{key}.json")); + // Persist to the collection-scoped store + let dir = state.templates_path().join(&owner); + tokio::fs::create_dir_all(&dir).await?; + let filepath = dir.join(format!("{key}.json")); let json = issue_type.to_json()?; tokio::fs::write(&filepath, json).await?; // Update in memory let mut registry = state.registry.write().await; registry - .register(issue_type.clone()) + .register_in(&owner, issue_type.clone()) .map_err(|e| ApiError::InternalError(format!("Failed to update issue type: {e}")))?; - Ok(Json(IssueTypeResponse::from(&issue_type))) + Ok(Json(IssueTypeResponse::with_collection( + &issue_type, + Some(&owner), + ))) +} + +/// Resolve which collection a write should target: the explicit query +/// collection, or wherever the key resolves. Imports are never writable. +fn owning_collection( + registry: &crate::issuetypes::IssueTypeRegistry, + key: &str, + requested: Option<&str>, +) -> Result { + let owner = match requested { + Some(name) => name.to_string(), + None => registry + .collection_of(key) + .ok_or_else(|| ApiError::NotFound(format!("Issue type '{key}' not found")))? + .to_string(), + }; + if owner == IMPORTS_NAMESPACE { + return Err(ApiError::ValidationError( + "The 'imports' namespace is provider-managed".to_string(), + )); + } + Ok(owner) } /// Delete an issue type @@ -221,14 +302,16 @@ pub async fn update( pub async fn delete( State(state): State, Path(key): Path, + Query(query): Query, ) -> Result, ApiError> { let key = key.to_uppercase(); - // Check if it exists and is not builtin - { + // Check it exists, find its collection, and reject builtins + let owner = { let registry = state.registry.read().await; + let owner = owning_collection(®istry, &key, query.collection.as_deref())?; let issue_type = registry - .get(&key) + .get_in(&owner, &key) .ok_or_else(|| ApiError::NotFound(format!("Issue type '{key}' not found")))?; if matches!(issue_type.source, IssueTypeSource::Builtin) { @@ -236,21 +319,26 @@ pub async fn delete( "Cannot delete builtin issue type '{key}'" ))); } - } + owner + }; - // Delete from filesystem - let filepath = state.issuetypes_path().join(format!("{key}.json")); + // Delete from the collection-scoped store + let filepath = state + .templates_path() + .join(&owner) + .join(format!("{key}.json")); if filepath.exists() { tokio::fs::remove_file(&filepath).await?; } - // Note: We can't remove from registry without exposing a remove method, - // but the file is deleted so it won't be loaded on next restart. - // For full removal, user would need to restart the API server. + // Remove from memory + let mut registry = state.registry.write().await; + registry.remove_from(&owner, &key); Ok(Json(serde_json::json!({ "deleted": key, - "message": "Issue type deleted. Restart API for full removal from memory." + "collection": owner, + "message": "Issue type deleted." }))) } @@ -265,26 +353,150 @@ mod tests { ApiState::new(config, PathBuf::from("/tmp/test")) } + fn make_temp_state() -> (ApiState, tempfile::TempDir) { + let temp = tempfile::tempdir().unwrap(); + let config = Config::default(); + let state = ApiState::new(config, temp.path().to_path_buf()); + (state, temp) + } + + fn no_collection() -> Query { + Query(CollectionQuery { collection: None }) + } + + fn in_collection(name: &str) -> Query { + Query(CollectionQuery { + collection: Some(name.to_string()), + }) + } + + fn sample_create(key: &str, collection: Option<&str>) -> CreateIssueTypeRequest { + serde_json::from_value(serde_json::json!({ + "key": key, + "name": "Sample", + "description": "A sample type", + "glyph": "s", + "steps": [{"name": "execute", "outputs": [], "prompt": "Do it."}], + "collection": collection, + })) + .unwrap() + } + #[tokio::test] async fn test_list() { let state = make_state(); - let resp = list(State(state)).await; + let resp = list(State(state), no_collection()).await.unwrap(); assert!(!resp.0.is_empty()); + // Every summary reports its owning collection. + assert!(resp.0.iter().all(|s| s.collection.is_some())); + } + + #[tokio::test] + async fn test_list_filters_by_collection() { + let state = make_state(); + let resp = list(State(state), in_collection("simple")).await.unwrap(); + let keys: Vec<&str> = resp.0.iter().map(|s| s.key.as_str()).collect(); + assert_eq!(keys, vec!["TASK"]); + assert_eq!(resp.0[0].collection.as_deref(), Some("simple")); + } + + #[tokio::test] + async fn test_list_unknown_collection_404s() { + let state = make_state(); + let result = list(State(state), in_collection("nope")).await; + assert!(matches!(result, Err(ApiError::NotFound(_)))); } #[tokio::test] async fn test_get_one_exists() { let state = make_state(); - let result = get_one(State(state), Path("FEAT".to_string())).await; + let result = get_one(State(state), Path("FEAT".to_string()), no_collection()).await; assert!(result.is_ok()); let resp = result.unwrap(); assert_eq!(resp.key, "FEAT"); + assert!(resp.collection.is_some()); } #[tokio::test] async fn test_get_one_not_found() { let state = make_state(); - let result = get_one(State(state), Path("NOTEXIST".to_string())).await; + let result = get_one(State(state), Path("NOTEXIST".to_string()), no_collection()).await; assert!(matches!(result, Err(ApiError::NotFound(_)))); } + + #[tokio::test] + async fn test_create_persists_into_collection_dir() { + let (state, _temp) = make_temp_state(); + let resp = create( + State(state.clone()), + Json(sample_create("NEWT", Some("mine"))), + ) + .await + .unwrap(); + assert_eq!(resp.collection.as_deref(), Some("mine")); + + let filepath = state.templates_path().join("mine/NEWT.json"); + assert!(filepath.exists(), "should persist to templates/mine/"); + let registry = state.registry.read().await; + assert!(registry.get_in("mine", "NEWT").is_some()); + } + + #[tokio::test] + async fn test_create_defaults_to_active_collection() { + let (state, _temp) = make_temp_state(); + let active = { + let registry = state.registry.read().await; + registry.active_collection_name().to_string() + }; + let resp = create(State(state.clone()), Json(sample_create("NEWT", None))) + .await + .unwrap(); + assert_eq!(resp.collection.as_deref(), Some(active.as_str())); + assert!(state + .templates_path() + .join(format!("{active}/NEWT.json")) + .exists()); + } + + #[tokio::test] + async fn test_create_same_key_other_collection_allowed() { + let (state, _temp) = make_temp_state(); + let _ = create( + State(state.clone()), + Json(sample_create("NEWT", Some("mine"))), + ) + .await + .unwrap(); + // Same key in a different collection is fine (namespaced)... + let _ = create( + State(state.clone()), + Json(sample_create("NEWT", Some("other"))), + ) + .await + .unwrap(); + // ...but a duplicate within the same collection conflicts. + let dup = create(State(state), Json(sample_create("NEWT", Some("mine")))).await; + assert!(matches!(dup, Err(ApiError::Conflict(_)))); + } + + #[tokio::test] + async fn test_delete_removes_from_memory_and_disk() { + let (state, _temp) = make_temp_state(); + let _ = create( + State(state.clone()), + Json(sample_create("NEWT", Some("mine"))), + ) + .await + .unwrap(); + let _ = delete( + State(state.clone()), + Path("NEWT".to_string()), + in_collection("mine"), + ) + .await + .unwrap(); + assert!(!state.templates_path().join("mine/NEWT.json").exists()); + let registry = state.registry.read().await; + assert!(registry.get_in("mine", "NEWT").is_none()); + } } diff --git a/src/rest/routes/launch.rs b/src/rest/routes/launch.rs index b54684d5..64bef363 100644 --- a/src/rest/routes/launch.rs +++ b/src/rest/routes/launch.rs @@ -804,6 +804,7 @@ mod tests { external_id: None, external_url: None, external_provider: None, + collection: None, } } diff --git a/src/rest/routes/steps.rs b/src/rest/routes/steps.rs index a163d5c9..7615ef31 100644 --- a/src/rest/routes/steps.rs +++ b/src/rest/routes/steps.rs @@ -94,13 +94,18 @@ pub async fn update( ) -> Result, ApiError> { let key = key.to_uppercase(); - // Get existing issue type - let mut issue_type = { + // Get existing issue type and its owning collection + let (mut issue_type, owner) = { let registry = state.registry.read().await; - registry - .get(&key) + let owner = registry + .collection_of(&key) .ok_or_else(|| ApiError::NotFound(format!("Issue type '{key}' not found")))? - .clone() + .to_string(); + let issue_type = registry + .get_in(&owner, &key) + .ok_or_else(|| ApiError::NotFound(format!("Issue type '{key}' not found")))? + .clone(); + (issue_type, owner) }; // Check if it's a builtin @@ -178,15 +183,17 @@ pub async fn update( ApiError::ValidationError(msgs.join("; ")) })?; - // Persist to filesystem - let filepath = state.issuetypes_path().join(format!("{key}.json")); + // Persist to the collection-scoped store + let dir = state.templates_path().join(&owner); + tokio::fs::create_dir_all(&dir).await?; + let filepath = dir.join(format!("{key}.json")); let json = issue_type.to_json()?; tokio::fs::write(&filepath, json).await?; // Update in memory let mut registry = state.registry.write().await; registry - .register(issue_type) + .register_in(&owner, issue_type) .map_err(|e| ApiError::InternalError(format!("Failed to update issue type: {e}")))?; Ok(Json(StepResponse::from(&updated_step))) diff --git a/src/rest/state.rs b/src/rest/state.rs index f43b3581..9f7d755d 100644 --- a/src/rest/state.rs +++ b/src/rest/state.rs @@ -60,20 +60,9 @@ impl ApiState { } /// Get the templates directory path - #[allow(dead_code)] // Reserved for future use in REST API pub fn templates_path(&self) -> PathBuf { self.tickets_path.join("templates") } - - /// Get the issuetypes directory path (legacy) - pub fn issuetypes_path(&self) -> PathBuf { - self.tickets_path.join("operator/issuetypes") - } - - /// Ensure the issuetypes directory exists - pub async fn ensure_issuetypes_dir(&self) -> std::io::Result<()> { - tokio::fs::create_dir_all(self.issuetypes_path()).await - } } #[cfg(test)] @@ -90,17 +79,6 @@ mod tests { assert!(registry.type_count() >= 5); // At least builtin types } - #[test] - fn test_issuetypes_path() { - let config = Config::default(); - let state = ApiState::new(config, PathBuf::from("/tmp/tickets")); - - assert_eq!( - state.issuetypes_path(), - PathBuf::from("/tmp/tickets/operator/issuetypes") - ); - } - #[test] fn test_kanban_sync_none_when_no_bidirectional_projects() { // Default config has no kanban projects configured, so kanban_sync is None. diff --git a/src/schemas/ticket_metadata.schema.json b/src/schemas/ticket_metadata.schema.json index a2cafc5a..f88a7d5d 100644 --- a/src/schemas/ticket_metadata.schema.json +++ b/src/schemas/ticket_metadata.schema.json @@ -19,6 +19,12 @@ "description": "Operator workflow status", "default": "queued" }, + "collection": { + "type": "string", + "description": "Issuetype collection the ticket's type resolves within. Stamped at creation (active collection) or kanban sync (the project sync's collection). Absent on legacy tickets — resolution falls back to the active collection, then a deterministic search.", + "pattern": "^[a-z0-9_]{3,64}$", + "examples": ["dev_kanban", "ralph_loop", "custom"] + }, "step": { "type": "string", "description": "Current workflow step name (e.g., plan, build, code, test, deploy)", diff --git a/src/services/kanban_sync.rs b/src/services/kanban_sync.rs index 78d92c98..034ca491 100644 --- a/src/services/kanban_sync.rs +++ b/src/services/kanban_sync.rs @@ -185,7 +185,13 @@ impl KanbanSyncService { } else { Some(&project_config.type_mappings) }; - match self.create_ticket_from_issue(&issue, provider_name, project_key, type_mappings) { + match self.create_ticket_from_issue( + &issue, + provider_name, + project_key, + type_mappings, + project_config.collection_name.as_deref(), + ) { Ok(filename) => { info!("Created ticket: {}", filename); result.created.push(issue.key.clone()); @@ -311,6 +317,7 @@ impl KanbanSyncService { provider: &str, project_key: &str, type_mappings: Option<&std::collections::HashMap>, + collection: Option<&str>, ) -> Result { let queue_path = Path::new(&self.config.paths.tickets).join("queue"); fs::create_dir_all(&queue_path)?; @@ -325,30 +332,8 @@ impl KanbanSyncService { let slug = slugify(&issue.summary, 50); let filename = format!("{timestamp}-{ticket_type}-{project_key}-{slug}.md"); - // Build frontmatter - let needs_mapping_line = if needs_mapping { - "\nneeds_issuetype_mapping: true" - } else { - "" - }; - let frontmatter = format!( - r"--- -id: {}-{} -status: queued -priority: {} -step: plan -external_id: {} -external_url: {} -external_provider: {}{} ----", - ticket_type, - issue.key.replace('-', ""), - map_priority(&issue.priority), - issue.key, - issue.url, - provider, - needs_mapping_line, - ); + let frontmatter = + ticket_frontmatter(ticket_type, issue, provider, needs_mapping, collection); // Build content let description = issue @@ -377,6 +362,47 @@ external_provider: {}{} } } +/// Build the YAML frontmatter for a ticket created from an external issue. +/// +/// `collection` is the project sync's issuetype collection +/// (`ProjectSyncConfig.collection_name`): when set, the ticket's type +/// resolves within that collection. +fn ticket_frontmatter( + ticket_type: &str, + issue: &ExternalIssue, + provider: &str, + needs_mapping: bool, + collection: Option<&str>, +) -> String { + let needs_mapping_line = if needs_mapping { + "\nneeds_issuetype_mapping: true" + } else { + "" + }; + let collection_line = collection + .map(|c| format!("\ncollection: {c}")) + .unwrap_or_default(); + format!( + r"--- +id: {}-{} +status: queued +priority: {} +step: plan{} +external_id: {} +external_url: {} +external_provider: {}{} +---", + ticket_type, + issue.key.replace('-', ""), + map_priority(&issue.priority), + collection_line, + issue.key, + issue.url, + provider, + needs_mapping_line, + ) +} + /// Extract `external_id` from ticket content frontmatter fn extract_external_id(content: &str) -> Option { // Simple extraction - look for "external_id: " in frontmatter @@ -468,6 +494,34 @@ fn slugify(s: &str, max_len: usize) -> String { mod tests { use super::*; + fn sample_issue() -> ExternalIssue { + ExternalIssue { + id: "10042".to_string(), + key: "PROJ-42".to_string(), + summary: "Fix the widget".to_string(), + description: None, + kanban_issue_types: vec![], + status: "To Do".to_string(), + assignee: None, + url: "https://example.atlassian.net/browse/PROJ-42".to_string(), + priority: Some("Medium".to_string()), + } + } + + #[test] + fn test_frontmatter_stamps_sync_collection() { + let fm = ticket_frontmatter("FIX", &sample_issue(), "jira", false, Some("devops_kanban")); + assert!(fm.contains("collection: devops_kanban")); + assert!(fm.contains("external_provider: jira")); + } + + #[test] + fn test_frontmatter_omits_collection_when_unset() { + let fm = ticket_frontmatter("FIX", &sample_issue(), "jira", true, None); + assert!(!fm.contains("collection:")); + assert!(fm.contains("needs_issuetype_mapping: true")); + } + #[test] fn test_resolve_ticket_type_with_mapping() { let mut mappings = std::collections::HashMap::new(); diff --git a/src/steps/manager.rs b/src/steps/manager.rs index 916a453a..0b7a4252 100644 --- a/src/steps/manager.rs +++ b/src/steps/manager.rs @@ -427,6 +427,7 @@ mod tests { external_id: None, external_url: None, external_provider: None, + collection: None, } } diff --git a/src/steps/session.rs b/src/steps/session.rs index b9394642..b2be7496 100644 --- a/src/steps/session.rs +++ b/src/steps/session.rs @@ -278,6 +278,7 @@ mod tests { external_id: None, external_url: None, external_provider: None, + collection: None, } } diff --git a/src/ui/collection_dialog.rs b/src/ui/collection_dialog.rs index 00cf6b4a..b37a156d 100644 --- a/src/ui/collection_dialog.rs +++ b/src/ui/collection_dialog.rs @@ -9,7 +9,8 @@ use ratatui::{ Frame, }; -use crate::issuetypes::{BuiltinPreset, IssueTypeCollection, IssueTypeRegistry}; +use crate::collections::manifest::CollectionTier; +use crate::issuetypes::{IssueTypeCollection, IssueTypeRegistry}; /// Information about a collection for display #[derive(Debug, Clone)] @@ -17,7 +18,10 @@ pub struct CollectionInfo { pub name: String, pub description: String, pub type_count: usize, - pub is_builtin: bool, + /// Provenance tier from the collection manifest + pub tier: CollectionTier, + /// Author attribution from the collection manifest + pub author: Option, /// Provider name if collection was synced from external source pub sync_source: Option, } @@ -28,7 +32,8 @@ impl CollectionInfo { name: collection.name.clone(), description: collection.description.clone(), type_count: collection.types.len(), - is_builtin: BuiltinPreset::from_name(&collection.name).is_some(), + tier: collection.tier, + author: collection.author.clone(), sync_source: collection.sync_source.as_ref().map(|s| s.provider.clone()), } } @@ -90,13 +95,11 @@ impl CollectionSwitchDialog { .map(CollectionInfo::from_collection) .collect(); - // Sort: builtins first, then by name - self.collections - .sort_by(|a, b| match (a.is_builtin, b.is_builtin) { - (true, false) => std::cmp::Ordering::Less, - (false, true) => std::cmp::Ordering::Greater, - _ => a.name.cmp(&b.name), - }); + // Sort: official tier first, then by name + self.collections.sort_by(|a, b| { + let rank = |t: CollectionTier| matches!(t, CollectionTier::Community); + rank(a.tier).cmp(&rank(b.tier)).then(a.name.cmp(&b.name)) + }); // Select the currently active collection let selected = self @@ -239,14 +242,23 @@ impl CollectionSwitchDialog { .map(|s| format!(" [{s}]")) .unwrap_or_default(); - let builtin_badge = if c.is_builtin { "" } else { " (custom)" }; + let tier_badge = match c.tier { + CollectionTier::Community => " [community]", + CollectionTier::Official => "", + }; + let author_badge = c + .author + .as_ref() + .map(|a| format!(" by {a}")) + .unwrap_or_default(); ListItem::new(vec![ Line::from(vec![ Span::raw(marker), Span::styled(&c.name, Style::default().add_modifier(Modifier::BOLD)), Span::styled(sync_badge, Style::default().fg(Color::Cyan)), - Span::styled(builtin_badge, Style::default().fg(Color::DarkGray)), + Span::styled(tier_badge, Style::default().fg(Color::Cyan)), + Span::styled(author_badge, Style::default().fg(Color::DarkGray)), Span::styled( format!(" ({} types)", c.type_count), Style::default().fg(Color::DarkGray), @@ -333,21 +345,25 @@ mod tests { assert_eq!(info.name, "test"); assert_eq!(info.description, "Test collection"); assert_eq!(info.type_count, 2); - assert!(!info.is_builtin); + assert_eq!(info.tier, CollectionTier::Official); + assert!(info.author.is_none()); assert!(info.sync_source.is_none()); } #[test] - fn test_collection_info_builtin_detection() { + fn test_collection_info_carries_tier_and_author() { use crate::issuetypes::IssueTypeCollection; - let builtin = IssueTypeCollection::new("dev_kanban", "Dev Kanban"); - let info = CollectionInfo::from_collection(&builtin); - assert!(info.is_builtin); - - let custom = IssueTypeCollection::new("my_workflow", "Custom"); - let info = CollectionInfo::from_collection(&custom); - assert!(!info.is_builtin); + let community = IssueTypeCollection::new("ralph_loop", "Ralph").with_manifest_metadata( + None, + None, + None, + Some("snarktank".to_string()), + CollectionTier::Community, + ); + let info = CollectionInfo::from_collection(&community); + assert_eq!(info.tier, CollectionTier::Community); + assert_eq!(info.author.as_deref(), Some("snarktank")); } #[test] @@ -358,14 +374,16 @@ mod tests { name: "a".to_string(), description: String::new(), type_count: 1, - is_builtin: true, + tier: CollectionTier::Official, + author: None, sync_source: None, }, CollectionInfo { name: "b".to_string(), description: String::new(), type_count: 2, - is_builtin: false, + tier: CollectionTier::Community, + author: None, sync_source: None, }, ]; @@ -392,7 +410,8 @@ mod tests { name: "test".to_string(), description: String::new(), type_count: 1, - is_builtin: false, + tier: CollectionTier::Official, + author: None, sync_source: None, }]; dialog.list_state.select(Some(0)); @@ -414,7 +433,8 @@ mod tests { name: "test".to_string(), description: String::new(), type_count: 1, - is_builtin: false, + tier: CollectionTier::Official, + author: None, sync_source: None, }]; dialog.list_state.select(Some(0)); diff --git a/src/ui/dialogs/confirm.rs b/src/ui/dialogs/confirm.rs index 6b8c9f4c..38500687 100644 --- a/src/ui/dialogs/confirm.rs +++ b/src/ui/dialogs/confirm.rs @@ -655,6 +655,7 @@ mod tests { external_id: None, external_url: None, external_provider: None, + collection: None, } } diff --git a/src/workflow_gen/agnt.rs b/src/workflow_gen/agnt.rs index 7103d022..f283cc67 100644 --- a/src/workflow_gen/agnt.rs +++ b/src/workflow_gen/agnt.rs @@ -412,6 +412,7 @@ mod tests { external_id: None, external_url: None, external_provider: None, + collection: None, } } diff --git a/src/workflow_gen/command.rs b/src/workflow_gen/command.rs index c90f422a..df8c849d 100644 --- a/src/workflow_gen/command.rs +++ b/src/workflow_gen/command.rs @@ -74,13 +74,15 @@ pub fn export_workflow_for_ticket( config: &Config, format: WorkflowFormat, ) -> Result { - let issuetype = registry.get(&ticket.ticket_type).ok_or_else(|| { - anyhow!( - "No issue type '{}' registered for ticket {}", - ticket.ticket_type, - ticket.id - ) - })?; + let issuetype = registry + .resolve(ticket.collection.as_deref(), &ticket.ticket_type) + .ok_or_else(|| { + anyhow!( + "No issue type '{}' registered for ticket {}", + ticket.ticket_type, + ticket.id + ) + })?; let env = pipeline_env_for(config, ticket, issuetype); let contents = render(ticket, issuetype, pr_config, &env, config, format)?; @@ -171,6 +173,7 @@ fn preview_ticket(issuetype: &IssueType) -> Ticket { external_id: None, external_url: None, external_provider: None, + collection: None, } } @@ -205,6 +208,7 @@ mod tests { external_id: None, external_url: None, external_provider: None, + collection: None, } } @@ -252,6 +256,49 @@ mod tests { ); } + #[test] + fn ticket_collection_scopes_issuetype_resolution() { + // Same key, different step shapes, in two collections. + let mut r = IssueTypeRegistry::new(); + let mut alpha = crate::issuetypes::IssueType::new_imported( + "TASK".to_string(), + "Alpha Task".to_string(), + "d".to_string(), + "p".to_string(), + "j".to_string(), + None, + ); + alpha.steps[0].name = "alpha_step".to_string(); + let mut beta = alpha.clone(); + beta.name = "Beta Task".to_string(); + beta.steps[0].name = "beta_step".to_string(); + r.register_in("alpha", alpha).unwrap(); + r.register_in("beta", beta).unwrap(); + r.activate_collection("alpha").unwrap(); + + let mut t = ticket("TASK"); + t.collection = Some("beta".to_string()); + let exported = + export_workflow_for_ticket(&t, &r, None, &Config::default(), WorkflowFormat::Claude) + .unwrap(); + assert!( + exported.contents.contains("beta_step"), + "ticket.collection should pick the beta definition: {}", + exported.contents + ); + + // Without a collection, the active collection's definition wins. + let exported = export_workflow_for_ticket( + &ticket("TASK"), + &r, + None, + &Config::default(), + WorkflowFormat::Claude, + ) + .unwrap(); + assert!(exported.contents.contains("alpha_step")); + } + #[test] fn errors_when_issuetype_unknown() { let result = export_workflow_for_ticket( diff --git a/src/workflow_gen/export.rs b/src/workflow_gen/export.rs index aee9a1c0..e151b571 100644 --- a/src/workflow_gen/export.rs +++ b/src/workflow_gen/export.rs @@ -657,6 +657,7 @@ mod tests { external_id: None, external_url: None, external_provider: None, + collection: None, } } diff --git a/src/workflow_gen/mod.rs b/src/workflow_gen/mod.rs index c660f802..314b23a2 100644 --- a/src/workflow_gen/mod.rs +++ b/src/workflow_gen/mod.rs @@ -59,6 +59,7 @@ mod tests { external_id: None, external_url: None, external_provider: None, + collection: None, } } From a5cb1e26abcb9f11158529735df9c4b2368f31c5 Mon Sep 17 00:00:00 2001 From: untra Date: Sat, 1 Aug 2026 10:04:43 -0600 Subject: [PATCH 08/11] hosted collections, webcomponents and docs updates --- .claude/commands/build-docs.md | 8 +- .claude/commands/new-collection.md | 264 ++++++++ .github/workflows/build.yaml | 30 + .github/workflows/docs.yml | 38 ++ .gitignore | 7 +- CLAUDE.md | 20 +- Makefile | 32 +- README.md | 2 +- bindings/AutoGenStrategy.ts | 6 + bindings/ClassifierConfig.ts | 23 + bindings/ClassifierOutputType.ts | 6 + bindings/CollectionResponse.ts | 28 + bindings/CustomFlags.ts | 19 + bindings/DelegatorStepConfig.ts | 23 + bindings/DirectoryPermissions.ts | 14 + bindings/ExecutionMode.ts | 6 + bindings/FieldSchema.ts | 52 ++ bindings/FieldType.ts | 6 + bindings/IssueType.ts | 62 ++ bindings/IssueTypeSource.ts | 14 + bindings/ItemSource.ts | 24 + bindings/MatrixedConfig.ts | 23 + bindings/MatrixedOutputFormat.ts | 6 + bindings/McpServerPermissions.ts | 14 + bindings/McpStepConfig.ts | 23 + bindings/McpToolRef.ts | 14 + bindings/MultiModelConfig.ts | 28 + bindings/MultiPromptConfig.ts | 23 + bindings/OnReject.ts | 14 + bindings/PermissionMode.ts | 6 + bindings/PipelineConfig.ts | 21 + bindings/PipelineStage.ts | 31 + bindings/ProviderCliArgs.ts | 18 + bindings/RagConfig.ts | 23 + bindings/RagSource.ts | 26 + bindings/ReviewType.ts | 6 + bindings/SelectionStrategy.ts | 6 + bindings/StepOutput.ts | 6 + bindings/StepPermissions.ts | 26 + bindings/StepSchema.ts | 123 ++++ bindings/StepTypeTag.ts | 6 + bindings/TemplateSchema.ts | 57 ++ bindings/ToolPattern.ts | 14 + bindings/ToolPermissions.ts | 15 + bindings/VisualReviewConfig.ts | 18 + bindings/VotingMode.ts | 6 + bindings/VotingStrategy.ts | 6 + collections/README.md | 12 +- .../community/example_chores/collection.json | 9 +- collections/community/example_chores/icon.svg | 1 + docs/_config.yml | 1 + docs/_data/navigation.yml | 22 +- docs/_includes/head.html | 6 + docs/_includes/sidebar.html | 5 +- docs/agents/index.md | 102 --- .../index.md} | 2 +- docs/assets/css/main.css | 183 ++++++ docs/assets/icons/anthropic.svg | 2 +- docs/assets/icons/claude-dark.svg | 4 - docs/assets/icons/claude-light.svg | 4 - docs/assets/icons/claude.svg | 4 +- docs/assets/icons/cmux.svg | 4 +- docs/assets/icons/coder.svg | 2 +- docs/assets/icons/codex.svg | 4 +- docs/assets/icons/cursor.svg | 2 +- docs/assets/icons/gemini.svg | 4 +- docs/assets/icons/github.svg | 4 +- docs/assets/icons/gitlab.svg | 4 +- docs/assets/icons/google.svg | 2 +- docs/assets/icons/jira.svg | 4 +- docs/assets/icons/linear.svg | 4 +- docs/assets/icons/notification.svg | 4 +- docs/assets/icons/ollama.svg | 2 +- docs/assets/icons/openrouter.svg | 2 +- docs/assets/icons/tmux.svg | 4 +- docs/assets/icons/vscode.svg | 4 +- docs/assets/icons/webhook.svg | 4 +- docs/assets/icons/zed.svg | 2 +- docs/assets/icons/zellij.svg | 4 +- docs/collections/coder/BUG.json | 139 ++++ docs/collections/coder/BUG.md | 23 + docs/collections/coder/FEATURE.json | 128 ++++ docs/collections/coder/FEATURE.md | 19 + docs/collections/coder/IMPROVEMENT.json | 120 ++++ docs/collections/coder/IMPROVEMENT.md | 19 + docs/collections/coder/collection.json | 80 +++ docs/collections/coder/icon.svg | 1 + docs/collections/dev_kanban/collection.json | 12 +- docs/collections/dev_kanban/icon.svg | 1 + .../collections/devops_kanban/collection.json | 18 +- docs/collections/devops_kanban/icon.svg | 1 + .../elves_overnight/collection.json | 15 +- docs/collections/elves_overnight/icon.svg | 1 + docs/collections/example_chores/CHORE.json | 45 ++ docs/collections/example_chores/CHORE.md | 14 + .../example_chores/collection.json | 50 ++ docs/collections/example_chores/icon.svg | 1 + docs/collections/index.json | 58 +- .../jr_orchestration/collection.json | 18 +- docs/collections/jr_orchestration/icon.svg | 1 + docs/collections/operator/collection.json | 18 +- docs/collections/operator/icon.svg | 1 + docs/collections/ralph_loop/collection.json | 12 +- docs/collections/ralph_loop/icon.svg | 1 + docs/collections/schema.json | 17 +- docs/collections/search.json | 592 ++++++++++++++++++ docs/collections/simple/collection.json | 6 +- docs/collections/simple/icon.svg | 1 + docs/design-system/index.md | 40 ++ docs/getting-started/agents/index.md | 56 ++ docs/getting-started/kanban/index.md | 40 +- docs/{ => getting-started}/tickets/index.md | 20 +- docs/getting-started/workflows/index.md | 18 +- docs/index.md | 9 +- docs/issue-types/index.md | 282 --------- docs/kanban/index.md | 65 -- docs/llms.txt | 13 +- docs/maturity/index.md | 2 +- docs/schemas/openapi.json | 102 ++- docs/workflows/coder/index.md | 49 ++ docs/workflows/dev_kanban/index.md | 49 ++ docs/workflows/devops_kanban/index.md | 51 ++ docs/workflows/elves_overnight/index.md | 50 ++ docs/workflows/example_chores/index.md | 47 ++ docs/workflows/index.md | 287 +++++++++ docs/workflows/jr_orchestration/index.md | 51 ++ docs/workflows/operator/index.md | 51 ++ docs/workflows/ralph_loop/index.md | 49 ++ docs/workflows/simple/index.md | 46 ++ scripts/cicdprep.sh | 63 +- src/app/tickets.rs | 5 +- src/collections/coder/BUG.json | 139 ++++ src/collections/coder/BUG.md | 23 + src/collections/coder/FEATURE.json | 128 ++++ src/collections/coder/FEATURE.md | 19 + src/collections/coder/IMPROVEMENT.json | 120 ++++ src/collections/coder/IMPROVEMENT.md | 19 + src/collections/coder/collection.json | 72 +++ src/collections/coder/icon.svg | 1 + src/collections/dev_kanban/collection.json | 3 + src/collections/dev_kanban/icon.svg | 1 + src/collections/devops_kanban/collection.json | 3 + src/collections/devops_kanban/icon.svg | 1 + .../elves_overnight/collection.json | 5 +- src/collections/elves_overnight/icon.svg | 1 + src/collections/fetch.rs | 39 +- src/collections/full/collection.json | 3 + src/collections/full/icon.svg | 1 + .../jr_orchestration/collection.json | 5 +- src/collections/jr_orchestration/icon.svg | 1 + src/collections/manifest.rs | 164 ++++- src/collections/mod.rs | 45 +- src/collections/operator/collection.json | 3 + src/collections/operator/icon.svg | 1 + src/collections/ralph_loop/collection.json | 5 +- src/collections/ralph_loop/icon.svg | 1 + src/collections/simple/collection.json | 3 + src/collections/simple/icon.svg | 1 + src/collections/validate.rs | 87 ++- src/docs_gen/collections_manifest.rs | 272 ++++++-- src/docs_gen/collections_pages.rs | 459 ++++++++++++++ src/docs_gen/collections_search.rs | 380 +++++++++++ src/docs_gen/llms.rs | 74 ++- src/docs_gen/mod.rs | 118 +++- src/integrations/catalog.rs | 2 +- src/issuetypes/collection.rs | 98 ++- src/issuetypes/loader.rs | 56 +- src/issuetypes/mod.rs | 19 +- src/issuetypes/schema.rs | 7 +- src/main.rs | 112 +--- src/permissions/mod.rs | 22 +- src/rest/dto/issue_types.rs | 84 ++- src/rest/mod.rs | 3 + src/rest/routes/issuetypes.rs | 39 ++ src/schemas/issuetype_collection_schema.json | 17 +- src/startup/templates.rs | 110 +++- src/templates/schema.rs | 88 ++- src/ui/collection_dialog.rs | 14 +- tests/svg_icon_standard.rs | 304 +++++++++ tests/ui_packaging.rs | 141 +++-- tests/workflow_mapper_contract.rs | 168 +++++ ui/bun.lock | 59 -- ui/package.json | 3 - ui/src/api-client.ts | 14 +- ui/src/components/TicketDetailPanel.tsx | 16 +- .../components/WorkflowGraphView.module.css | 27 - ui/src/components/WorkflowGraphView.tsx | 74 --- ui/src/concepts.ts | 6 +- ui/src/routes/IssueTypesPage.tsx | 24 +- ui/tsconfig.json | 3 +- ui/vite.config.ts | 2 + webcomponents/README.md | 71 +++ webcomponents/bun.lock | 330 ++++++++++ webcomponents/package.json | 43 ++ webcomponents/scripts/copy-types.mjs | 73 +++ webcomponents/src/elements.css | 94 +++ webcomponents/src/elements.ts | 32 + .../elements/operator-collection-search.ts | 113 ++++ .../elements/operator-workflow-explorer.tsx | 144 +++++ webcomponents/src/index.ts | 21 + webcomponents/src/shared/theme.ts | 50 ++ webcomponents/src/workflow/WorkflowGraph.tsx | 63 ++ .../src/workflow/issuetype-to-ir.test.ts | 223 +++++++ webcomponents/src/workflow/issuetype-to-ir.ts | 238 +++++++ webcomponents/tsconfig.build.json | 12 + webcomponents/tsconfig.json | 20 + webcomponents/vite.elements.config.ts | 33 + webcomponents/vite.react.config.ts | 26 + 208 files changed, 8582 insertions(+), 1230 deletions(-) create mode 100644 .claude/commands/new-collection.md create mode 100644 bindings/AutoGenStrategy.ts create mode 100644 bindings/ClassifierConfig.ts create mode 100644 bindings/ClassifierOutputType.ts create mode 100644 bindings/CustomFlags.ts create mode 100644 bindings/DelegatorStepConfig.ts create mode 100644 bindings/DirectoryPermissions.ts create mode 100644 bindings/ExecutionMode.ts create mode 100644 bindings/FieldSchema.ts create mode 100644 bindings/FieldType.ts create mode 100644 bindings/IssueType.ts create mode 100644 bindings/IssueTypeSource.ts create mode 100644 bindings/ItemSource.ts create mode 100644 bindings/MatrixedConfig.ts create mode 100644 bindings/MatrixedOutputFormat.ts create mode 100644 bindings/McpServerPermissions.ts create mode 100644 bindings/McpStepConfig.ts create mode 100644 bindings/McpToolRef.ts create mode 100644 bindings/MultiModelConfig.ts create mode 100644 bindings/MultiPromptConfig.ts create mode 100644 bindings/OnReject.ts create mode 100644 bindings/PermissionMode.ts create mode 100644 bindings/PipelineConfig.ts create mode 100644 bindings/PipelineStage.ts create mode 100644 bindings/ProviderCliArgs.ts create mode 100644 bindings/RagConfig.ts create mode 100644 bindings/RagSource.ts create mode 100644 bindings/ReviewType.ts create mode 100644 bindings/SelectionStrategy.ts create mode 100644 bindings/StepOutput.ts create mode 100644 bindings/StepPermissions.ts create mode 100644 bindings/StepSchema.ts create mode 100644 bindings/StepTypeTag.ts create mode 100644 bindings/TemplateSchema.ts create mode 100644 bindings/ToolPattern.ts create mode 100644 bindings/ToolPermissions.ts create mode 100644 bindings/VisualReviewConfig.ts create mode 100644 bindings/VotingMode.ts create mode 100644 bindings/VotingStrategy.ts create mode 100644 collections/community/example_chores/icon.svg delete mode 100644 docs/agents/index.md rename docs/{agents/artifact-detection.md => artifact-detection/index.md} (96%) delete mode 100644 docs/assets/icons/claude-dark.svg delete mode 100644 docs/assets/icons/claude-light.svg create mode 100644 docs/collections/coder/BUG.json create mode 100644 docs/collections/coder/BUG.md create mode 100644 docs/collections/coder/FEATURE.json create mode 100644 docs/collections/coder/FEATURE.md create mode 100644 docs/collections/coder/IMPROVEMENT.json create mode 100644 docs/collections/coder/IMPROVEMENT.md create mode 100644 docs/collections/coder/collection.json create mode 100644 docs/collections/coder/icon.svg create mode 100644 docs/collections/dev_kanban/icon.svg create mode 100644 docs/collections/devops_kanban/icon.svg create mode 100644 docs/collections/elves_overnight/icon.svg create mode 100644 docs/collections/example_chores/CHORE.json create mode 100644 docs/collections/example_chores/CHORE.md create mode 100644 docs/collections/example_chores/collection.json create mode 100644 docs/collections/example_chores/icon.svg create mode 100644 docs/collections/jr_orchestration/icon.svg create mode 100644 docs/collections/operator/icon.svg create mode 100644 docs/collections/ralph_loop/icon.svg create mode 100644 docs/collections/search.json create mode 100644 docs/collections/simple/icon.svg rename docs/{ => getting-started}/tickets/index.md (66%) delete mode 100644 docs/issue-types/index.md delete mode 100644 docs/kanban/index.md create mode 100644 docs/workflows/coder/index.md create mode 100644 docs/workflows/dev_kanban/index.md create mode 100644 docs/workflows/devops_kanban/index.md create mode 100644 docs/workflows/elves_overnight/index.md create mode 100644 docs/workflows/example_chores/index.md create mode 100644 docs/workflows/index.md create mode 100644 docs/workflows/jr_orchestration/index.md create mode 100644 docs/workflows/operator/index.md create mode 100644 docs/workflows/ralph_loop/index.md create mode 100644 docs/workflows/simple/index.md create mode 100644 src/collections/coder/BUG.json create mode 100644 src/collections/coder/BUG.md create mode 100644 src/collections/coder/FEATURE.json create mode 100644 src/collections/coder/FEATURE.md create mode 100644 src/collections/coder/IMPROVEMENT.json create mode 100644 src/collections/coder/IMPROVEMENT.md create mode 100644 src/collections/coder/collection.json create mode 100644 src/collections/coder/icon.svg create mode 100644 src/collections/dev_kanban/icon.svg create mode 100644 src/collections/devops_kanban/icon.svg create mode 100644 src/collections/elves_overnight/icon.svg create mode 100644 src/collections/full/icon.svg create mode 100644 src/collections/jr_orchestration/icon.svg create mode 100644 src/collections/operator/icon.svg create mode 100644 src/collections/ralph_loop/icon.svg create mode 100644 src/collections/simple/icon.svg create mode 100644 src/docs_gen/collections_pages.rs create mode 100644 src/docs_gen/collections_search.rs create mode 100644 tests/svg_icon_standard.rs create mode 100644 tests/workflow_mapper_contract.rs delete mode 100644 ui/src/components/WorkflowGraphView.module.css delete mode 100644 ui/src/components/WorkflowGraphView.tsx create mode 100644 webcomponents/README.md create mode 100644 webcomponents/bun.lock create mode 100644 webcomponents/package.json create mode 100644 webcomponents/scripts/copy-types.mjs create mode 100644 webcomponents/src/elements.css create mode 100644 webcomponents/src/elements.ts create mode 100644 webcomponents/src/elements/operator-collection-search.ts create mode 100644 webcomponents/src/elements/operator-workflow-explorer.tsx create mode 100644 webcomponents/src/index.ts create mode 100644 webcomponents/src/shared/theme.ts create mode 100644 webcomponents/src/workflow/WorkflowGraph.tsx create mode 100644 webcomponents/src/workflow/issuetype-to-ir.test.ts create mode 100644 webcomponents/src/workflow/issuetype-to-ir.ts create mode 100644 webcomponents/tsconfig.build.json create mode 100644 webcomponents/tsconfig.json create mode 100644 webcomponents/vite.elements.config.ts create mode 100644 webcomponents/vite.react.config.ts diff --git a/.claude/commands/build-docs.md b/.claude/commands/build-docs.md index 39d622cd..4cefb6c3 100644 --- a/.claude/commands/build-docs.md +++ b/.claude/commands/build-docs.md @@ -10,14 +10,16 @@ Build and serve the `docs/` subproject locally for inspection. Stop immediately ## Workflow -Run each step sequentially from the `docs/` directory. If any step fails, stop and report the failure clearly. +Run each step sequentially from the repo root. If any step fails, stop and report the failure clearly. -1. **Install dependencies**: `cd docs && bundle install` -2. **Build site**: `cd docs && bundle exec jekyll build` +1. **Install Ruby dependencies**: `cd docs && bundle install` +2. **Build the full site**: `make docs` from the repo root. This runs the whole pipeline in order — ts-rs bindings, the generated reference docs and hosted collection bundle (`cargo run -- docs`), the shared `webcomponents/` bundle, the copy into `docs/assets/js/`, then Jekyll. 3. **Serve locally**: `cd docs && bundle exec jekyll serve` (run in background so the session remains interactive; serves on port 4000) 4. **Report**: Confirm the site is running at http://localhost:4000. Let the user know it auto-rebuilds on file changes. ## Notes +- Use `make docs`, not a bare `bundle exec jekyll build`. Jekyll alone will not regenerate the collection bundle or build the web components, so `/workflows/` pages render an empty graph canvas. +- `bun` is required for step 2 (the `webcomponents/` build). `docs/assets/js/` is a build artifact and is gitignored. - If port 4000 is already in use, report the conflict and suggest killing the existing process or using `--port` to pick a different one. - To stop the server later, kill the background Jekyll process. diff --git a/.claude/commands/new-collection.md b/.claude/commands/new-collection.md new file mode 100644 index 00000000..3cddaaca --- /dev/null +++ b/.claude/commands/new-collection.md @@ -0,0 +1,264 @@ +--- +description: Author a new Operator issuetype collection — a shareable AI workflow +allowed-tools: Bash, Read, Write, Edit, Glob, Grep +--- + +# Author a New Collection + +Create a new workflow **collection**: a named, versioned bundle of issue types that encodes a coherent process of doing software development with AI agents. + +The mechanics are easy; the design is the hard part. workflows allow agents to apply themselves in a deterministic manner. + +## Vocabulary — get this right first + +Three terms that are easy to conflate: + +| Term | What it is | +|---|---| +| **Operator workflow** | The step graph itself: an ordered set of typed steps with review gates and reject edges. Lives in an issue type's `steps`. This is the native format. | +| **Issue type** | One kind of work (`FEAT`, `PRD`, `ELVSTAGE`). Carries identity, input `fields`, and exactly one Operator workflow. | +| **Collection** | A bundle of issue types that work together. What you are authoring. | + +Kanban issue types describe how a *team labels* work; a collection describes how the *agents do* the work. + +### Other non-Operator "workflows" + +the term _"Workflow"_ is overloaded in the AI space. Operator workflows are json described collections of issuetypes. they can export to other workflows, such as `claude workflows` among others. As a result Operator workflows are meant to compose as broad and neutral as possible, with some opinions about structure and behavior. Define your workflows in the context of the work you want to get done. + +## Step 1 — Decide what loop you are encoding + +Answer these before opening an editor. If you cannot answer them crisply, the +collection is not ready to write. + +1. **What is the loop?** What repeats, and what ends it? "Implement one + right-sized story per fresh agent context" (`ralph_loop`) is a loop. + "Do software development" is not. +2. **What are the work types?** Each issue type must be a *genuinely different + shape of work*, not a different priority or label. If two types have the + same steps, they are one type with a field. +3. **Where does state live between steps?** Agents lose context. Name the + files or surfaces that carry memory forward (`workflow_hints.memory_surfaces`). +4. **What are the gates?** Where must a human or a test approve before + continuing, and what happens on rejection? +5. **When does it stop?** Both success and give-up conditions. + +Study the shipped collections before inventing a shape — they are short and +each encodes a real, published methodology: + +```bash +ls src/collections/ # simple, dev_kanban, devops_kanban, operator, + # ralph_loop, jr_orchestration, elves_overnight +cat src/collections/ralph_loop/collection.json +cat src/collections/dev_kanban/FEAT.json # the canonical multi-step example +``` + +## Step 2 — Create the directory + +Official/curated collections that ship in the binary live in +`src/collections//`. Community contributions live in +`collections/community//` and are hosted-only. + +**Pick `src/collections/` only if the collection should be available offline +to every user.** When in doubt, use `collections/community/`. + +``` +/ +├── collection.json # the manifest +├── icon.svg # Simple Icons-shaped glyph +├── .json # one per issue type — the Operator workflow +└── .md # optional ticket template per issue type +``` + +`` must match `^[a-z0-9_]{3,64}$` and equal the directory name. + +## Step 3 — Write the issue types + +One `.json` per issue type. `KEY` matches `^[A-Z][A-Z0-9_]{1,15}$` — +**no hyphens**, because the hyphen separates the key from the ticket number in +`FEAT-123-project-summary.md`. + +Start from the JSON Schema and a real example: + +```bash +cat src/schemas/issuetype_schema.json # the contract +cat src/collections/ralph_loop/STORY.json # a 5-step autonomous workflow +``` + +Required top-level fields: `key`, `name`, `description`, `mode`, `glyph`, +`fields`, `steps`. Also set `"$schema": "../../schemas/issuetype_schema.json"` +so editors validate as you type. + +- **`mode`** — `autonomous` (launch and monitor; several run in parallel) or + `paired` (needs you in the loop; one at a time). This is a real scheduling + constraint, not a hint. Choose `paired` only when a human genuinely must + participate throughout. +- **`glyph`** — one character shown in the TUI. Already in use across + collections: `! # % * > ? @ B E F J L P R S T V ~`. Pick something unused and + mnemonic. +- **`color`** — one of `cyan`, `green`, `blue`, `magenta`, `yellow`, `red`. +- **`fields`** — the ticket's inputs. Types: `string`, `text`, `enum`, `bool`, + `date`, `integer`. Use `"auto": "id" | "date" | "branch" | "status"` for + values Operator fills in, and mark those `"user_editable": false`. + +### Designing the steps + +Steps are where the methodology actually lives. Each step is one agent session. + +```jsonc +{ + "name": "plan", // lowercase identifier + "display_name": "Planning", // shown in the UI + "outputs": ["plan"], // plan|code|test|pr|ticket|review|report|documentation + "prompt": "...", // Handlebars over the ticket's fields: {{ summary }} + "allowed_tools": ["Read", "Grep"], // least privilege for this step + "artifact_patterns": [".tickets/plans/{{ id }}.md"], // files that signal completion + "review_type": "plan", // none|plan|visual|pr — a gate + "on_reject": { "goto_step": "plan", "prompt": "Plan rejected: {{ rejection_reason }}..." }, + "next_step": "build" // omit on the final step +} +``` + +Rules that matter: + +- **Chain with `next_step`.** Ordering follows the chain from the first step, + then appends anything unreached. Do not rely on array order alone. +- **`on_reject.goto_step` is the retry edge** — it may point backwards, and + usually should point at the step that can actually fix the problem (a failed + PR review goes back to `code`, not to `plan`). +- **One step, one job.** A step that plans *and* implements *and* tests gives + the agent no checkpoint and no place to fail cleanly. +- **Scope `allowed_tools` per step.** A planning step should not have `Write` + to source. This is the main safety control you have. +- **Prompts are Handlebars** over the ticket's fields. Reference only fields + you actually declared. + +Beyond plain `task` steps, these types exist — use them when the shape calls +for it, not for novelty: `classifier`, `rag`, `delegator`, `mcp`, +`multi_model` (fan out, then vote), `multi_prompt`, `matrixed`, `pipeline`. + +### Ticket templates + +`.md` is the markdown scaffold for a new ticket, with YAML frontmatter +and Handlebars placeholders. Copy the shape from an existing one: + +```bash +cat src/collections/dev_kanban/FEAT.md +``` + +## Step 4 — Write the manifest + +```jsonc +{ + "schema_version": 1, + "id": "", // must equal the directory name + "name": "Display Name", + "description": "One line. What loop is this, in plain language.", + "version": "1.0.0", + "publisher": "untra", + "author": "you-or-the-methodology-author", + "url": "https://github.com/...", // where the methodology comes from + "license": "MIT", + "tags": ["agentic-loop", "..."], + "tier": "community", // or "official" for src/collections/ + "icon_path": "icon.svg", + "created": "YYYY-MM-DD", + "updated": "YYYY-MM-DD", + "issue_types": [ // display order; also priority order + { "key": "PRD", "schema_path": "PRD.json", "template_path": "PRD.md" } + ], + "workflow_hints": { + "loop_kind": "fresh_context_story_loop", + "memory_surfaces": ["docs/plan.md"], + "review_gates": ["plan_review", "test_suite"], + "external_tools": ["git", "gh"], + "stop_conditions": ["all stories pass", "budget exhausted"], + "runner_semantics": "prompt_driven" + }, + "default_selected": ["PRD"] +} +``` + +**Do not write `checksum` or `schema_checksum`** — the docs generator computes +them at publish time. `tier: "community"` additionally requires `author`, +`url`, `license`, and `icon_path`. + +`workflow_hints` is descriptive metadata (v1 does not execute it) but it is +what the catalog page displays, so it is how a reader decides whether to adopt +your collection. Write it for them, not for the parser. + +## Step 5 — Draw the icon + +A single-path 24×24 glyph. The full rules and rationale are in +`docs/design-system/` under "Brand & collection icons"; the short version: + +```svg +Display Name +``` + +No `fill`, `stroke`, `width`, or `height` — the icon inherits `currentColor` +and its container's size. The `` must equal the manifest's `name`. + +Render it and *look at it* before trusting it — hand-authored path data is easy +to get subtly wrong, and the test checks shape, not whether the glyph reads: + +```bash +# whichever is available +rsvg-convert -w 96 -h 96 -b white <id>/icon.svg -o /tmp/icon.png +magick -background white -density 384 <id>/icon.svg /tmp/icon.png +``` + +Then open `/tmp/icon.png`. If neither tool is installed, open the SVG in a +browser. + +## Step 6 — Register it (embedded collections only) + +Skip this for `collections/community/`. For `src/collections/<id>/`, add an +entry to `EMBEDDED_COLLECTIONS` in `src/collections/mod.rs`, following the +existing entries exactly — `manifest`, `icon_svg`, and one `EmbeddedIssueType` +per key, in the same order as the manifest. + +## Step 7 — Validate + +Run these in order and fix anything that fails. Do not skip ahead. + +```bash +# Community collections: schema, key grammar, paths, attribution, referenced files +cargo test --test community_collections + +# Icon shape +cargo test --test svg_icon_standard + +# Everything: manifest parsing, embedded/manifest ordering, checksums, generators +make check + +# Publish the bundle and the catalog page +cargo run -- docs +``` + +Then look at the result: + +```bash +make docs +cd docs/_site && python3 -m http.server 4100 +``` + +Open `http://localhost:4100/workflows/` — your collection should appear as a +card — then its page, and step through each issue type's graph. **A workflow +that looks wrong as a graph is wrong.** Disconnected nodes, a reject edge +pointing somewhere useless, or a 12-step chain with no gates are all visible +at a glance and all worth fixing before shipping. + +## What good looks like + +Before opening a PR, check the collection against its own claims: + +- Could someone adopt this without reading the source? The description and + `workflow_hints` should be enough. +- Does each issue type earn its place, or is one of them a field on another? +- Does every reject edge point at a step that can actually fix the problem? +- Is `allowed_tools` scoped per step, or did every step get `["*"]`? +- Does the loop actually terminate, and is that visible in `stop_conditions`? + +Submissions are reviewed for prompt quality and safety, not just schema +validity. A good collection describes a workflow shape worth sharing: what loop +it runs, what memory it keeps, what gates it enforces, and when it stops. diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 1a8c212c..d65c0e65 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -52,11 +52,34 @@ jobs: key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} restore-keys: ${{ runner.os }}-cargo- + # The frontend is typed against these; generate before anything compiles. + - name: Generate TypeScript bindings + run: cargo test --locked export_bindings_ + + # bindings/ is committed, so a Rust change without a regeneration would + # otherwise leave TypeScript silently stale. `git status` rather than + # `git diff` because a newly exported type produces an *untracked* file, + # which `git diff` does not see. + - name: Verify generated bindings are committed + run: | + changes="$(git status --porcelain --untracked-files=all bindings/)" + if [ -n "$changes" ]; then + echo "::error::bindings/ is out of date. Run 'make bindings' and commit the result." + echo "$changes" + exit 1 + fi + - name: Setup Bun uses: oven-sh/setup-bun@v2 with: bun-version: 1.3.14 + - name: Build shared web components + run: | + cd webcomponents + bun install --frozen-lockfile + bun run build + - name: Build UI dist run: | cd ui @@ -274,6 +297,13 @@ jobs: # Pinned to match .tool-versions bun-version: 1.3.14 + # Shared components first: ui/ imports @operator/webcomponents from its dist. + - name: Build shared web components + run: | + cd webcomponents + bun install --frozen-lockfile + bun run build + # Build embedded web UI for operator binary - name: Build UI for embedding run: | diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 47a0ef4c..31aace1e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -12,6 +12,10 @@ on: - 'src/collections/**' - 'collections/**' - 'src/schemas/**' + # The shared components bundle ships with the site, and the workflow + # exporter's ordering rule is mirrored by the graph mapper. + - 'webcomponents/**' + - 'src/workflow_gen/**' - '.github/workflows/docs.yml' workflow_dispatch: @@ -54,6 +58,30 @@ jobs: - name: Generate reference docs run: cargo run --locked -- docs + # The components are typed against these; generate before they compile. + - name: Generate TypeScript bindings + run: cargo test --locked export_bindings_ + + # Shared components, built before Jekyll so the site never needs a + # prebuilt bundle committed to the repo. + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + + - name: Build shared web components + working-directory: webcomponents + run: | + bun install --frozen-lockfile + bun run typecheck + bun test + bun run build + + - name: Stage web components into the site + run: | + mkdir -p docs/assets/js + cp webcomponents/dist/elements.js webcomponents/dist/elements.css docs/assets/js/ + - name: Setup Ruby uses: ruby/setup-ruby@v1 with: @@ -69,6 +97,16 @@ jobs: run: bundle exec jekyll build working-directory: docs + # The hosted collection bundle is excluded from Jekyll processing (see + # docs/_config.yml) and copied in verbatim, so every file operator fetches + # matches the SHA-256 recorded in its manifest. + - name: Stage the collection bundle verbatim + run: cp -R docs/collections docs/_site/ + + - name: Verify the served bundle is byte-identical + run: | + diff -r docs/collections docs/_site/collections + - name: Deploy to gh-pages if: github.event_name == 'workflow_dispatch' uses: peaceiris/actions-gh-pages@v4 diff --git a/.gitignore b/.gitignore index 8300859d..16b74bf1 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,10 @@ zed-extension/target/ dist/ node_modules/ +# Shared web-component bundle copied into the docs site by `make docs`. +# Built from webcomponents/ ahead of the Jekyll build; never committed. +docs/assets/js/ + # Test coverage coverage/ *.lcov @@ -42,8 +46,9 @@ docs/_site/ # built zed extension *.wasm -# vscode-extension generated types (copied from bindings/) +# generated types copied from bindings/ (bindings/ itself is committed) vscode-extension/src/generated/ +webcomponents/src/generated/ vscode-extension/out/ vscode-extension/.vscode-test/ test-output.txt diff --git a/CLAUDE.md b/CLAUDE.md index 44639d6c..8548f4d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -236,6 +236,9 @@ Operator uses a schema-driven, code-derived documentation strategy to reduce mai | `src/config.rs` | `docs/configuration/index.md` | Config structure (via schemars) | | `src/rest/` | `docs/schemas/openapi.json` | REST API spec (via utoipa) | | `src/docs_gen/llms.rs` + `docs/*/index.md` | `docs/llms.txt` | llms.txt site map for LLMs (no front matter; served verbatim) | +| `src/collections/*/collection.json` + `collections/community/*/` | `docs/collections/` | Hosted collection bundle: `index.json`, per-collection manifests, issuetype schemas, templates, icons | +| the same collection sources | `docs/collections/search.json` | Machine-readable collection catalog (also feeds the `/workflows/` page) | +| the same collection sources | `docs/workflows/` | The workflow catalog page + one page per collection | ### Regenerating Documentation @@ -248,7 +251,9 @@ cargo run -- docs --only taxonomy cargo run -- docs --only openapi cargo run -- docs --only config -# Available generators: taxonomy, issuetype, metadata, shortcuts, cli, config, openapi +# `--only` accepts any key from docs_gen::all_generators(); an unknown key +# prints the full list. `llm-tools` is opt-in only: it is excluded from a full +# run because docs/llm-tools/index.md is currently maintained by hand. ``` ### Auto-Generated File Headers @@ -264,8 +269,9 @@ All generated files include a header warning: 1. Create a struct implementing `DocGenerator` trait in `src/docs_gen/` 2. Implement `name()`, `source()`, `output_path()`, and `generate()` -3. Register in `src/docs_gen/mod.rs` `generate_all()` function -4. Add to CLI match in `src/main.rs` `cmd_docs()` +3. Register it in `src/docs_gen/mod.rs` `all_generators()` — that one list drives + the full run, the `--only` filter, and the CLI help text, so there is nothing + to add in `src/main.rs` ## Design & UI Consistency @@ -288,3 +294,11 @@ it; never re-declare a brand color elsewhere. When adding or changing UI: change a brand color in `tokens.css` (web surfaces follow automatically); reference semantic tokens in new web CSS; map a role to ANSI in the TUI; and leave the webview deferring to the editor theme. + +**Icons.** Every SVG icon follows the Operator icon standard — a single +monochrome `<path>` on a 24×24 canvas with no `fill`/`stroke`/`width`/`height`, +so it tints from `currentColor` and sizes to its container on all four +surfaces. Governed directories: `icons/`, `docs/assets/icons/`, +`ui/public/icons/`, and each collection's `icon.svg`. Enforced by +`cargo test --test svg_icon_standard`; the rules and rationale are in +`docs/design-system/`. diff --git a/Makefile b/Makefile index 5437bd9f..14784276 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ # a clean CI run. `make install-hooks` wires the committed pre-push hook so the # same gate runs automatically before every push. -.PHONY: check fmt clippy test build run install-hooks +.PHONY: check fmt clippy test build run install-hooks bindings webcomponents ui docs # Full CI-parity gate. Keep these commands byte-identical to # .github/workflows/build.yaml so local and CI never disagree. @@ -27,6 +27,36 @@ build: run: cargo run +# TypeScript types generated from the Rust domain types. ts-rs writes bindings/ +# as a side effect of the `export_bindings_*` tests it generates, so this is the +# first link in the chain: cargo -> bindings/ -> copy-types -> tsc/vite. +bindings: + cargo test --locked export_bindings_ + +# Shared frontend components. Built ahead of both consumers so the SPA and the +# docs site render collections from one implementation, and so neither needs a +# prebuilt artifact committed to the repo. Depends on `bindings` because the +# components are typed against the generated Rust types. +webcomponents: bindings + cd webcomponents && bun install --frozen-lockfile && bun run typecheck && bun test && bun run build + +# The embedded SPA, which resolves @operator/webcomponents from its dist/. +ui: webcomponents + cd ui && bun install --frozen-lockfile && bun run build + +# Full docs pipeline: bindings, generated reference docs and the hosted +# collection bundle, the shared components bundle, then Jekyll. Mirrors the +# ordering in .github/workflows/docs.yml. +docs: webcomponents + cargo test --locked + cargo run --locked -- docs + mkdir -p docs/assets/js + cp webcomponents/dist/elements.js webcomponents/dist/elements.css docs/assets/js/ + cd docs && bundle exec jekyll build + # The collection bundle is excluded from Jekyll (see docs/_config.yml) and + # copied in verbatim, so the bytes operator fetches match their checksums. + cp -R docs/collections docs/_site/ + # One-time per clone: route git hooks at the committed .githooks/ directory. install-hooks: git config core.hooksPath .githooks diff --git a/README.md b/README.md index 017a38f2..bfc7178c 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ * **Platform** [![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=white)](https://operator.untra.io/getting-started/platforms/docker/) [![Coder](https://img.shields.io/badge/Coder-7C71FF?logo=coder&logoColor=white)](https://operator.untra.io/getting-started/platforms/coder/) -* **Workflow Format** [![Claude Workflow](https://img.shields.io/badge/Claude_Workflow-D97757?logo=claude&logoColor=white)](https://operator.untra.io/getting-started/workflows/claude/) [![AGNT Workflow](https://img.shields.io/badge/AGNT_Workflow-6E56CF)](https://operator.untra.io/getting-started/workflows/agnt/) +* **Workflow Export Format** [![Claude Workflow](https://img.shields.io/badge/Claude_Workflow-D97757?logo=claude&logoColor=white)](https://operator.untra.io/getting-started/workflows/claude/) [![AGNT Workflow](https://img.shields.io/badge/AGNT_Workflow-6E56CF)](https://operator.untra.io/getting-started/workflows/agnt/) An orchestration tool for [**AI-assisted**](https://operator.untra.io/getting-started/agents/) [_kanban-shaped_](https://operator.untra.io/getting-started/kanban/) [git-versioned](https://operator.untra.io/getting-started/git/) software development. diff --git a/bindings/AutoGenStrategy.ts b/bindings/AutoGenStrategy.ts new file mode 100644 index 00000000..4fc27988 --- /dev/null +++ b/bindings/AutoGenStrategy.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Auto-generation strategies for fields + */ +export type AutoGenStrategy = "id" | "date" | "branch" | "status"; diff --git a/bindings/ClassifierConfig.ts b/bindings/ClassifierConfig.ts new file mode 100644 index 00000000..7210672f --- /dev/null +++ b/bindings/ClassifierConfig.ts @@ -0,0 +1,23 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ClassifierOutputType } from "./ClassifierOutputType"; + +/** + * Configuration for classifier steps that return structured typed output + */ +export type ClassifierConfig = { +/** + * What type of answer the classifier returns + */ +output_type: ClassifierOutputType, +/** + * For enum type: the allowed options + */ +options?: Array<string> | null, +/** + * For `short_string`: max character length (default 255) + */ +max_length?: number | null, +/** + * Agent/delegator to use (overrides issuetype default) + */ +agent?: string | null, }; diff --git a/bindings/ClassifierOutputType.ts b/bindings/ClassifierOutputType.ts new file mode 100644 index 00000000..a69bfdc3 --- /dev/null +++ b/bindings/ClassifierOutputType.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Output types for classifier steps + */ +export type ClassifierOutputType = "boolean" | "number" | "short_string" | "big_text" | "enum"; diff --git a/bindings/CollectionResponse.ts b/bindings/CollectionResponse.ts index 6c4ca395..674ece99 100644 --- a/bindings/CollectionResponse.ts +++ b/bindings/CollectionResponse.ts @@ -13,6 +13,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). */ diff --git a/bindings/CustomFlags.ts b/bindings/CustomFlags.ts new file mode 100644 index 00000000..ff8b85e7 --- /dev/null +++ b/bindings/CustomFlags.ts @@ -0,0 +1,19 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * Per-provider custom configuration flags + */ +export type CustomFlags = { +/** + * Claude-specific configuration flags + */ +claude: { [key in string]: JsonValue }, +/** + * Gemini-specific configuration flags + */ +gemini: { [key in string]: JsonValue }, +/** + * Codex-specific configuration flags + */ +codex: { [key in string]: JsonValue }, }; diff --git a/bindings/DelegatorStepConfig.ts b/bindings/DelegatorStepConfig.ts new file mode 100644 index 00000000..5cfd1864 --- /dev/null +++ b/bindings/DelegatorStepConfig.ts @@ -0,0 +1,23 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { StepPermissions } from "./StepPermissions"; + +/** + * Configuration for delegator steps that run with a specific model+flavor + */ +export type DelegatorStepConfig = { +/** + * Named delegator reference (from config.delegators) + */ +delegator: string, +/** + * Additional prompt flavor text prepended to the step prompt + */ +prompt_flavor?: string | null, +/** + * Tools allowed + */ +allowed_tools: Array<string>, +/** + * Permissions + */ +permissions?: StepPermissions | null, }; diff --git a/bindings/DirectoryPermissions.ts b/bindings/DirectoryPermissions.ts new file mode 100644 index 00000000..2edb7ea8 --- /dev/null +++ b/bindings/DirectoryPermissions.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Directory-level permissions + */ +export type DirectoryPermissions = { +/** + * Additional directories to allow access to (glob patterns) + */ +allow: Array<string>, +/** + * Directories to deny access to (glob patterns) + */ +deny: Array<string>, }; diff --git a/bindings/ExecutionMode.ts b/bindings/ExecutionMode.ts new file mode 100644 index 00000000..2b90fa7a --- /dev/null +++ b/bindings/ExecutionMode.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Execution mode for an issuetype + */ +export type ExecutionMode = "autonomous" | "paired"; diff --git a/bindings/FieldSchema.ts b/bindings/FieldSchema.ts new file mode 100644 index 00000000..bd48032d --- /dev/null +++ b/bindings/FieldSchema.ts @@ -0,0 +1,52 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AutoGenStrategy } from "./AutoGenStrategy"; +import type { FieldType } from "./FieldType"; + +/** + * Schema definition for a single field in a template + */ +export type FieldSchema = { +/** + * Field identifier (matches handlebar variable name) + */ +name: string, +/** + * Help text for the field + */ +description: string, +/** + * Type of the field + */ +type: FieldType, +/** + * Whether this field must be filled + */ +required: boolean, +/** + * Default value if any + */ +default?: string | null, +/** + * Auto-generation strategy for this field + */ +auto?: AutoGenStrategy | null, +/** + * Options for enum fields + */ +options: Array<string>, +/** + * Placeholder text shown in template + */ +placeholder?: string | null, +/** + * Maximum length for string fields + */ +max_length?: number | null, +/** + * Display order in form (lower = first) + */ +display_order?: number | null, +/** + * Whether the user can edit this field (false for auto-generated) + */ +user_editable: boolean, }; diff --git a/bindings/FieldType.ts b/bindings/FieldType.ts new file mode 100644 index 00000000..357d3515 --- /dev/null +++ b/bindings/FieldType.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Types of fields supported in template schemas + */ +export type FieldType = "string" | "enum" | "bool" | "date" | "text" | "integer"; diff --git a/bindings/IssueType.ts b/bindings/IssueType.ts new file mode 100644 index 00000000..6acd81d4 --- /dev/null +++ b/bindings/IssueType.ts @@ -0,0 +1,62 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ExecutionMode } from "./ExecutionMode"; +import type { FieldSchema } from "./FieldSchema"; +import type { IssueTypeSource } from "./IssueTypeSource"; +import type { StepSchema } from "./StepSchema"; + +/** + * An issue type definition (dynamic version of `TemplateSchema`) + */ +export type IssueType = { +/** + * Unique issuetype key (e.g., FEAT, FIX, STORY, BUG) + */ +key: string, +/** + * Display name of the issue type + */ +name: string, +/** + * Brief description of when to use this issue type + */ +description: string, +/** + * Whether this issue type runs autonomously or requires human pairing + */ +mode: ExecutionMode, +/** + * Glyph character displayed in UI for this issue type + */ +glyph: string, +/** + * Optional color for glyph display in TUI + */ +color?: string | null, +/** + * Whether a project must be specified for this issue type + */ +project_required: boolean, +/** + * Field definitions for this issue type + */ +fields: Array<FieldSchema>, +/** + * Lifecycle steps for completing this ticket type + */ +steps: Array<StepSchema>, +/** + * Prompt for generating this issue type's operator agent via `claude -p` + */ +agent_prompt?: string | null, +/** + * Default delegator name for this issuetype (overridden by step.agent) + */ +agent?: string | null, +/** + * Source of this issue type (builtin, user, import) + */ +source: IssueTypeSource, +/** + * Original external ID (for imported types) + */ +external_id?: string | null, }; diff --git a/bindings/IssueTypeSource.ts b/bindings/IssueTypeSource.ts new file mode 100644 index 00000000..9c151a5f --- /dev/null +++ b/bindings/IssueTypeSource.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Source of an issue type definition + */ +export type IssueTypeSource = "builtin" | "user" | { "import": { +/** + * Provider name (e.g., "jira", "linear") + */ +provider: string, +/** + * Project/team identifier + */ +project: string, } }; diff --git a/bindings/ItemSource.ts b/bindings/ItemSource.ts new file mode 100644 index 00000000..7bba7b5e --- /dev/null +++ b/bindings/ItemSource.ts @@ -0,0 +1,24 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Where a pipeline's iterated items come from. The variant determines *when* + * the list resolves: export-time (a literal array → static fan-out width in + * the compiled graph) vs runtime (an identifier → symbolic width). + */ +export type ItemSource = { "type": "projects" } | { "type": "from_step", +/** + * Name of the prior step whose (array) output is iterated. + */ +step: string, } | { "type": "glob", +/** + * Glob pattern, relative to the project root. + */ +pattern: string, } | { "type": "static", +/** + * The items to iterate. + */ +items: Array<string>, } | { "type": "field", +/** + * Name of the ticket field to read. + */ +name: string, }; diff --git a/bindings/MatrixedConfig.ts b/bindings/MatrixedConfig.ts new file mode 100644 index 00000000..ab2c51cc --- /dev/null +++ b/bindings/MatrixedConfig.ts @@ -0,0 +1,23 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { MatrixedOutputFormat } from "./MatrixedOutputFormat"; + +/** + * Configuration for matrixed work output steps (N x M delegators x prompts) + */ +export type MatrixedConfig = { +/** + * Named delegator references (N), minimum 2 + */ +delegators: Array<string>, +/** + * Prompt variations (M) — Handlebars templates, minimum 2 + */ +prompt_variations: Array<string>, +/** + * How to organize/present the N x M output + */ +output_format: MatrixedOutputFormat, +/** + * Optional aggregation prompt (receives the full matrix of results) + */ +aggregation_prompt?: string | null, }; diff --git a/bindings/MatrixedOutputFormat.ts b/bindings/MatrixedOutputFormat.ts new file mode 100644 index 00000000..245407e9 --- /dev/null +++ b/bindings/MatrixedOutputFormat.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Output format for matrixed steps + */ +export type MatrixedOutputFormat = "directory" | "structured"; diff --git a/bindings/McpServerPermissions.ts b/bindings/McpServerPermissions.ts new file mode 100644 index 00000000..69cbb68c --- /dev/null +++ b/bindings/McpServerPermissions.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * MCP server permissions (server-level enable/disable only) + */ +export type McpServerPermissions = { +/** + * MCP servers to enable for this step + */ +enable: Array<string>, +/** + * MCP servers to disable for this step + */ +disable: Array<string>, }; diff --git a/bindings/McpStepConfig.ts b/bindings/McpStepConfig.ts new file mode 100644 index 00000000..c03069ab --- /dev/null +++ b/bindings/McpStepConfig.ts @@ -0,0 +1,23 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpToolRef } from "./McpToolRef"; + +/** + * Configuration for MCP steps that require specific MCP tools + */ +export type McpStepConfig = { +/** + * MCP tools that MUST be available (step fails if missing) + */ +required_tools: Array<McpToolRef>, +/** + * MCP tools that SHOULD be available (warning if missing) + */ +optional_tools: Array<McpToolRef>, +/** + * Agent/delegator to use + */ +agent?: string | null, +/** + * Tools allowed (in addition to MCP tools) + */ +allowed_tools: Array<string>, }; diff --git a/bindings/McpToolRef.ts b/bindings/McpToolRef.ts new file mode 100644 index 00000000..bd3eb874 --- /dev/null +++ b/bindings/McpToolRef.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Reference to a specific MCP server tool + */ +export type McpToolRef = { +/** + * MCP server name + */ +server: string, +/** + * Specific tool name (None = all tools from this server) + */ +tool?: string | null, }; diff --git a/bindings/MultiModelConfig.ts b/bindings/MultiModelConfig.ts new file mode 100644 index 00000000..22a680a7 --- /dev/null +++ b/bindings/MultiModelConfig.ts @@ -0,0 +1,28 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { VotingMode } from "./VotingMode"; +import type { VotingStrategy } from "./VotingStrategy"; + +/** + * Configuration for multi-model delegation steps (fan-out + vote) + */ +export type MultiModelConfig = { +/** + * Named delegator references (from config.delegators), minimum 2 + */ +delegators: Array<string>, +/** + * How to aggregate/select the final answer + */ +voting_strategy: VotingStrategy, +/** + * Whether to share all answers with all models in the voting round + */ +share_answers: boolean, +/** + * Prompt for the voting round (Handlebars, receives {{ answers }} array) + */ +voting_prompt?: string | null, +/** + * How the voting round executes + */ +voting_mode: VotingMode, }; diff --git a/bindings/MultiPromptConfig.ts b/bindings/MultiPromptConfig.ts new file mode 100644 index 00000000..39a9cc1c --- /dev/null +++ b/bindings/MultiPromptConfig.ts @@ -0,0 +1,23 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { SelectionStrategy } from "./SelectionStrategy"; + +/** + * Configuration for multi-prompt interrogation steps (N variations, select best) + */ +export type MultiPromptConfig = { +/** + * Prompt variations (Handlebars templates), minimum 2 + */ +prompt_variations: Array<string>, +/** + * How to select the best result + */ +selection_strategy: SelectionStrategy, +/** + * Agent/delegator to use for all variations + */ +agent?: string | null, +/** + * Prompt for the selection/review round + */ +selection_prompt?: string | null, }; diff --git a/bindings/OnReject.ts b/bindings/OnReject.ts new file mode 100644 index 00000000..8b209cce --- /dev/null +++ b/bindings/OnReject.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Action to take when a step is rejected + */ +export type OnReject = { +/** + * Step name to return to on rejection + */ +goto_step: string, +/** + * Prompt to use when restarting after rejection + */ +prompt: string, }; diff --git a/bindings/PermissionMode.ts b/bindings/PermissionMode.ts new file mode 100644 index 00000000..8ac0d84a --- /dev/null +++ b/bindings/PermissionMode.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Permission mode for LLM interaction + */ +export type PermissionMode = "default" | "plan" | "acceptEdits" | "delegate"; diff --git a/bindings/PipelineConfig.ts b/bindings/PipelineConfig.ts new file mode 100644 index 00000000..4b69cf04 --- /dev/null +++ b/bindings/PipelineConfig.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. +import type { ItemSource } from "./ItemSource"; +import type { PipelineStage } from "./PipelineStage"; + +/** + * Configuration for pipeline steps: iterate a list of items through ordered + * stages with no barrier (each item flows through all stages independently). + * + * The step graph stays linear — a pipeline step still has exactly one + * `next_step`. The fan-out (N items x M stages) lives entirely inside this one + * step; iteration is an intra-step concern, never a step-to-step edge. + */ +export type PipelineConfig = { +/** + * Where the iterated items come from. + */ +item_source: ItemSource, +/** + * Ordered mini-steps each item flows through. Must be non-empty. + */ +stages: Array<PipelineStage>, }; diff --git a/bindings/PipelineStage.ts b/bindings/PipelineStage.ts new file mode 100644 index 00000000..463bec91 --- /dev/null +++ b/bindings/PipelineStage.ts @@ -0,0 +1,31 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * A single stage in a pipeline — deliberately flat (not a recursive + * `StepSchema`): "prompt + optional agent/model/schema" only. It has no + * `next_step`/`review_type`/`on_reject`, so a stage cannot reopen the + * step-graph linearity question. + */ +export type PipelineStage = { +/** + * Handlebars prompt. The per-item value is appended as a JS binding at + * export time (see `workflow_gen::export`), not via a Handlebars variable. + */ +prompt: string, +/** + * Optional agent/delegator name (falls back to the step/issuetype agent). + */ +agent?: string | null, +/** + * Optional model pin (emitted as `{ model: … }`). + */ +model?: string | null, +/** + * Optional structured-output JSON schema (emitted as `{ schema: … }`). + */ +jsonSchema?: JsonValue | null, +/** + * Optional display label override (defaults to `<step>:<stage-index>`). + */ +label?: string | null, }; diff --git a/bindings/ProviderCliArgs.ts b/bindings/ProviderCliArgs.ts new file mode 100644 index 00000000..db19b48b --- /dev/null +++ b/bindings/ProviderCliArgs.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. + +/** + * Arbitrary CLI arguments per provider + */ +export type ProviderCliArgs = { +/** + * CLI arguments for Claude + */ +claude: Array<string>, +/** + * CLI arguments for Gemini + */ +gemini: Array<string>, +/** + * CLI arguments for Codex + */ +codex: Array<string>, }; diff --git a/bindings/RagConfig.ts b/bindings/RagConfig.ts new file mode 100644 index 00000000..fbc973e0 --- /dev/null +++ b/bindings/RagConfig.ts @@ -0,0 +1,23 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { RagSource } from "./RagSource"; + +/** + * Configuration for RAG (retrieval-augmented generation) steps + */ +export type RagConfig = { +/** + * Context sources to retrieve before running the prompt + */ +sources: Array<RagSource>, +/** + * Maximum tokens of context to inject (default: 50000) + */ +max_context_tokens?: number | null, +/** + * Agent/delegator to use + */ +agent?: string | null, +/** + * Tools allowed for the agent + */ +allowed_tools: Array<string>, }; diff --git a/bindings/RagSource.ts b/bindings/RagSource.ts new file mode 100644 index 00000000..2375d780 --- /dev/null +++ b/bindings/RagSource.ts @@ -0,0 +1,26 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A source of context for RAG steps + */ +export type RagSource = { "type": "glob", +/** + * Glob pattern relative to project root + */ +pattern: string, } | { "type": "file", +/** + * File path relative to project root + */ +path: string, } | { "type": "mcp", +/** + * MCP server name + */ +server: string, +/** + * Tool name on the MCP server + */ +tool: string, +/** + * Optional query template (Handlebars) + */ +query: string | null, }; diff --git a/bindings/ReviewType.ts b/bindings/ReviewType.ts new file mode 100644 index 00000000..63e513b3 --- /dev/null +++ b/bindings/ReviewType.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Type of review required for a step + */ +export type ReviewType = "none" | "plan" | "visual" | "pr"; diff --git a/bindings/SelectionStrategy.ts b/bindings/SelectionStrategy.ts new file mode 100644 index 00000000..3ee34946 --- /dev/null +++ b/bindings/SelectionStrategy.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Selection strategy for multi-prompt steps + */ +export type SelectionStrategy = "model_choice" | "scored"; diff --git a/bindings/StepOutput.ts b/bindings/StepOutput.ts new file mode 100644 index 00000000..26a0a12d --- /dev/null +++ b/bindings/StepOutput.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Types of outputs a step can produce + */ +export type StepOutput = "plan" | "code" | "test" | "pr" | "ticket" | "review" | "report" | "documentation"; diff --git a/bindings/StepPermissions.ts b/bindings/StepPermissions.ts new file mode 100644 index 00000000..6771417b --- /dev/null +++ b/bindings/StepPermissions.ts @@ -0,0 +1,26 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CustomFlags } from "./CustomFlags"; +import type { DirectoryPermissions } from "./DirectoryPermissions"; +import type { McpServerPermissions } from "./McpServerPermissions"; +import type { ToolPermissions } from "./ToolPermissions"; + +/** + * Complete permission set for a step (as defined in issuetype schema) + */ +export type StepPermissions = { +/** + * Tool-level allow/deny lists + */ +tools: ToolPermissions, +/** + * Directory-level allow/deny lists + */ +directories: DirectoryPermissions, +/** + * MCP server enable/disable configuration + */ +mcp_servers: McpServerPermissions, +/** + * Per-provider custom configuration flags + */ +custom_flags: CustomFlags, }; diff --git a/bindings/StepSchema.ts b/bindings/StepSchema.ts new file mode 100644 index 00000000..1f06fb89 --- /dev/null +++ b/bindings/StepSchema.ts @@ -0,0 +1,123 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ClassifierConfig } from "./ClassifierConfig"; +import type { DelegatorStepConfig } from "./DelegatorStepConfig"; +import type { MatrixedConfig } from "./MatrixedConfig"; +import type { McpStepConfig } from "./McpStepConfig"; +import type { MultiModelConfig } from "./MultiModelConfig"; +import type { MultiPromptConfig } from "./MultiPromptConfig"; +import type { OnReject } from "./OnReject"; +import type { PermissionMode } from "./PermissionMode"; +import type { PipelineConfig } from "./PipelineConfig"; +import type { ProviderCliArgs } from "./ProviderCliArgs"; +import type { RagConfig } from "./RagConfig"; +import type { ReviewType } from "./ReviewType"; +import type { StepOutput } from "./StepOutput"; +import type { StepPermissions } from "./StepPermissions"; +import type { StepTypeTag } from "./StepTypeTag"; +import type { VisualReviewConfig } from "./VisualReviewConfig"; +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * Schema definition for a lifecycle step + */ +export type StepSchema = { +/** + * Step identifier (lowercase) + */ +name: string, +/** + * Human-readable step name + */ +display_name?: string | null, +/** + * Step type discriminator (defaults to "task" for backward compatibility) + */ +type: StepTypeTag, +/** + * Types of outputs this step produces + */ +outputs: Array<StepOutput>, +/** + * Initial prompt template for the Claude agent + */ +prompt: string, +/** + * Type of review required for this step (none, plan, visual, pr) + */ +review_type: ReviewType, +/** + * Configuration for visual review (required when `review_type` is "visual") + */ +visual_config?: VisualReviewConfig | null, +/** + * What to do if step output is rejected + */ +on_reject?: OnReject | null, +/** + * Name of the next step (None for final step) + */ +next_step?: string | null, +/** + * Claude Code tools allowed in this step + */ +allowed_tools: Array<string>, +/** + * Optional agent (delegator) name for this step (overrides ticket's default agent) + */ +agent?: string | null, +/** + * Provider-agnostic permissions for this step + */ +permissions?: StepPermissions | null, +/** + * Arbitrary CLI arguments per provider + */ +cli_args?: ProviderCliArgs | null, +/** + * Preferred LLM permission mode for this step + */ +permission_mode: PermissionMode, +/** + * Inline JSON schema for structured output (Claude-specific) + */ +jsonSchema?: JsonValue | null, +/** + * Path to JSON schema file for structured output (Claude-specific) + */ +jsonSchemaFile?: string | null, +/** + * File glob patterns in the worktree that signal this step is complete + */ +artifact_patterns: Array<string>, +/** + * Configuration for classifier steps (required when type=classifier) + */ +classifier_config?: ClassifierConfig | null, +/** + * Configuration for RAG steps (required when type=rag) + */ +rag_config?: RagConfig | null, +/** + * Configuration for delegator steps (required when type=delegator) + */ +delegator_config?: DelegatorStepConfig | null, +/** + * Configuration for MCP steps (required when type=mcp) + */ +mcp_config?: McpStepConfig | null, +/** + * Configuration for multi-model steps (required when `type=multi_model`) + */ +multi_model_config?: MultiModelConfig | null, +/** + * Configuration for multi-prompt steps (required when `type=multi_prompt`) + */ +multi_prompt_config?: MultiPromptConfig | null, +/** + * Configuration for matrixed steps (required when type=matrixed) + */ +matrixed_config?: MatrixedConfig | null, +/** + * Configuration for pipeline steps (required when type=pipeline) + */ +pipeline_config?: PipelineConfig | null, }; diff --git a/bindings/StepTypeTag.ts b/bindings/StepTypeTag.ts new file mode 100644 index 00000000..a2ebeb16 --- /dev/null +++ b/bindings/StepTypeTag.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Discriminator tag for step types + */ +export type StepTypeTag = "task" | "classifier" | "rag" | "delegator" | "mcp" | "multi_model" | "multi_prompt" | "matrixed" | "pipeline"; diff --git a/bindings/TemplateSchema.ts b/bindings/TemplateSchema.ts new file mode 100644 index 00000000..a2bd683f --- /dev/null +++ b/bindings/TemplateSchema.ts @@ -0,0 +1,57 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ExecutionMode } from "./ExecutionMode"; +import type { FieldSchema } from "./FieldSchema"; +import type { StepSchema } from "./StepSchema"; + +/** + * Schema definition for an issuetype template + */ +export type TemplateSchema = { +/** + * Unique issuetype key (e.g., FEAT, FIX, SPIKE, INV, TASK) + */ +key: string, +/** + * Display name of the template type + */ +name: string, +/** + * Brief description of when to use this template + */ +description: string, +/** + * Whether this issuetype runs autonomously or requires human pairing + */ +mode: ExecutionMode, +/** + * Glyph character displayed in UI for this issuetype + */ +glyph: string, +/** + * Optional color for glyph display in TUI + */ +color?: string | null, +/** + * Whether a project must be specified for this issuetype + */ +project_required: boolean, +/** + * Field definitions for this template + */ +fields: Array<FieldSchema>, +/** + * Lifecycle steps for completing this ticket type + */ +steps: Array<StepSchema>, +/** + * Optional prompt for work launching (interpolated with handlebars) + */ +prompt?: string | null, +/** + * Prompt for generating this issue type's operator agent via `claude -p` + */ +agent_prompt?: string | null, +/** + * Default delegator name for this issuetype (overridden by step.agent) + */ +agent?: string | null, }; diff --git a/bindings/ToolPattern.ts b/bindings/ToolPattern.ts new file mode 100644 index 00000000..17f1ca71 --- /dev/null +++ b/bindings/ToolPattern.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Provider-agnostic tool pattern + */ +export type ToolPattern = { +/** + * Tool name: Read, Write, Edit, Bash, Glob, Grep, `WebFetch`, etc. + */ +tool: string, +/** + * Optional pattern for tool arguments (e.g., "cargo test:*" for Bash) + */ +pattern?: string | null, }; diff --git a/bindings/ToolPermissions.ts b/bindings/ToolPermissions.ts new file mode 100644 index 00000000..da57ed3d --- /dev/null +++ b/bindings/ToolPermissions.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ToolPattern } from "./ToolPattern"; + +/** + * Tool-level permissions (allow/deny lists) + */ +export type ToolPermissions = { +/** + * Tools/patterns to allow + */ +allow: Array<ToolPattern>, +/** + * Tools/patterns to deny + */ +deny: Array<ToolPattern>, }; diff --git a/bindings/VisualReviewConfig.ts b/bindings/VisualReviewConfig.ts new file mode 100644 index 00000000..284d605e --- /dev/null +++ b/bindings/VisualReviewConfig.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. + +/** + * Configuration for visual review steps + */ +export type VisualReviewConfig = { +/** + * URL to open for visual check (supports handlebars templates) + */ +url: string, +/** + * Optional startup command (e.g., dev server) to run before opening browser + */ +startup_command?: string | null, +/** + * Timeout in seconds for server startup (default: 30) + */ +startup_timeout_secs?: number | null, }; diff --git a/bindings/VotingMode.ts b/bindings/VotingMode.ts new file mode 100644 index 00000000..b2e8dea7 --- /dev/null +++ b/bindings/VotingMode.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * How the voting round is executed in multi-model steps + */ +export type VotingMode = "single_judge" | "multi_voter"; diff --git a/bindings/VotingStrategy.ts b/bindings/VotingStrategy.ts new file mode 100644 index 00000000..983d231d --- /dev/null +++ b/bindings/VotingStrategy.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Voting strategy for multi-model steps + */ +export type VotingStrategy = "majority" | "ranked" | "unanimous"; diff --git a/collections/README.md b/collections/README.md index f9ccbeef..8a9f9d4d 100644 --- a/collections/README.md +++ b/collections/README.md @@ -10,6 +10,11 @@ curated embedded set lives in `src/collections/`. ## Contributing a collection +> Working with an AI agent? Point it at +> [`.claude/commands/new-collection.md`](../.claude/commands/new-collection.md) +> (or run `/new-collection`) — it covers the design questions to answer first, +> the full schema, and the validation loop. + 1. Create `collections/community/<id>/` where `<id>` matches `^[a-z0-9_]{3,64}$` (e.g. `gastown_loop`). 2. Add a `collection.json` manifest conforming to @@ -27,11 +32,14 @@ curated embedded set lives in `src/collections/`. 3. Add one `<KEY>.json` per issuetype conforming to [the issuetype schema](https://operator.untra.io/schemas/issuetype.json), plus an optional `<KEY>.md` ticket template. -4. Do **not** set checksums — the docs generator computes them at publish time. -5. Run the CI gate locally before opening a PR: +4. Add an `icon.svg` following the Operator icon standard — a single-path 24×24 + glyph with no `fill`/`stroke`/`width`/`height`, titled with the collection's + display name. Rules and rationale in `docs/design-system/`. +5. Do **not** set checksums — the docs generator computes them at publish time. Run the CI gates locally before opening a PR to ensure the collection is correctly structured: ```bash cargo test --test community_collections + cargo test --test svg_icon_standard ``` See `community/example_chores/` for a minimal working example. diff --git a/collections/community/example_chores/collection.json b/collections/community/example_chores/collection.json index 89b38bca..a90a476d 100644 --- a/collections/community/example_chores/collection.json +++ b/collections/community/example_chores/collection.json @@ -4,13 +4,17 @@ "name": "Example Chores", "description": "Minimal example community collection demonstrating the shareable format.", "version": "0.1.0", - "tier": "community", "author": "untra", "url": "https://github.com/untra/operator", + "license": "MIT", "tags": [ "example", "starter" ], + "tier": "community", + "icon_path": "icon.svg", + "created": "2026-07-01", + "updated": "2026-07-01", "issue_types": [ { "key": "CHORE", @@ -36,6 +40,5 @@ }, "default_selected": [ "CHORE" - ], - "license": "MIT" + ] } diff --git a/collections/community/example_chores/icon.svg b/collections/community/example_chores/icon.svg new file mode 100644 index 00000000..13c68dc8 --- /dev/null +++ b/collections/community/example_chores/icon.svg @@ -0,0 +1 @@ +<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Example Chores diff --git a/docs/_config.yml b/docs/_config.yml index 72ad07ce..70c9733a 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -27,6 +27,7 @@ exclude: - Gemfile.lock - vendor - typescript + - collections # Plugins plugins: diff --git a/docs/_data/navigation.yml b/docs/_data/navigation.yml index 83a63e4e..d1fc08f8 100644 --- a/docs/_data/navigation.yml +++ b/docs/_data/navigation.yml @@ -9,6 +9,8 @@ docs: url: /getting-started/platform-support/ - title: Installation url: /getting-started/installation/ + - title: Tickets + url: /getting-started/tickets/ - title: Supported Session Management url: /getting-started/sessions/ children: @@ -101,23 +103,6 @@ docs: children: - title: AGNT.gg url: /getting-started/integrations/agnt/ - - title: Core - children: - - title: Kanban - url: /kanban/ - codicon: layout - - title: Issue Types - url: /issue-types/ - codicon: issues - - title: Tickets - url: /tickets/ - codicon: note - - title: Agents - url: /agents/ - codicon: robot - children: - - title: Artifact Detection - url: /agents/artifact-detection/ - title: Reference children: - title: CLI @@ -135,6 +120,9 @@ docs: - title: Design System url: /design-system/ codicon: symbol-color + - title: Artifact Detection + url: /artifact-detection/ + codicon: file-binary - title: Taxonomy codicon: type-hierarchy children: diff --git a/docs/_includes/head.html b/docs/_includes/head.html index 80c70368..076c3fc4 100644 --- a/docs/_includes/head.html +++ b/docs/_includes/head.html @@ -23,5 +23,11 @@ + + {% if page.section == 'workflows' %} + + + {% endif %} + {% seo %} diff --git a/docs/_includes/sidebar.html b/docs/_includes/sidebar.html index 90d7b0dc..8263caaa 100644 --- a/docs/_includes/sidebar.html +++ b/docs/_includes/sidebar.html @@ -64,14 +64,15 @@ {% endfor %} +

Currently v{{ site.version }} |
diff --git a/docs/agents/index.md b/docs/agents/index.md deleted file mode 100644 index d95b7eab..00000000 --- a/docs/agents/index.md +++ /dev/null @@ -1,102 +0,0 @@ ---- -title: Agents -description: "Understand agent lifecycle, states, autonomous vs paired modes, parallelism rules, and session tracking." -layout: doc ---- - -Agents are LLM-powered workers that execute tickets. Operator! manages their lifecycle and coordinates their work. - -## Agent Lifecycle - -``` -Created -> Running -> Completed - | - v - Awaiting Input -``` - -### States - -| State | Description | -|-------|-------------| -| **Created** | Agent initialized, not yet started | -| **Running** | Actively working on ticket | -| **Awaiting Input** | Needs human response | -| **Completed** | Work finished successfully | -| **Failed** | Error occurred | - -## Agent Modes - -### Autonomous Mode - -Used for: **FEAT**, **FIX** - -- Launch and monitor -- Minimal intervention -- Can run in parallel - -### Paired Mode - -Used for: **INV**, **SPIKE** - -- Active human participation -- Back-and-forth discussion -- One at a time per operator - -## Parallelism - -Operator! enforces parallelism rules: - -``` -Max agents = min(configured_max, cpu_cores - reserved) -``` - -### Rules - -1. **Different projects** - Autonomous agents can run in parallel -2. **Same project** - Sequential only (avoid conflicts) -3. **Paired agents** - One at a time - -## Tracking - -Operator! tracks agents in real-time: - -```json -{ - "agents": [ - { - "id": "agent-123", - "ticket": "FEAT-042", - "project": "backend", - "status": "running", - "started_at": "2024-01-15T10:30:00Z" - } - ] -} -``` - -## Sessions - -Agent sessions persist in `.operator/sessions/`: - -``` -.operator/ -├── state.json -├── sessions/ -│ ├── agent-123.json -│ └── agent-456.json -└── history.json -``` - -Session files contain: -- Ticket information -- Start/end times -- Status history -- Output logs - -## Best Practices - -1. **Monitor paired agents** - Stay engaged with INV/SPIKE -2. **Review autonomous work** - Check completed FEAT/FIX -3. **Handle failures promptly** - Address failed agents quickly -4. **Balance load** - Don't overload with too many agents diff --git a/docs/agents/artifact-detection.md b/docs/artifact-detection/index.md similarity index 96% rename from docs/agents/artifact-detection.md rename to docs/artifact-detection/index.md index 9909406b..a30ca7c6 100644 --- a/docs/agents/artifact-detection.md +++ b/docs/artifact-detection/index.md @@ -122,4 +122,4 @@ The `artifact_patterns` field is defined in the [Issue Type Schema](/schemas/iss } ``` -See [Issue Types](/issue-types/) for more on configuring steps. +See the [issue type schema](/schemas/issuetype/) for the full step structure, and [Workflows](/workflows/) for the collections that ship these steps. diff --git a/docs/assets/css/main.css b/docs/assets/css/main.css index 0c2b8cae..1af23e51 100644 --- a/docs/assets/css/main.css +++ b/docs/assets/css/main.css @@ -302,6 +302,28 @@ summary.nav-item-row::-webkit-details-marker { color: inherit; } +/* Workflows nav button — the collection catalog. + * A shade apart from both the sage (--color-green-l1) used by every ordinary + * nav link and the salmon Downloads button below it, so the two read as a pair + * of distinct calls to action rather than one repeated. */ +.sidebar-nav .workflows-link { + margin-top: 16px; +} + +.sidebar-nav .workflows-link a { + background: var(--color-green-l2); + color: var(--color-cream); + font-weight: 600; +} + +.sidebar-nav .workflows-link a:hover { + background: var(--color-cornflower); +} + +.sidebar-nav .workflows-link a.active { + background: var(--color-green-l3); +} + /* Downloads nav link - highlighted */ .sidebar-nav .downloads-link { margin-top: 16px; @@ -595,3 +617,164 @@ tr:hover { background-size: 92px 92px; } } + +/* ── Collection catalog (/workflows/) ───────────────────────────────────────── + * Cards and the table are both rendered statically by the collections-pages + * generator; `data-view` on the container decides which one is shown, and + * flips it. Without JavaScript the default view + * still renders in full. */ + +.collection-search { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin: 24px 0 16px; +} + +.collection-search input[type="search"] { + flex: 1 1 18rem; + min-width: 0; + padding: 8px 12px; + font: inherit; + font-size: 0.95rem; + color: var(--color-teal); + background: var(--color-white); + border: 1px solid var(--color-cornflower); + border-radius: 4px; +} + +.collection-search input[type="search"]:focus { + outline: 2px solid var(--color-green-l2); + outline-offset: 1px; +} + +.collection-view-toggle { + padding: 8px 14px; + font: inherit; + font-weight: 600; + color: var(--color-cream); + cursor: pointer; + background: var(--color-green-l1); + border: none; + border-radius: 4px; +} + +.collection-view-toggle:hover { + background: var(--color-green-l2); +} + +.collection-search-count { + font-size: 0.85rem; + color: var(--color-cornflower); +} + +/* One view at a time. Both are in the DOM so filtering keeps them in sync. */ +[data-view="cards"] > .collection-table, +[data-view="table"] > .collection-grid { + display: none; +} + +.collection-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); + gap: 16px; +} + +.collection-card { + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px; + background: var(--color-white); + border: 1px solid var(--color-cornflower); + border-radius: 8px; +} + +.collection-card:hover { + border-color: var(--color-salmon); +} + +.collection-card-link { + display: flex; + gap: 10px; + align-items: center; + text-decoration: none; +} + +/* Inlined SVG, so it tints from the link color in both themes. */ +.collection-icon { + flex: none; + width: 28px; + height: 28px; + color: var(--color-salmon); + fill: currentColor; +} + +.collection-card-title { + margin: 0; + font-size: 1.05rem; + color: var(--color-green-l3); +} + +.collection-card-description, +.collection-card-types, +.collection-card-meta { + margin: 0; + font-size: 0.85rem; +} + +.collection-card-description { + flex: 1; + color: var(--color-teal); +} + +.collection-card-types code { + font-size: 0.75rem; +} + +.collection-card-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + color: var(--color-cornflower); +} + +/* The table is wide by design; let it scroll rather than the page. */ +.collection-table { + display: table; + width: 100%; + overflow-x: auto; +} + +.collection-table .collection-icon { + width: 18px; + height: 18px; + margin-right: 6px; + vertical-align: -3px; +} + +#collection-catalog[data-empty="true"]::after { + display: block; + padding: 24px 0; + color: var(--color-cornflower); + content: "No collections match that filter."; +} + +/* Split view on a collection page: issue-type rail beside the graph. The + * component owns its internal layout; this only bounds it on the page. */ +.workflow-explorer { + margin: 16px 0 24px; +} + +[data-theme="dark"] .collection-card, +[data-theme="dark"] .collection-search input[type="search"] { + background-color: #1a2426; +} + +@media (max-width: 768px) { + .collection-grid { + grid-template-columns: 1fr; + } +} diff --git a/docs/assets/icons/anthropic.svg b/docs/assets/icons/anthropic.svg index c917480d..3408b26e 100644 --- a/docs/assets/icons/anthropic.svg +++ b/docs/assets/icons/anthropic.svg @@ -1 +1 @@ -Anthropic \ No newline at end of file +Anthropic diff --git a/docs/assets/icons/claude-dark.svg b/docs/assets/icons/claude-dark.svg deleted file mode 100644 index 96be6003..00000000 --- a/docs/assets/icons/claude-dark.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - C - diff --git a/docs/assets/icons/claude-light.svg b/docs/assets/icons/claude-light.svg deleted file mode 100644 index 5797ebf5..00000000 --- a/docs/assets/icons/claude-light.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - C - diff --git a/docs/assets/icons/claude.svg b/docs/assets/icons/claude.svg index c33100cb..0f8385a6 100644 --- a/docs/assets/icons/claude.svg +++ b/docs/assets/icons/claude.svg @@ -1,3 +1 @@ - - - +Claude diff --git a/docs/assets/icons/cmux.svg b/docs/assets/icons/cmux.svg index 32f2b8f5..9e005499 100644 --- a/docs/assets/icons/cmux.svg +++ b/docs/assets/icons/cmux.svg @@ -1,3 +1 @@ - - - +cmux diff --git a/docs/assets/icons/coder.svg b/docs/assets/icons/coder.svg index a0acff3a..5356a1cb 100644 --- a/docs/assets/icons/coder.svg +++ b/docs/assets/icons/coder.svg @@ -1 +1 @@ -Coder \ No newline at end of file +Coder diff --git a/docs/assets/icons/codex.svg b/docs/assets/icons/codex.svg index 7997b3d2..99844e1d 100644 --- a/docs/assets/icons/codex.svg +++ b/docs/assets/icons/codex.svg @@ -1,3 +1 @@ - - - +Codex diff --git a/docs/assets/icons/cursor.svg b/docs/assets/icons/cursor.svg index 61303fbc..b9672494 100644 --- a/docs/assets/icons/cursor.svg +++ b/docs/assets/icons/cursor.svg @@ -1 +1 @@ -Cursor \ No newline at end of file +Cursor diff --git a/docs/assets/icons/gemini.svg b/docs/assets/icons/gemini.svg index a801d939..b21e43aa 100644 --- a/docs/assets/icons/gemini.svg +++ b/docs/assets/icons/gemini.svg @@ -1,3 +1 @@ - - - +Gemini diff --git a/docs/assets/icons/github.svg b/docs/assets/icons/github.svg index 8d44456c..23349765 100644 --- a/docs/assets/icons/github.svg +++ b/docs/assets/icons/github.svg @@ -1,3 +1 @@ - - - +GitHub diff --git a/docs/assets/icons/gitlab.svg b/docs/assets/icons/gitlab.svg index d99593fb..ab1286a2 100644 --- a/docs/assets/icons/gitlab.svg +++ b/docs/assets/icons/gitlab.svg @@ -1,3 +1 @@ - - - +GitLab diff --git a/docs/assets/icons/google.svg b/docs/assets/icons/google.svg index 2eaf9155..75543e0c 100644 --- a/docs/assets/icons/google.svg +++ b/docs/assets/icons/google.svg @@ -1 +1 @@ -Google \ No newline at end of file +Google diff --git a/docs/assets/icons/jira.svg b/docs/assets/icons/jira.svg index bb652f5a..8cc91ccf 100644 --- a/docs/assets/icons/jira.svg +++ b/docs/assets/icons/jira.svg @@ -1,3 +1 @@ - - - +Jira diff --git a/docs/assets/icons/linear.svg b/docs/assets/icons/linear.svg index 5c114d7c..ada2e9da 100644 --- a/docs/assets/icons/linear.svg +++ b/docs/assets/icons/linear.svg @@ -1,3 +1 @@ - - - +Linear diff --git a/docs/assets/icons/notification.svg b/docs/assets/icons/notification.svg index 72f41c13..3b0ebdf0 100644 --- a/docs/assets/icons/notification.svg +++ b/docs/assets/icons/notification.svg @@ -1,3 +1 @@ - - - +Notification diff --git a/docs/assets/icons/ollama.svg b/docs/assets/icons/ollama.svg index bc368e99..1c0ab7b6 100644 --- a/docs/assets/icons/ollama.svg +++ b/docs/assets/icons/ollama.svg @@ -1 +1 @@ -Ollama \ No newline at end of file +Ollama diff --git a/docs/assets/icons/openrouter.svg b/docs/assets/icons/openrouter.svg index 83ec807b..1d1e68cb 100644 --- a/docs/assets/icons/openrouter.svg +++ b/docs/assets/icons/openrouter.svg @@ -1 +1 @@ -OpenRouter \ No newline at end of file +OpenRouter diff --git a/docs/assets/icons/tmux.svg b/docs/assets/icons/tmux.svg index f22913b9..921c63bb 100644 --- a/docs/assets/icons/tmux.svg +++ b/docs/assets/icons/tmux.svg @@ -1,3 +1 @@ - - - +tmux diff --git a/docs/assets/icons/vscode.svg b/docs/assets/icons/vscode.svg index cfcccfc6..92f3ae83 100644 --- a/docs/assets/icons/vscode.svg +++ b/docs/assets/icons/vscode.svg @@ -1,3 +1 @@ - - - +Visual Studio Code diff --git a/docs/assets/icons/webhook.svg b/docs/assets/icons/webhook.svg index be5ace01..6bfc9efb 100644 --- a/docs/assets/icons/webhook.svg +++ b/docs/assets/icons/webhook.svg @@ -1,3 +1 @@ - - - +Webhook diff --git a/docs/assets/icons/zed.svg b/docs/assets/icons/zed.svg index 02327fd1..5c3fe595 100644 --- a/docs/assets/icons/zed.svg +++ b/docs/assets/icons/zed.svg @@ -1 +1 @@ -Zed Industries \ No newline at end of file +Zed diff --git a/docs/assets/icons/zellij.svg b/docs/assets/icons/zellij.svg index 0d4abed2..160b3c07 100644 --- a/docs/assets/icons/zellij.svg +++ b/docs/assets/icons/zellij.svg @@ -1,3 +1 @@ - - - +Zellij diff --git a/docs/collections/coder/BUG.json b/docs/collections/coder/BUG.json new file mode 100644 index 00000000..9a1a54a9 --- /dev/null +++ b/docs/collections/coder/BUG.json @@ -0,0 +1,139 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "BUG", + "name": "Bug", + "description": "Defect report synced from the Linear Bug label with reproduce-first workflow", + "mode": "autonomous", + "glyph": "x", + "color": "red", + "project_required": true, + "agent_prompt": "Review this project and prepare to create an agent for fixing bugs. The agent should read tickets from .tickets/, reproduce issues with failing tests, implement minimal fixes, verify with tests, and create PRs. Output ONLY the agent system prompt.", + "prompt": "You are fixing a bug in the {{ project }} project.\n\nThis ticket may have been synced from Linear; the ticket body carries the source description and a link back to the Linear issue.\n\n## Current Behavior\n{{ current_behavior }}\n\n## Expected Behavior\n{{ expected_behavior }}\n\n## Steps to Reproduce\n{{ steps_to_reproduce }}\n\nReproduce first, then fix the root cause with the smallest change that resolves it.", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "priority", + "description": "Ticket priority level", + "type": "enum", + "required": false, + "default": "P2-medium", + "options": ["P0-critical", "P1-high", "P2-medium", "P3-low"], + "display_order": 1 + }, + { + "name": "severity", + "description": "Bug severity", + "type": "enum", + "required": false, + "default": "N/A", + "options": ["S0-outage", "S1-major", "S2-minor", "S3-cosmetic", "N/A"], + "display_order": 2 + }, + { + "name": "branch", + "description": "Git branch name", + "type": "string", + "required": true, + "auto": "branch", + "display_order": 3, + "user_editable": false + }, + { + "name": "summary", + "description": "Name or summary of this bug", + "type": "string", + "required": true, + "default": "", + "placeholder": "Brief description of the defect", + "max_length": 120, + "display_order": 4 + }, + { + "name": "current_behavior", + "description": "What happens today (the defect)", + "type": "text", + "required": false, + "default": "", + "placeholder": "Observed behavior, error messages, log excerpts", + "display_order": 5 + }, + { + "name": "expected_behavior", + "description": "What should happen instead", + "type": "text", + "required": false, + "default": "", + "placeholder": "Correct behavior once fixed", + "display_order": 6 + }, + { + "name": "steps_to_reproduce", + "description": "How to trigger the defect", + "type": "text", + "required": false, + "default": "", + "placeholder": "1. Do X\n2. Do Y\n3. Observe Z", + "display_order": 7 + } + ], + "steps": [ + { + "name": "plan", + "display_name": "Planning", + "outputs": ["plan"], + "prompt": "Assess the project and plan reproduction:\n\n1. Understand the project structure\n2. Find how to run the project and tests\n3. Locate code likely involved in the defect\n\n## Current Behavior\n{{ current_behavior }}\n\n## Expected Behavior\n{{ expected_behavior }}\n\n## Steps to Reproduce\n{{ steps_to_reproduce }}\n\nWrite a plan to `.tickets/plans/{{ id }}.md` with:\n- How to run the project and relevant test commands\n- Steps to reproduce the issue\n- Initial hypotheses about the root cause", + "allowed_tools": ["Read", "Glob", "Grep", "Write", "Bash"], + "review_type": "plan", + "artifact_patterns": [".tickets/plans/{{ id }}.md"], + "on_reject": { + "goto_step": "plan", + "prompt": "Plan rejected: {{ rejection_reason }}\n\nRevise the reproduction plan." + }, + "next_step": "reproduce" + }, + { + "name": "reproduce", + "display_name": "Reproducing", + "outputs": ["test", "report"], + "prompt": "Reproduce the bug:\n\n1. Follow the plan to reproduce the issue\n2. Write a failing test that demonstrates the defect\n3. Document the reproduction in `.tickets/notes/{{ id }}.md`\n4. Confirm the test fails for the expected reason", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], + "next_step": "test" + }, + { + "name": "test", + "display_name": "Baseline Testing", + "outputs": ["report"], + "prompt": "Establish test baseline:\n\n1. Run the full test suite to understand current state\n2. Document which tests pass/fail\n3. Identify any related test coverage gaps\n4. Note any flaky or slow tests", + "allowed_tools": ["Read", "Bash"], + "next_step": "fix" + }, + { + "name": "fix", + "display_name": "Fixing", + "outputs": ["code"], + "prompt": "Implement the fix:\n\n1. Apply a minimal fix addressing the root cause\n2. Verify the previously failing test now passes\n3. Run the project's full test suite\n4. Run the project's linter and formatter\n\nKeep the fix focused and minimal.", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], + "next_step": "deploy" + }, + { + "name": "deploy", + "display_name": "Deploying", + "outputs": ["pr"], + "prompt": "Create a pull request for the fix:\n\n1. Commit all changes with message: `fix({{ project }}): {{ summary }}`\n2. Push the fix branch\n3. Create a PR with:\n - Root cause analysis\n - Description of the fix\n - Test verification\n - Link to ticket: {{ id }}\n\nIf the ticket links a Linear issue, reference it in the PR body so Linear links the PR.\n\nFix complete. Move ticket to completed after PR is merged.", + "allowed_tools": ["Bash", "Read"], + "review_type": "pr", + "on_reject": { + "goto_step": "fix", + "prompt": "Deploy rejected: {{ rejection_reason }}\n\nAddress feedback and revise the fix." + } + } + ] +} diff --git a/docs/collections/coder/BUG.md b/docs/collections/coder/BUG.md new file mode 100644 index 00000000..e054b47d --- /dev/null +++ b/docs/collections/coder/BUG.md @@ -0,0 +1,23 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +branch: {{ branch }} +{{#if priority }}priority: {{ priority }} +{{/if}}--- + +# Bug: {{ summary }} + +{{#if current_behavior }} +## Current Behavior +{{ current_behavior }} +{{/if}} +{{#if expected_behavior }} +## Expected Behavior +{{ expected_behavior }} +{{/if}} +{{#if steps_to_reproduce }} +## Steps to Reproduce +{{ steps_to_reproduce }} +{{/if}} diff --git a/docs/collections/coder/FEATURE.json b/docs/collections/coder/FEATURE.json new file mode 100644 index 00000000..e79eeacd --- /dev/null +++ b/docs/collections/coder/FEATURE.json @@ -0,0 +1,128 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "FEATURE", + "name": "Feature", + "description": "New functionality synced from the Linear Feature label", + "mode": "autonomous", + "glyph": "+", + "color": "green", + "project_required": true, + "agent_prompt": "Review this project and prepare to create an agent for implementing new features. The agent should read tickets from .tickets/, create feature branches, implement code following existing patterns, run tests and linting, and create PRs. Output ONLY the agent system prompt.", + "prompt": "You are implementing a new feature for the {{ project }} project.\n\nThis ticket may have been synced from Linear; the ticket body carries the source description and a link back to the Linear issue.\n\n## User Story\n{{ user_story }}\n\nBefore starting, understand the requirements and how success will be judged.", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "priority", + "description": "Ticket priority level", + "type": "enum", + "required": false, + "default": "P2-medium", + "options": ["P0-critical", "P1-high", "P2-medium", "P3-low"], + "display_order": 1 + }, + { + "name": "branch", + "description": "Git branch name", + "type": "string", + "required": true, + "auto": "branch", + "display_order": 2, + "user_editable": false + }, + { + "name": "summary", + "description": "Name or summary of this feature", + "type": "string", + "required": true, + "default": "", + "placeholder": "Brief description of the feature", + "max_length": 120, + "display_order": 3 + }, + { + "name": "user_story", + "description": "Why is this feature needed? User story format preferred", + "type": "text", + "required": false, + "default": "", + "placeholder": "As a USER, I want to X, so I can Y", + "display_order": 4 + }, + { + "name": "estimate", + "description": "Estimate points (from Linear)", + "type": "integer", + "required": false, + "display_order": 5 + }, + { + "name": "customer", + "description": "Requesting customer (from Linear)", + "type": "string", + "required": false, + "default": "", + "placeholder": "Customer name if applicable", + "display_order": 6 + } + ], + "steps": [ + { + "name": "plan", + "display_name": "Planning", + "outputs": ["plan"], + "prompt": "You are implementing a new feature. First, read the ticket and explore the codebase to understand:\n1. Where the feature should be implemented\n2. What existing code/patterns to follow\n3. What tests need to be added\n\nCreate a detailed implementation plan in `.tickets/plans/{{ id }}.md` with:\n- Files to create/modify\n- Key implementation steps\n- Test coverage requirements\n- Any dependencies or risks", + "allowed_tools": ["Read", "Glob", "Grep", "Write"], + "review_type": "plan", + "artifact_patterns": [".tickets/plans/{{ id }}.md"], + "on_reject": { + "goto_step": "plan", + "prompt": "Plan rejected: {{ rejection_reason }}\n\nRevise the plan based on feedback." + }, + "next_step": "build" + }, + { + "name": "build", + "display_name": "Building", + "outputs": ["code"], + "prompt": "Build the feature structure based on the plan in `.tickets/plans/{{ id }}.md`.\n\nIn this step, focus on:\n- Creating new files and modules\n- Setting up the basic structure\n- Adding type definitions and interfaces\n- Creating stub implementations\n\nDo not implement full logic yet - that comes in the next step.", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], + "next_step": "code" + }, + { + "name": "code", + "display_name": "Coding", + "outputs": ["code"], + "prompt": "Implement the feature logic based on the plan and structure.\n\nGuidelines:\n- Follow existing code patterns and conventions\n- Keep changes minimal and focused\n- Run formatters after making changes", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], + "next_step": "test" + }, + { + "name": "test", + "display_name": "Testing", + "outputs": ["test", "code"], + "prompt": "Add tests for the new feature and ensure all tests pass.\n\n1. Write unit tests for new functions/modules\n2. Add integration tests if applicable\n3. Run the project's full test suite\n4. Run the project's linter and formatter\n\nFix any failures before proceeding.", + "allowed_tools": ["Read", "Write", "Edit", "Bash"], + "next_step": "deploy" + }, + { + "name": "deploy", + "display_name": "Deploying", + "outputs": ["pr"], + "prompt": "Create a pull request for the feature:\n\n1. Commit all changes with a descriptive message\n2. Push the feature branch\n3. Create a PR with:\n - Clear title: `feat({{ project }}): {{ summary }}`\n - Description of changes\n - Link to ticket: {{ id }}\n - Test instructions\n\nIf the ticket links a Linear issue, reference it in the PR body so Linear links the PR.\n\nFeature complete. Move ticket to completed after PR is merged.", + "allowed_tools": ["Bash", "Read"], + "review_type": "pr", + "on_reject": { + "goto_step": "code", + "prompt": "Deploy rejected: {{ rejection_reason }}\n\nAddress feedback and revise the implementation." + } + } + ] +} diff --git a/docs/collections/coder/FEATURE.md b/docs/collections/coder/FEATURE.md new file mode 100644 index 00000000..7c5d680b --- /dev/null +++ b/docs/collections/coder/FEATURE.md @@ -0,0 +1,19 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +branch: {{ branch }} +{{#if priority }}priority: {{ priority }} +{{/if}}--- + +# Feature: {{ summary }} + +{{#if user_story }} +## User Story +{{ user_story }} +{{/if}} +{{#if customer }} +## Customer +{{ customer }} +{{/if}} diff --git a/docs/collections/coder/IMPROVEMENT.json b/docs/collections/coder/IMPROVEMENT.json new file mode 100644 index 00000000..959d5094 --- /dev/null +++ b/docs/collections/coder/IMPROVEMENT.json @@ -0,0 +1,120 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "IMPROVEMENT", + "name": "Improvement", + "description": "Enhancement to existing behavior synced from the Linear Improvement label", + "mode": "autonomous", + "glyph": "^", + "color": "blue", + "project_required": true, + "agent_prompt": "Review this project and prepare to create an agent for improving existing functionality. The agent should read tickets from .tickets/, scope the smallest change that delivers the improvement, implement it following existing patterns, run tests and linting, and create PRs. Output ONLY the agent system prompt.", + "prompt": "You are improving existing functionality in the {{ project }} project.\n\nThis ticket may have been synced from Linear; the ticket body carries the source description and a link back to the Linear issue.\n\n## Motivation\n{{ motivation }}\n\nPrefer the smallest change that delivers the improvement. Do not expand scope.", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "priority", + "description": "Ticket priority level", + "type": "enum", + "required": false, + "default": "P2-medium", + "options": ["P0-critical", "P1-high", "P2-medium", "P3-low"], + "display_order": 1 + }, + { + "name": "branch", + "description": "Git branch name", + "type": "string", + "required": true, + "auto": "branch", + "display_order": 2, + "user_editable": false + }, + { + "name": "summary", + "description": "Name or summary of this improvement", + "type": "string", + "required": true, + "default": "", + "placeholder": "Brief description of the improvement", + "max_length": 120, + "display_order": 3 + }, + { + "name": "motivation", + "description": "What is inadequate today, and what does better look like?", + "type": "text", + "required": false, + "default": "", + "placeholder": "Current shortcoming and the expected improvement", + "display_order": 4 + }, + { + "name": "estimate", + "description": "Estimate points (from Linear)", + "type": "integer", + "required": false, + "display_order": 5 + }, + { + "name": "customer", + "description": "Requesting customer (from Linear)", + "type": "string", + "required": false, + "default": "", + "placeholder": "Customer name if applicable", + "display_order": 6 + } + ], + "steps": [ + { + "name": "plan", + "display_name": "Planning", + "outputs": ["plan"], + "prompt": "You are improving existing functionality. First, read the ticket and locate the current behavior in the codebase:\n1. Find the code that implements today's behavior\n2. Understand why it is inadequate ({{ motivation }})\n3. Identify the smallest change that delivers the improvement\n\nCreate a focused plan in `.tickets/plans/{{ id }}.md` with:\n- The current behavior and where it lives\n- The proposed change, scoped minimally - no scope creep\n- How existing tests must change, and what new coverage is needed\n- Any behavior changes callers/users will notice", + "allowed_tools": ["Read", "Glob", "Grep", "Write"], + "review_type": "plan", + "artifact_patterns": [".tickets/plans/{{ id }}.md"], + "on_reject": { + "goto_step": "plan", + "prompt": "Plan rejected: {{ rejection_reason }}\n\nRevise the plan based on feedback." + }, + "next_step": "code" + }, + { + "name": "code", + "display_name": "Coding", + "outputs": ["code"], + "prompt": "Implement the improvement per the plan in `.tickets/plans/{{ id }}.md`.\n\nGuidelines:\n- Change only what the plan scoped; resist adjacent cleanups\n- Follow existing code patterns and conventions\n- Preserve existing behavior outside the improvement\n- Run formatters after making changes", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], + "next_step": "test" + }, + { + "name": "test", + "display_name": "Testing", + "outputs": ["test", "code"], + "prompt": "Verify the improvement and guard against regressions.\n\n1. Update tests that asserted the old behavior\n2. Add tests demonstrating the improved behavior\n3. Run the project's full test suite\n4. Run the project's linter and formatter\n\nFix any failures before proceeding.", + "allowed_tools": ["Read", "Write", "Edit", "Bash"], + "next_step": "deploy" + }, + { + "name": "deploy", + "display_name": "Deploying", + "outputs": ["pr"], + "prompt": "Create a pull request for the improvement:\n\n1. Commit all changes with a descriptive message\n2. Push the branch\n3. Create a PR with:\n - Clear title: `improve({{ project }}): {{ summary }}`\n - Before/after description of the behavior\n - Link to ticket: {{ id }}\n - Test instructions\n\nIf the ticket links a Linear issue, reference it in the PR body so Linear links the PR.\n\nImprovement complete. Move ticket to completed after PR is merged.", + "allowed_tools": ["Bash", "Read"], + "review_type": "pr", + "on_reject": { + "goto_step": "code", + "prompt": "Deploy rejected: {{ rejection_reason }}\n\nAddress feedback and revise the implementation." + } + } + ] +} diff --git a/docs/collections/coder/IMPROVEMENT.md b/docs/collections/coder/IMPROVEMENT.md new file mode 100644 index 00000000..13b5e7ef --- /dev/null +++ b/docs/collections/coder/IMPROVEMENT.md @@ -0,0 +1,19 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +branch: {{ branch }} +{{#if priority }}priority: {{ priority }} +{{/if}}--- + +# Improvement: {{ summary }} + +{{#if motivation }} +## Motivation +{{ motivation }} +{{/if}} +{{#if customer }} +## Customer +{{ customer }} +{{/if}} diff --git a/docs/collections/coder/collection.json b/docs/collections/coder/collection.json new file mode 100644 index 00000000..fd0c9d51 --- /dev/null +++ b/docs/collections/coder/collection.json @@ -0,0 +1,80 @@ +{ + "schema_version": 1, + "id": "coder", + "name": "Coder", + "description": "Linear-synced engineering flow: Feature, Improvement, and Bug work delegated to coding agents.", + "version": "1.0.0", + "publisher": "untra", + "author": "untra", + "url": "https://github.com/untra/operator", + "license": "MIT", + "tags": [ + "community", + "linear", + "kanban", + "engineering" + ], + "compatibility": null, + "tier": "community", + "icon_path": "icon.svg", + "created": "2026-08-01", + "updated": "2026-08-01", + "kanban_defaults": { + "suggested_type_mappings": { + "Bug": "BUG", + "Feature": "FEATURE", + "Improvement": "IMPROVEMENT" + } + }, + "issue_types": [ + { + "key": "FEATURE", + "schema_path": "FEATURE.json", + "schema_checksum": "1d8146f05bacf6db3c568eebb040c2082a3f64b71e7b6c2eb8d0895357ed99a4", + "template_path": "FEATURE.md", + "template_checksum": "57bd76a8423725e27520a89ed8704f47e7a620a5d6d8c044058f492311027786" + }, + { + "key": "IMPROVEMENT", + "schema_path": "IMPROVEMENT.json", + "schema_checksum": "810c73ac51239e6f7327529b9b48ca0ba0522a0fdeadff76cdc6835d074ee440", + "template_path": "IMPROVEMENT.md", + "template_checksum": "9140c8c2ee7833c0158596a43f8e492cff24f5400631c161b5e8226699a7268a" + }, + { + "key": "BUG", + "schema_path": "BUG.json", + "schema_checksum": "7f5b5d4682893d9f6ba4da2f461893bafa8e93d88eace3f5f068caf32589c37e", + "template_path": "BUG.md", + "template_checksum": "f03d4a2fc843553d42d281c6af9054348f42e0ebf11c0562e37c97a0b7261467" + } + ], + "workflow_hints": { + "loop_kind": "kanban_synced_single_pass", + "memory_surfaces": [ + "ticket", + ".tickets/plans/{{ id }}.md" + ], + "review_gates": [ + "plan_review", + "test_suite", + "pr_review" + ], + "external_tools": [ + "git", + "gh", + "linear" + ], + "stop_conditions": [ + "tests_green", + "pr_created" + ], + "runner_semantics": "prompt_driven" + }, + "default_selected": [ + "FEATURE", + "IMPROVEMENT", + "BUG" + ], + "checksum": "b677f89b72755cc2314622bb8148065527f83b7a31c83859b043f8bbb18bda38" +} diff --git a/docs/collections/coder/icon.svg b/docs/collections/coder/icon.svg new file mode 100644 index 00000000..5356a1cb --- /dev/null +++ b/docs/collections/coder/icon.svg @@ -0,0 +1 @@ +Coder diff --git a/docs/collections/dev_kanban/collection.json b/docs/collections/dev_kanban/collection.json index 5ec89404..1f361b19 100644 --- a/docs/collections/dev_kanban/collection.json +++ b/docs/collections/dev_kanban/collection.json @@ -15,6 +15,9 @@ ], "compatibility": null, "tier": "official", + "icon_path": "icon.svg", + "created": "2026-01-08", + "updated": "2026-06-16", "kanban_defaults": null, "issue_types": [ { @@ -22,24 +25,21 @@ "schema_path": "TASK.json", "schema_checksum": "f654229454da050ca038c273cc74884c2dbe68f09b293eee3764f23d6771b939", "template_path": "TASK.md", - "template_checksum": "7a71bf692588af1d1ac5d9192edd64316353ada616e22019dfff7683ab5d09c4", - "workflow_preview_path": null + "template_checksum": "7a71bf692588af1d1ac5d9192edd64316353ada616e22019dfff7683ab5d09c4" }, { "key": "FEAT", "schema_path": "FEAT.json", "schema_checksum": "42a04e3c8c821e69f334e92b94d57e5aa18e08bc064fe82f53839284b834e520", "template_path": "FEAT.md", - "template_checksum": "fbc7e17641d8e796dad14c09fe20ffcbbdbdb8f58199b0d29d9e9729b51b4d5a", - "workflow_preview_path": null + "template_checksum": "fbc7e17641d8e796dad14c09fe20ffcbbdbdb8f58199b0d29d9e9729b51b4d5a" }, { "key": "FIX", "schema_path": "FIX.json", "schema_checksum": "4deafa08b2dcf1c94462d7079574be05d2efd6ff0abb6036b8fde955dca0e0a1", "template_path": "FIX.md", - "template_checksum": "5237e1faf9b2c49d80fc9efff5a0d3e184f86c4fa5a34ff4dfed26ee883856bf", - "workflow_preview_path": null + "template_checksum": "5237e1faf9b2c49d80fc9efff5a0d3e184f86c4fa5a34ff4dfed26ee883856bf" } ], "workflow_hints": { diff --git a/docs/collections/dev_kanban/icon.svg b/docs/collections/dev_kanban/icon.svg new file mode 100644 index 00000000..13516744 --- /dev/null +++ b/docs/collections/dev_kanban/icon.svg @@ -0,0 +1 @@ +Dev Kanban diff --git a/docs/collections/devops_kanban/collection.json b/docs/collections/devops_kanban/collection.json index 3f9d22c1..3f88b247 100644 --- a/docs/collections/devops_kanban/collection.json +++ b/docs/collections/devops_kanban/collection.json @@ -15,6 +15,9 @@ ], "compatibility": null, "tier": "official", + "icon_path": "icon.svg", + "created": "2026-01-08", + "updated": "2026-06-16", "kanban_defaults": null, "issue_types": [ { @@ -22,40 +25,35 @@ "schema_path": "TASK.json", "schema_checksum": "f654229454da050ca038c273cc74884c2dbe68f09b293eee3764f23d6771b939", "template_path": "TASK.md", - "template_checksum": "7a71bf692588af1d1ac5d9192edd64316353ada616e22019dfff7683ab5d09c4", - "workflow_preview_path": null + "template_checksum": "7a71bf692588af1d1ac5d9192edd64316353ada616e22019dfff7683ab5d09c4" }, { "key": "FEAT", "schema_path": "FEAT.json", "schema_checksum": "2e8405834ef2a8a62966c70357cbe23f5557f446009e8d434d9fae4efc8f626a", "template_path": "FEAT.md", - "template_checksum": "fbc7e17641d8e796dad14c09fe20ffcbbdbdb8f58199b0d29d9e9729b51b4d5a", - "workflow_preview_path": null + "template_checksum": "fbc7e17641d8e796dad14c09fe20ffcbbdbdb8f58199b0d29d9e9729b51b4d5a" }, { "key": "FIX", "schema_path": "FIX.json", "schema_checksum": "4deafa08b2dcf1c94462d7079574be05d2efd6ff0abb6036b8fde955dca0e0a1", "template_path": "FIX.md", - "template_checksum": "5237e1faf9b2c49d80fc9efff5a0d3e184f86c4fa5a34ff4dfed26ee883856bf", - "workflow_preview_path": null + "template_checksum": "5237e1faf9b2c49d80fc9efff5a0d3e184f86c4fa5a34ff4dfed26ee883856bf" }, { "key": "SPIKE", "schema_path": "SPIKE.json", "schema_checksum": "1f69f05190fdff5545371c042c388e276accbbb50179ce65365f17dac042c0b5", "template_path": "SPIKE.md", - "template_checksum": "030d37b1b4b3b8a26db62bd590a61fb5b9e01d5a39be6ab771bd56e31179f2c4", - "workflow_preview_path": null + "template_checksum": "030d37b1b4b3b8a26db62bd590a61fb5b9e01d5a39be6ab771bd56e31179f2c4" }, { "key": "INV", "schema_path": "INV.json", "schema_checksum": "42129e41c6c735adb47061a7d99478a1e4ed9ca5ca9f42b3c300c4a833f9265b", "template_path": "INV.md", - "template_checksum": "65ef45c01d30b93f9d81b830896f20fe4eb9645e82d8e16ea1bd77ac4f359050", - "workflow_preview_path": null + "template_checksum": "65ef45c01d30b93f9d81b830896f20fe4eb9645e82d8e16ea1bd77ac4f359050" } ], "workflow_hints": { diff --git a/docs/collections/devops_kanban/icon.svg b/docs/collections/devops_kanban/icon.svg new file mode 100644 index 00000000..db59a314 --- /dev/null +++ b/docs/collections/devops_kanban/icon.svg @@ -0,0 +1 @@ +DevOps Kanban diff --git a/docs/collections/elves_overnight/collection.json b/docs/collections/elves_overnight/collection.json index fd8c935f..4c6490f7 100644 --- a/docs/collections/elves_overnight/collection.json +++ b/docs/collections/elves_overnight/collection.json @@ -16,6 +16,9 @@ ], "compatibility": null, "tier": "community", + "icon_path": "icon.svg", + "created": "2026-06-16", + "updated": "2026-07-01", "kanban_defaults": null, "issue_types": [ { @@ -23,32 +26,28 @@ "schema_path": "ELVSTAGE.json", "schema_checksum": "35dd4dc5460d4d88e28278df3a8591eb3a38c639e8e548a6fd893aee0c5a3982", "template_path": "ELVSTAGE.md", - "template_checksum": "31a80e4ee58c016dd13076a6042008a32021c5d9b974b29a912eb301d850e1b2", - "workflow_preview_path": null + "template_checksum": "31a80e4ee58c016dd13076a6042008a32021c5d9b974b29a912eb301d850e1b2" }, { "key": "ELVBATCH", "schema_path": "ELVBATCH.json", "schema_checksum": "16c957079f1cc5777f8feff8dc97b79a1b59d08a27df533d23bb610ad3ebaeb3", "template_path": "ELVBATCH.md", - "template_checksum": "e92e6f5a71629a1c9eb39c77799d304d5fc9175f8077f6a7c595bb6e60f7d1da", - "workflow_preview_path": null + "template_checksum": "e92e6f5a71629a1c9eb39c77799d304d5fc9175f8077f6a7c595bb6e60f7d1da" }, { "key": "LANDPR", "schema_path": "LANDPR.json", "schema_checksum": "663cd76ad36b3fddf8516c7d489bc595ab7e8ee4ef36c84d8bd2916de886afe5", "template_path": "LANDPR.md", - "template_checksum": "b0f09ec49c38f5f66d0af77bcc018337bff1df1e814b0ee05aadd38be17a685b", - "workflow_preview_path": null + "template_checksum": "b0f09ec49c38f5f66d0af77bcc018337bff1df1e814b0ee05aadd38be17a685b" }, { "key": "ELVRPT", "schema_path": "ELVRPT.json", "schema_checksum": "491f175c67462671e862420ce3a1b7275e3fcfda4edd91501cc491069bee4569", "template_path": "ELVRPT.md", - "template_checksum": "747897b723b96b5945c14ef46f44af2a7b733517b24ceb569ff8bec1b81a0c20", - "workflow_preview_path": null + "template_checksum": "747897b723b96b5945c14ef46f44af2a7b733517b24ceb569ff8bec1b81a0c20" } ], "workflow_hints": { diff --git a/docs/collections/elves_overnight/icon.svg b/docs/collections/elves_overnight/icon.svg new file mode 100644 index 00000000..0e288457 --- /dev/null +++ b/docs/collections/elves_overnight/icon.svg @@ -0,0 +1 @@ +Elves Overnight diff --git a/docs/collections/example_chores/CHORE.json b/docs/collections/example_chores/CHORE.json new file mode 100644 index 00000000..5db5f457 --- /dev/null +++ b/docs/collections/example_chores/CHORE.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://operator.untra.io/schemas/issuetype.json", + "key": "CHORE", + "name": "Chore", + "description": "Small recurring maintenance task with a verification step", + "mode": "autonomous", + "glyph": "*", + "color": "cyan", + "project_required": true, + "fields": [ + { + "name": "id", + "description": "Ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0 + }, + { + "name": "summary", + "description": "One-line description of the chore", + "type": "string", + "required": false, + "placeholder": "e.g. rotate dependency lockfiles", + "display_order": 1 + } + ], + "steps": [ + { + "name": "execute", + "display_name": "Executing", + "outputs": ["code", "report"], + "prompt": "Complete this maintenance chore:\n\n1. Read the ticket summary and context\n2. Make the smallest change that completes the chore\n3. Note anything that should become a follow-up ticket\n", + "review_type": "none", + "next_step": "verify" + }, + { + "name": "verify", + "display_name": "Verifying", + "outputs": ["report"], + "prompt": "Verify the chore is complete: run the project's test or lint command and summarize the result.\n", + "review_type": "none" + } + ] +} diff --git a/docs/collections/example_chores/CHORE.md b/docs/collections/example_chores/CHORE.md new file mode 100644 index 00000000..ef1ac300 --- /dev/null +++ b/docs/collections/example_chores/CHORE.md @@ -0,0 +1,14 @@ +# Chore: {{ summary }} + +**Project**: {{ project }} +**Created**: {{ date }} + +## Context + +Describe why this chore matters and any constraints. + +## Definition of Done + +- [ ] The change is made and minimal +- [ ] Project checks pass +- [ ] Follow-ups (if any) are filed as new tickets diff --git a/docs/collections/example_chores/collection.json b/docs/collections/example_chores/collection.json new file mode 100644 index 00000000..eb21c176 --- /dev/null +++ b/docs/collections/example_chores/collection.json @@ -0,0 +1,50 @@ +{ + "schema_version": 1, + "id": "example_chores", + "name": "Example Chores", + "description": "Minimal example community collection demonstrating the shareable format.", + "version": "0.1.0", + "publisher": null, + "author": "untra", + "url": "https://github.com/untra/operator", + "license": "MIT", + "tags": [ + "example", + "starter" + ], + "compatibility": null, + "tier": "community", + "icon_path": "icon.svg", + "created": "2026-07-01", + "updated": "2026-07-01", + "kanban_defaults": null, + "issue_types": [ + { + "key": "CHORE", + "schema_path": "CHORE.json", + "schema_checksum": "feeabd2aef3de61dfd7b5760d58f104519cc5f8152718b0dfa1221da8c5852d7", + "template_path": "CHORE.md", + "template_checksum": "7e85067ba98ce97c0dd632704c744d2d7da60248051b1a0db7c18b524a018496" + } + ], + "workflow_hints": { + "loop_kind": "single_pass", + "memory_surfaces": [ + "ticket" + ], + "review_gates": [ + "test_suite" + ], + "external_tools": [ + "git" + ], + "stop_conditions": [ + "tests_green" + ], + "runner_semantics": "prompt_driven" + }, + "default_selected": [ + "CHORE" + ], + "checksum": "f16a9b3bf3e907358a73af2904e755cf42ba8179d8967a8130943e8c5042c157" +} diff --git a/docs/collections/example_chores/icon.svg b/docs/collections/example_chores/icon.svg new file mode 100644 index 00000000..13c68dc8 --- /dev/null +++ b/docs/collections/example_chores/icon.svg @@ -0,0 +1 @@ +Example Chores diff --git a/docs/collections/index.json b/docs/collections/index.json index 802874b4..5e9fa533 100644 --- a/docs/collections/index.json +++ b/docs/collections/index.json @@ -11,9 +11,9 @@ "builtin" ], "manifest_path": "simple/collection.json", - "checksum": "a365a781cfa26378726fe0c381daaf95ba783898c78e315794940c0863e627dd", + "checksum": "355275423b6acfa32690263fe566ae11641b406db2802c48ac95afdf5733ae0e", "tier": "official", - "docs_path": null + "docs_path": "/workflows/simple/" }, { "id": "dev_kanban", @@ -26,9 +26,9 @@ "dev" ], "manifest_path": "dev_kanban/collection.json", - "checksum": "746173e1a6d6fd161a71e9a0d11d8d189bcea650b5ed0934ff45b33dbe6046c0", + "checksum": "62de8ec80b4355bf598df21ab11fabf9c4eb96b0a59b0cb9b188666ab70fa7f0", "tier": "official", - "docs_path": null + "docs_path": "/workflows/dev_kanban/" }, { "id": "devops_kanban", @@ -41,9 +41,9 @@ "devops" ], "manifest_path": "devops_kanban/collection.json", - "checksum": "dd214563b84e5cdac33a5f2fffa91a22cfe334cf5d26693a762ff7e27dc4f030", + "checksum": "a22d7aabb3f038e9fec83a2559b4f2aead1499abc12419678387a2e47124db1a", "tier": "official", - "docs_path": null + "docs_path": "/workflows/devops_kanban/" }, { "id": "operator", @@ -55,9 +55,9 @@ "automation" ], "manifest_path": "operator/collection.json", - "checksum": "4fa68663b0346086879714210440d1f3893ffab29b182683fcb15d3c2789b546", + "checksum": "af7b34cd0103edd1b3d4d47ca26e9721f7374b4b8d3d94d77b23dc8de19bcf71", "tier": "official", - "docs_path": null + "docs_path": "/workflows/operator/" }, { "id": "ralph_loop", @@ -71,9 +71,9 @@ "ralph" ], "manifest_path": "ralph_loop/collection.json", - "checksum": "fd59584f70acc1c5e7aa3b03d36ea31f30316018457e3e866991bd7f70d76e9d", + "checksum": "09276da66290f1faf69bc658a050c2f166e831510ac90800e59654f62aee4da4", "tier": "community", - "docs_path": null + "docs_path": "/workflows/ralph_loop/" }, { "id": "jr_orchestration", @@ -87,9 +87,9 @@ "jr" ], "manifest_path": "jr_orchestration/collection.json", - "checksum": "d2aac0a3215aaaf96b28cb9960f56ef82ffd0c9fa834023ca69385ff29310ea7", + "checksum": "7deebf57f6542592c30cd633577cdb6ebcee1227dab9adcf9cc0980e155eeaea", "tier": "community", - "docs_path": null + "docs_path": "/workflows/jr_orchestration/" }, { "id": "elves_overnight", @@ -103,9 +103,39 @@ "elves" ], "manifest_path": "elves_overnight/collection.json", - "checksum": "0edf6f1503fc13302ab616d1c681ebad53b3f5a8475c548c7c37760a341a475e", + "checksum": "c005292f4779fab887c23bcaf5095cd19ad5aebba221d1ddf41b3895e33143cf", "tier": "community", - "docs_path": null + "docs_path": "/workflows/elves_overnight/" + }, + { + "id": "coder", + "name": "Coder", + "description": "Linear-synced engineering flow: Feature, Improvement, and Bug work delegated to coding agents.", + "version": "1.0.0", + "tags": [ + "community", + "linear", + "kanban", + "engineering" + ], + "manifest_path": "coder/collection.json", + "checksum": "27d0ec62188589d54326a913128217b14431b810e8280923d9bf2c4193207284", + "tier": "community", + "docs_path": "/workflows/coder/" + }, + { + "id": "example_chores", + "name": "Example Chores", + "description": "Minimal example community collection demonstrating the shareable format.", + "version": "0.1.0", + "tags": [ + "example", + "starter" + ], + "manifest_path": "example_chores/collection.json", + "checksum": "648c10ee77d24be1c2589538663ef4ff30173f74b91fcaa4b4b12adaa90eed46", + "tier": "community", + "docs_path": "/workflows/example_chores/" } ] } diff --git a/docs/collections/jr_orchestration/collection.json b/docs/collections/jr_orchestration/collection.json index 670b4076..fbc78e18 100644 --- a/docs/collections/jr_orchestration/collection.json +++ b/docs/collections/jr_orchestration/collection.json @@ -16,6 +16,9 @@ ], "compatibility": null, "tier": "community", + "icon_path": "icon.svg", + "created": "2026-06-16", + "updated": "2026-07-01", "kanban_defaults": null, "issue_types": [ { @@ -23,40 +26,35 @@ "schema_path": "JRPLAN.json", "schema_checksum": "f123c0fbf30095cfc0f42298789656bfccbc2a5bac12ce48a836820110c550ac", "template_path": "JRPLAN.md", - "template_checksum": "d6b07074e56971a6e451cb620fe4a478740d17882140d6ad58da3209874a19f2", - "workflow_preview_path": null + "template_checksum": "d6b07074e56971a6e451cb620fe4a478740d17882140d6ad58da3209874a19f2" }, { "key": "JRFEAT", "schema_path": "JRFEAT.json", "schema_checksum": "8de197203daf3ba5635d4cd0bdee5665372a732f0b8110ad8a92615b36906c20", "template_path": "JRFEAT.md", - "template_checksum": "85da760b93724d4f7eb05c86ca6ae427c58c240a141a797091aa4b4bcf828160", - "workflow_preview_path": null + "template_checksum": "85da760b93724d4f7eb05c86ca6ae427c58c240a141a797091aa4b4bcf828160" }, { "key": "JRTASK", "schema_path": "JRTASK.json", "schema_checksum": "48decac3204ad569f35c2b7603272db35d7084990d46d90483b326e1d718d6b6", "template_path": "JRTASK.md", - "template_checksum": "a3029a01c71f68d505d4c209eaa022ca8f3925797bb41173d5fe78c6d5c8a6fe", - "workflow_preview_path": null + "template_checksum": "a3029a01c71f68d505d4c209eaa022ca8f3925797bb41173d5fe78c6d5c8a6fe" }, { "key": "JRREV", "schema_path": "JRREV.json", "schema_checksum": "e99767e4d217392b321118e3a7e7f016636437361ed94f8453667348ff1e9394", "template_path": "JRREV.md", - "template_checksum": "2ae11b478d2c87d68a729907037d67a56385bf9580748f278e1ce67166b51e14", - "workflow_preview_path": null + "template_checksum": "2ae11b478d2c87d68a729907037d67a56385bf9580748f278e1ce67166b51e14" }, { "key": "JRREBASE", "schema_path": "JRREBASE.json", "schema_checksum": "08772fab7390c7c2bc88f37d2584975c76173aba1c4618493662632fcd2f0dc2", "template_path": "JRREBASE.md", - "template_checksum": "032e592b86ec4a40610e51a073af930e0aff5e34a920e0be9a2128b57c432afa", - "workflow_preview_path": null + "template_checksum": "032e592b86ec4a40610e51a073af930e0aff5e34a920e0be9a2128b57c432afa" } ], "workflow_hints": { diff --git a/docs/collections/jr_orchestration/icon.svg b/docs/collections/jr_orchestration/icon.svg new file mode 100644 index 00000000..2ebdb4bb --- /dev/null +++ b/docs/collections/jr_orchestration/icon.svg @@ -0,0 +1 @@ +JR Orchestration diff --git a/docs/collections/operator/collection.json b/docs/collections/operator/collection.json index ceea2bf5..4dfe0365 100644 --- a/docs/collections/operator/collection.json +++ b/docs/collections/operator/collection.json @@ -14,6 +14,9 @@ ], "compatibility": null, "tier": "official", + "icon_path": "icon.svg", + "created": "2026-01-08", + "updated": "2026-07-01", "kanban_defaults": null, "issue_types": [ { @@ -21,40 +24,35 @@ "schema_path": "ASSESS.json", "schema_checksum": "63d218368c58df22606fbd654920edeb304ebc1bc7282501be38ae14027aa9e1", "template_path": "ASSESS.md", - "template_checksum": "e2fb41d771ccf37af6189cdc0ef9b2752d3e1ddb1fa193f0264fbbdd432e7e0f", - "workflow_preview_path": null + "template_checksum": "e2fb41d771ccf37af6189cdc0ef9b2752d3e1ddb1fa193f0264fbbdd432e7e0f" }, { "key": "SYNC", "schema_path": "SYNC.json", "schema_checksum": "6e52fb05db6e09cd723becb8ffe0388957c639b39fb81efe38d7e9cb8d725e55", "template_path": "SYNC.md", - "template_checksum": "fc5cf44f553720b44ef328c560155fda137102cee28d60505b289d35e45d79cf", - "workflow_preview_path": null + "template_checksum": "fc5cf44f553720b44ef328c560155fda137102cee28d60505b289d35e45d79cf" }, { "key": "INIT", "schema_path": "INIT.json", "schema_checksum": "bd7387742f9d81e49dcbe05cf25d3db0544e2f48843fb855df881d7ab094a0d6", "template_path": "INIT.md", - "template_checksum": "ff54445fb446a8ba8155e00259ebf44860c2e8d1865df6943ceec648fe3650e8", - "workflow_preview_path": null + "template_checksum": "ff54445fb446a8ba8155e00259ebf44860c2e8d1865df6943ceec648fe3650e8" }, { "key": "AGENT_SETUP", "schema_path": "AGENT_SETUP.json", "schema_checksum": "6f934f8a688058d85453cf97c39a79a83f71ddce2a1493b9fcfe441ded485d5e", "template_path": "AGENT_SETUP.md", - "template_checksum": "528f9437434e5ed8f8ca2f8e09c3acb04d021e8fa2fbb978cb6aed5c005aa8f7", - "workflow_preview_path": null + "template_checksum": "528f9437434e5ed8f8ca2f8e09c3acb04d021e8fa2fbb978cb6aed5c005aa8f7" }, { "key": "PROJECT_INIT", "schema_path": "PROJECT_INIT.json", "schema_checksum": "3d942d60f658e477043ae294ed8006e5d7d4964771c47470c409ce4ab114b684", "template_path": "PROJECT_INIT.md", - "template_checksum": "767a9a6481908826809222e87a36c840f759047170b0ff9d917ffa2846a19805", - "workflow_preview_path": null + "template_checksum": "767a9a6481908826809222e87a36c840f759047170b0ff9d917ffa2846a19805" } ], "workflow_hints": { diff --git a/docs/collections/operator/icon.svg b/docs/collections/operator/icon.svg new file mode 100644 index 00000000..7ce0d33f --- /dev/null +++ b/docs/collections/operator/icon.svg @@ -0,0 +1 @@ +Operator diff --git a/docs/collections/ralph_loop/collection.json b/docs/collections/ralph_loop/collection.json index 2afb2a31..f5f05ad4 100644 --- a/docs/collections/ralph_loop/collection.json +++ b/docs/collections/ralph_loop/collection.json @@ -16,6 +16,9 @@ ], "compatibility": null, "tier": "community", + "icon_path": "icon.svg", + "created": "2026-06-16", + "updated": "2026-07-01", "kanban_defaults": null, "issue_types": [ { @@ -23,24 +26,21 @@ "schema_path": "PRD.json", "schema_checksum": "6d70b60b8c3986d4782c5e8c5e453c1b9dc44234cb2bbd054498231945435ce1", "template_path": "PRD.md", - "template_checksum": "c719e1af7c715cdac7d59ec8586d06c612debfbf8c560d5be3ae738499fe9518", - "workflow_preview_path": null + "template_checksum": "c719e1af7c715cdac7d59ec8586d06c612debfbf8c560d5be3ae738499fe9518" }, { "key": "STORY", "schema_path": "STORY.json", "schema_checksum": "7af1d443e7083d08c860a51a6524fec969c99812e6ee51d181058d7b8e92bd0e", "template_path": "STORY.md", - "template_checksum": "340c5fda8830dd79fbe8432dc6921d1556e145d657933b8575f1092013bd9f54", - "workflow_preview_path": null + "template_checksum": "340c5fda8830dd79fbe8432dc6921d1556e145d657933b8575f1092013bd9f54" }, { "key": "RLOOP", "schema_path": "RLOOP.json", "schema_checksum": "edb318f1adf22fb6ce18ebff5b99f3e741ff8aee911e34d4864bd756e038ab8d", "template_path": "RLOOP.md", - "template_checksum": "c29422d0a45c58aa22aee9d010f9ea38d3ecaf47940e53611b082ac180194fb3", - "workflow_preview_path": null + "template_checksum": "c29422d0a45c58aa22aee9d010f9ea38d3ecaf47940e53611b082ac180194fb3" } ], "workflow_hints": { diff --git a/docs/collections/ralph_loop/icon.svg b/docs/collections/ralph_loop/icon.svg new file mode 100644 index 00000000..55e80009 --- /dev/null +++ b/docs/collections/ralph_loop/icon.svg @@ -0,0 +1 @@ +Ralph Loop diff --git a/docs/collections/schema.json b/docs/collections/schema.json index f405b408..c0826f6c 100644 --- a/docs/collections/schema.json +++ b/docs/collections/schema.json @@ -27,6 +27,20 @@ "default": "official", "description": "Provenance tier: who authored and maintains the collection. Orthogonal to distribution (curated community-authored collections may ship embedded)." }, + "icon_path": { + "type": ["string", "null"], + "description": "Bare filename of the collection's SVG icon, next to the manifest. Must satisfy the Simple Icons shape (24x24 viewBox, single path, no fill/stroke so it inherits currentColor). Deliberately not checksummed: presentational, never executed. Required for community-tier collections." + }, + "created": { + "type": ["string", "null"], + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "description": "ISO-8601 date (YYYY-MM-DD) the collection was first published." + }, + "updated": { + "type": ["string", "null"], + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "description": "ISO-8601 date (YYYY-MM-DD) of the last substantive revision." + }, "kanban_defaults": { "type": ["object", "null"], "description": "Descriptive kanban onboarding defaults. v1 is metadata only: suggestions seed the onboarding mapping UI but do not drive sync behavior.", @@ -61,8 +75,7 @@ "schema_path": { "type": "string", "description": "Path to the issuetype JSON, relative to the manifest." }, "schema_checksum": { "type": "string", "description": "SHA-256 (lowercase hex) of the issuetype JSON bytes. Required for hosted manifests; omitted for embedded ones." }, "template_path": { "type": ["string", "null"], "description": "Optional path to the markdown template, relative to the manifest." }, - "template_checksum": { "type": ["string", "null"], "description": "SHA-256 (lowercase hex) of the markdown template bytes, if present." }, - "workflow_preview_path": { "type": ["string", "null"], "description": "Path to a pre-generated workflow preview (.js), relative to the manifest. Filled by the docs producer for visualization; excluded from checksum derivation." } + "template_checksum": { "type": ["string", "null"], "description": "SHA-256 (lowercase hex) of the markdown template bytes, if present." } } } }, diff --git a/docs/collections/search.json b/docs/collections/search.json new file mode 100644 index 00000000..2341a0e2 --- /dev/null +++ b/docs/collections/search.json @@ -0,0 +1,592 @@ +{ + "schema_version": 1, + "collections": [ + { + "id": "simple", + "name": "Simple", + "description": "Simple workflow with TASK only", + "version": "1.0.0", + "tier": "official", + "author": "Operator!", + "publisher": "untra", + "url": "https://github.com/untra/operator", + "license": "MIT", + "tags": [ + "builtin" + ], + "created": "2026-01-08", + "updated": "2026-07-01", + "icon_path": "simple/icon.svg", + "docs_path": "/workflows/simple/", + "manifest_path": "simple/collection.json", + "issue_type_count": 1, + "issue_types": [ + { + "key": "TASK", + "name": "Task", + "mode": "autonomous", + "glyph": ">", + "step_count": 1, + "schema_path": "TASK.json" + } + ], + "loop_kind": "single_pass", + "review_gates": [], + "stop_conditions": [ + "task_complete" + ], + "memory_surfaces": [ + "ticket" + ], + "search_text": "autonomous builtin mit official only operator simple single_pass task untra with workflow" + }, + { + "id": "dev_kanban", + "name": "Dev Kanban", + "description": "Developer kanban with TASK, FEAT, FIX", + "version": "1.0.0", + "tier": "official", + "author": "Operator!", + "publisher": "untra", + "url": "https://github.com/untra/operator", + "license": "MIT", + "tags": [ + "builtin", + "kanban", + "dev" + ], + "created": "2026-01-08", + "updated": "2026-06-16", + "icon_path": "dev_kanban/icon.svg", + "docs_path": "/workflows/dev_kanban/", + "manifest_path": "dev_kanban/collection.json", + "issue_type_count": 3, + "issue_types": [ + { + "key": "TASK", + "name": "Task", + "mode": "autonomous", + "glyph": ">", + "step_count": 1, + "schema_path": "TASK.json" + }, + { + "key": "FEAT", + "name": "Feature", + "mode": "autonomous", + "glyph": "*", + "step_count": 5, + "schema_path": "FEAT.json" + }, + { + "key": "FIX", + "name": "Fix", + "mode": "autonomous", + "glyph": "#", + "step_count": 5, + "schema_path": "FIX.json" + } + ], + "loop_kind": "single_pass", + "review_gates": [ + "test_suite" + ], + "stop_conditions": [ + "tests_green" + ], + "memory_surfaces": [ + "ticket" + ], + "search_text": "autonomous builtin dev dev_kanban developer feat feature fix kanban mit official operator single_pass task test_suite untra with" + }, + { + "id": "devops_kanban", + "name": "DevOps Kanban", + "description": "DevOps kanban with TASK, FEAT, FIX, SPIKE, INV", + "version": "1.0.0", + "tier": "official", + "author": "Operator!", + "publisher": "untra", + "url": "https://github.com/untra/operator", + "license": "MIT", + "tags": [ + "builtin", + "kanban", + "devops" + ], + "created": "2026-01-08", + "updated": "2026-06-16", + "icon_path": "devops_kanban/icon.svg", + "docs_path": "/workflows/devops_kanban/", + "manifest_path": "devops_kanban/collection.json", + "issue_type_count": 5, + "issue_types": [ + { + "key": "TASK", + "name": "Task", + "mode": "autonomous", + "glyph": ">", + "step_count": 1, + "schema_path": "TASK.json" + }, + { + "key": "FEAT", + "name": "Feature", + "mode": "autonomous", + "glyph": "*", + "step_count": 5, + "schema_path": "FEAT.json" + }, + { + "key": "FIX", + "name": "Fix", + "mode": "autonomous", + "glyph": "#", + "step_count": 5, + "schema_path": "FIX.json" + }, + { + "key": "SPIKE", + "name": "Spike", + "mode": "paired", + "glyph": "?", + "step_count": 3, + "schema_path": "SPIKE.json" + }, + { + "key": "INV", + "name": "Investigation", + "mode": "paired", + "glyph": "!", + "step_count": 5, + "schema_path": "INV.json" + } + ], + "loop_kind": "review_loop", + "review_gates": [ + "human", + "test_suite" + ], + "stop_conditions": [ + "tests_green", + "review_approved" + ], + "memory_surfaces": [ + "ticket", + "scratchpad" + ], + "search_text": "autonomous builtin devops devops_kanban feat feature fix human inv investigation kanban mit official operator paired review_loop spike task test_suite untra with" + }, + { + "id": "operator", + "name": "Operator", + "description": "Operator automation tasks: ASSESS, SYNC, INIT", + "version": "1.0.0", + "tier": "official", + "author": "Operator!", + "publisher": "untra", + "url": "https://github.com/untra/operator", + "license": "MIT", + "tags": [ + "builtin", + "automation" + ], + "created": "2026-01-08", + "updated": "2026-07-01", + "icon_path": "operator/icon.svg", + "docs_path": "/workflows/operator/", + "manifest_path": "operator/collection.json", + "issue_type_count": 5, + "issue_types": [ + { + "key": "ASSESS", + "name": "Project Assessment", + "mode": "autonomous", + "glyph": "~", + "step_count": 2, + "schema_path": "ASSESS.json" + }, + { + "key": "SYNC", + "name": "Catalog Sync", + "mode": "autonomous", + "glyph": "@", + "step_count": 3, + "schema_path": "SYNC.json" + }, + { + "key": "INIT", + "name": "Workspace Init", + "mode": "paired", + "glyph": "%", + "step_count": 3, + "schema_path": "INIT.json" + }, + { + "key": "AGENT_SETUP", + "name": "Agent Setup", + "mode": "paired", + "glyph": "@", + "step_count": 3, + "schema_path": "AGENT_SETUP.json" + }, + { + "key": "PROJECT_INIT", + "name": "Project Initialization", + "mode": "autonomous", + "glyph": "*", + "step_count": 2, + "schema_path": "PROJECT_INIT.json" + } + ], + "loop_kind": "single_pass", + "review_gates": [ + "human" + ], + "stop_conditions": [ + "setup_artifacts_written" + ], + "memory_surfaces": [ + "ticket", + "catalog-info.yaml", + ".claude/agents/" + ], + "search_text": "agent agent_setup assess assessment automation autonomous builtin catalog human init initialization mit official operator paired project project_init setup single_pass sync tasks untra workspace" + }, + { + "id": "ralph_loop", + "name": "Ralph Loop", + "description": "PRD-to-story loop for completing one right-sized story per fresh agent context.", + "version": "1.0.0", + "tier": "community", + "author": "snarktank", + "publisher": "untra", + "url": "https://github.com/snarktank/ralph", + "license": "MIT", + "tags": [ + "agentic-loop", + "prd", + "stories", + "ralph" + ], + "created": "2026-06-16", + "updated": "2026-07-01", + "icon_path": "ralph_loop/icon.svg", + "docs_path": "/workflows/ralph_loop/", + "manifest_path": "ralph_loop/collection.json", + "issue_type_count": 3, + "issue_types": [ + { + "key": "PRD", + "name": "Product Requirements Document", + "mode": "paired", + "glyph": "P", + "step_count": 4, + "schema_path": "PRD.json" + }, + { + "key": "STORY", + "name": "Ralph Story", + "mode": "autonomous", + "glyph": "S", + "step_count": 5, + "schema_path": "STORY.json" + }, + { + "key": "RLOOP", + "name": "Ralph Loop Coordinator", + "mode": "paired", + "glyph": "R", + "step_count": 4, + "schema_path": "RLOOP.json" + } + ], + "loop_kind": "fresh_context_story_loop", + "review_gates": [ + "plan_review", + "test_suite", + "story_completion_check" + ], + "stop_conditions": [ + "all stories have passes=true", + "blocked story documented", + "quality gates fail repeatedly", + "max_iterations reached (advisory; outer story loop is operator-queue-driven)" + ], + "memory_surfaces": [ + ".tickets/workflows/{{ id }}/prd.json", + ".tickets/workflows/{{ id }}/progress.txt", + "AGENTS.md" + ], + "search_text": "agent agentic-loop autonomous community completing context coordinator document for fresh fresh_context_story_loop loop mit one paired per plan_review prd prd-to-story product ralph ralph_loop requirements right-sized rloop snarktank stories story story_completion_check test_suite untra" + }, + { + "id": "jr_orchestration", + "name": "JR Orchestration", + "description": "Feature/task orchestration with coder, reviewer, architect, and rebase work units.", + "version": "1.0.0", + "tier": "community", + "author": "snapwich", + "publisher": "untra", + "url": "https://github.com/snapwich/jr", + "license": "MIT", + "tags": [ + "agentic-loop", + "feature-graph", + "review", + "jr" + ], + "created": "2026-06-16", + "updated": "2026-07-01", + "icon_path": "jr_orchestration/icon.svg", + "docs_path": "/workflows/jr_orchestration/", + "manifest_path": "jr_orchestration/collection.json", + "issue_type_count": 5, + "issue_types": [ + { + "key": "JRPLAN", + "name": "JR Plan", + "mode": "paired", + "glyph": "J", + "step_count": 4, + "schema_path": "JRPLAN.json" + }, + { + "key": "JRFEAT", + "name": "JR Feature", + "mode": "paired", + "glyph": "F", + "step_count": 4, + "schema_path": "JRFEAT.json" + }, + { + "key": "JRTASK", + "name": "JR Task", + "mode": "autonomous", + "glyph": "T", + "step_count": 5, + "schema_path": "JRTASK.json" + }, + { + "key": "JRREV", + "name": "JR Review", + "mode": "paired", + "glyph": "V", + "step_count": 4, + "schema_path": "JRREV.json" + }, + { + "key": "JRREBASE", + "name": "JR Rebase", + "mode": "autonomous", + "glyph": "B", + "step_count": 4, + "schema_path": "JRREBASE.json" + } + ], + "loop_kind": "feature_task_review_graph", + "review_gates": [ + "code_review", + "architect_review", + "human_pr_review" + ], + "stop_conditions": [ + "feature PR ready for human review", + "review changes requested", + "blocked dependency documented", + "review escalated to human after repeated changes (operator stops; no auto-handoff)" + ], + "memory_surfaces": [ + ".tickets/jr/{{ id }}/plan.md", + ".tickets/jr/{{ feature_id }}/handoff.md", + "ticket parent/dependency notes" + ], + "search_text": "agentic-loop and architect architect_review autonomous code_review coder community feature feature-graph feature/task feature_task_review_graph human_pr_review jr jr_orchestration jrfeat jrplan jrrebase jrrev jrtask mit orchestration paired plan rebase review reviewer snapwich task units untra with work" + }, + { + "id": "elves_overnight", + "name": "Elves Overnight", + "description": "Long-running staged batch workflow with durable memory, validation, PR review, and reporting.", + "version": "1.0.0", + "tier": "community", + "author": "Aigora", + "publisher": "untra", + "url": "https://github.com/aigorahub/elves", + "license": "MIT", + "tags": [ + "agentic-loop", + "overnight", + "batch", + "elves" + ], + "created": "2026-06-16", + "updated": "2026-07-01", + "icon_path": "elves_overnight/icon.svg", + "docs_path": "/workflows/elves_overnight/", + "manifest_path": "elves_overnight/collection.json", + "issue_type_count": 4, + "issue_types": [ + { + "key": "ELVSTAGE", + "name": "Elves Stage", + "mode": "paired", + "glyph": "E", + "step_count": 4, + "schema_path": "ELVSTAGE.json" + }, + { + "key": "ELVBATCH", + "name": "Elves Batch", + "mode": "autonomous", + "glyph": "B", + "step_count": 9, + "schema_path": "ELVBATCH.json" + }, + { + "key": "LANDPR", + "name": "Land Pull Request", + "mode": "paired", + "glyph": "L", + "step_count": 6, + "schema_path": "LANDPR.json" + }, + { + "key": "ELVRPT", + "name": "Elves Report", + "mode": "paired", + "glyph": "R", + "step_count": 3, + "schema_path": "ELVRPT.json" + } + ], + "loop_kind": "staged_long_running_batch_loop", + "review_gates": [ + "stage_review", + "batch_validation", + "fresh_review", + "judge_verdict", + "human_land_gate" + ], + "stop_conditions": [ + "batch complete and checkpointed", + "validation cannot be repaired safely", + "PR has unresolved requested changes", + "time/risk budget exhausted" + ], + "memory_surfaces": [ + "docs/elves/survival-guide.md", + "docs/elves/execution-log.md", + "docs/elves/learnings.md", + ".elves-session.json", + "PR comments and checks" + ], + "search_text": "agentic-loop aigora and autonomous batch batch_validation community durable elvbatch elves elves_overnight elvrpt elvstage fresh_review human_land_gate judge_verdict land landpr long-running memory mit overnight paired pr pull report reporting request review stage stage_review staged staged_long_running_batch_loop untra validation with workflow" + }, + { + "id": "coder", + "name": "Coder", + "description": "Linear-synced engineering flow: Feature, Improvement, and Bug work delegated to coding agents.", + "version": "1.0.0", + "tier": "community", + "author": "untra", + "publisher": "untra", + "url": "https://github.com/untra/operator", + "license": "MIT", + "tags": [ + "community", + "linear", + "kanban", + "engineering" + ], + "created": "2026-08-01", + "updated": "2026-08-01", + "icon_path": "coder/icon.svg", + "docs_path": "/workflows/coder/", + "manifest_path": "coder/collection.json", + "issue_type_count": 3, + "issue_types": [ + { + "key": "FEATURE", + "name": "Feature", + "mode": "autonomous", + "glyph": "+", + "step_count": 5, + "schema_path": "FEATURE.json" + }, + { + "key": "IMPROVEMENT", + "name": "Improvement", + "mode": "autonomous", + "glyph": "^", + "step_count": 4, + "schema_path": "IMPROVEMENT.json" + }, + { + "key": "BUG", + "name": "Bug", + "mode": "autonomous", + "glyph": "x", + "step_count": 5, + "schema_path": "BUG.json" + } + ], + "loop_kind": "kanban_synced_single_pass", + "review_gates": [ + "plan_review", + "test_suite", + "pr_review" + ], + "stop_conditions": [ + "tests_green", + "pr_created" + ], + "memory_surfaces": [ + "ticket", + ".tickets/plans/{{ id }}.md" + ], + "search_text": "agents and autonomous bug coder coding community delegated engineering feature flow improvement kanban kanban_synced_single_pass linear linear-synced mit plan_review pr_review test_suite to untra work" + }, + { + "id": "example_chores", + "name": "Example Chores", + "description": "Minimal example community collection demonstrating the shareable format.", + "version": "0.1.0", + "tier": "community", + "author": "untra", + "publisher": null, + "url": "https://github.com/untra/operator", + "license": "MIT", + "tags": [ + "example", + "starter" + ], + "created": "2026-07-01", + "updated": "2026-07-01", + "icon_path": "example_chores/icon.svg", + "docs_path": "/workflows/example_chores/", + "manifest_path": "example_chores/collection.json", + "issue_type_count": 1, + "issue_types": [ + { + "key": "CHORE", + "name": "Chore", + "mode": "autonomous", + "glyph": "*", + "step_count": 2, + "schema_path": "CHORE.json" + } + ], + "loop_kind": "single_pass", + "review_gates": [ + "test_suite" + ], + "stop_conditions": [ + "tests_green" + ], + "memory_surfaces": [ + "ticket" + ], + "search_text": "autonomous chore chores collection community demonstrating example example_chores format minimal mit shareable single_pass starter test_suite the untra" + } + ] +} diff --git a/docs/collections/simple/collection.json b/docs/collections/simple/collection.json index 101cf86f..b5d80e68 100644 --- a/docs/collections/simple/collection.json +++ b/docs/collections/simple/collection.json @@ -13,6 +13,9 @@ ], "compatibility": null, "tier": "official", + "icon_path": "icon.svg", + "created": "2026-01-08", + "updated": "2026-07-01", "kanban_defaults": null, "issue_types": [ { @@ -20,8 +23,7 @@ "schema_path": "TASK.json", "schema_checksum": "f654229454da050ca038c273cc74884c2dbe68f09b293eee3764f23d6771b939", "template_path": "TASK.md", - "template_checksum": "7a71bf692588af1d1ac5d9192edd64316353ada616e22019dfff7683ab5d09c4", - "workflow_preview_path": null + "template_checksum": "7a71bf692588af1d1ac5d9192edd64316353ada616e22019dfff7683ab5d09c4" } ], "workflow_hints": { diff --git a/docs/collections/simple/icon.svg b/docs/collections/simple/icon.svg new file mode 100644 index 00000000..35bac239 --- /dev/null +++ b/docs/collections/simple/icon.svg @@ -0,0 +1 @@ +Simple diff --git a/docs/design-system/index.md b/docs/design-system/index.md index 375812de..d56be0d7 100644 --- a/docs/design-system/index.md +++ b/docs/design-system/index.md @@ -118,6 +118,46 @@ was resolved as kanban→`layout`, projects→`project`. > the font/CSS **code** is MIT, © Microsoft. The webfont is vendored under > `docs/assets/` and bundled into the SPA via `@vscode/codicons`. +## Brand & collection icons (SVG) + +Codicons cover concepts. Everything else — provider logos, collection glyphs — is a hand-shipped SVG, and every one of them follows the **Operator icon standard**: a single monochrome `` on a 24×24 canvas carrying no color or +size of its own. + +That shape is what makes one file work on all four surfaces at once. The docs +site inlines collection icons directly into generated HTML, the SPA loads them +through ``, and both themes recolor them from context. + +| Rule | Why | +|------|-----| +| `viewBox="0 0 24 24"` | One coordinate space, so icons are interchangeable and align optically when mixed | +| `role="img"` + exactly one `` | The glyph is content, not decoration; assistive tech needs a name | +| Exactly one `<path>` | A single shape can be recolored, masked, or inlined as a unit | +| No other elements | `<text>` depends on per-surface fonts; `<image>`/`<use>` pull in external documents; `<style>`/`<script>` do not survive inlining | +| No `fill` / `stroke` / `style` | Color comes from `currentColor`, so the icon follows the theme. A hardcoded fill is invisible in light or dark | +| No `width` / `height` | Size is the container's decision | +| No `on*` handlers or external refs | These files are inlined verbatim into generated pages | + +```svg +<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Ralph Loop +``` + +**Where icons live.** `icons/` is the canonical set; `docs/assets/icons/` and +`ui/public/icons/` are the per-surface copies; each collection ships its own +`icon.svg` beside its `collection.json`, titled with the collection's display +name. + +**Adding one.** Copy the shape from [Simple Icons](https://simpleicons.org) +where one exists, or draw a single path on a 24×24 grid. Then: + +```bash +cargo test --test svg_icon_standard +``` + +That test governs every directory above and catches a missing title, a stray +`fill`, a second ``, a wrong viewBox, or embedded script. The only +exemption is `docs/assets/img/operator_logo.svg` — a full-color wordmark, not a +glyph — and the test also asserts that exemption is still needed. + ## Issue type glyphs & colors Issue type color + glyph are defined once in the collection JSON schemas and diff --git a/docs/getting-started/agents/index.md b/docs/getting-started/agents/index.md index d78283ec..26bec423 100644 --- a/docs/getting-started/agents/index.md +++ b/docs/getting-started/agents/index.md @@ -29,3 +29,59 @@ Each agent can: ## Choosing an Agent **Claude** is recommended for most users due to its strong code understanding and generation capabilities. See individual agent pages for setup instructions and specific features. + +## Agent Lifecycle + +Operator tracks every agent it launches through these states: + +``` +Created -> Running -> Completed + | + v + Awaiting Input +``` + +| State | Description | +|-------|-------------| +| **Created** | Agent initialized, not yet started | +| **Running** | Actively working on a ticket | +| **Awaiting Input** | Needs a human response | +| **Completed** | Work finished successfully | +| **Failed** | An error occurred | + +## Autonomous and Paired Modes + +Every issue type declares a `mode`, and that decides how much of your attention +its tickets need: + +- **Autonomous** — launch and monitor. Minimal intervention, and several can run + in parallel across different projects. +- **Paired** — active human participation, with back-and-forth discussion. One at + a time, because they compete for the same operator: you. + +Mode is a property of the issue type, not of the agent, so a collection decides +which of its work types are hands-off. See [Workflows](/workflows/). + +## Sessions + +Agent sessions persist under `.operator/`: + +``` +.operator/ +├── state.json +├── sessions/ +│ ├── agent-123.json +│ └── agent-456.json +└── history.json +``` + +Session files record ticket information, start and end times, status history, and +output logs. Operator can also detect completion from files an agent produces — +see [Artifact Detection](/artifact-detection/). + +## Best Practices + +1. **Monitor paired agents** — stay engaged with paired work +2. **Review autonomous work** — check completed tickets +3. **Handle failures promptly** — address failed agents quickly +4. **Balance load** — don't overload with too many agents diff --git a/docs/getting-started/kanban/index.md b/docs/getting-started/kanban/index.md index c77a475d..88464f63 100644 --- a/docs/getting-started/kanban/index.md +++ b/docs/getting-started/kanban/index.md @@ -24,6 +24,44 @@ Operator syncs tickets from your kanban provider: 3. **Assign**: Dispatches tickets to available agents 4. **Update**: Pushes status changes back to your provider +## The Ticket Lifecycle + +Whether tickets come from a provider or from `.tickets/`, Operator moves them +through the same three directories: + +``` +.tickets/queue/ -> Work waiting to be picked up +.tickets/in-progress/ -> Currently being worked on +.tickets/completed/ -> Finished work +``` + +**Queue.** New tickets land in `.tickets/queue/` and are ordered by their issue +type's position in the active collection, then FIFO by timestamp within the same +type. The ordering is a property of the collection, not a hard-coded table — see +[Workflows](/workflows/). + +**Assignment.** When an agent slot frees up, Operator selects the next ticket, +prompts for launch confirmation, and moves it to `in-progress/`. + +**In progress.** Agent status is tracked, progress notifications are sent, and +Operator watches for completion or for the agent awaiting input. + +**Completion.** The ticket moves to `completed/`, a notification is sent, and the +slot is freed for the next ticket. + +## Parallelism Rules + +Operator bounds concurrent work so agents do not collide: + +- **Max agents** = min(configured_max, cpu_cores - reserved_cores) +- **Autonomous agents** can run in parallel across different projects +- **Paired agents** run one at a time — they need your attention +- **Same project** is sequential, to avoid conflicting edits + +Whether an issue type is autonomous or paired is declared by its `mode`. See +[Supported Coding Agents](/getting-started/agents/) for what each mode means in +practice. + ## Column Mapping (todo / doing / done) Operator is strict about its three internal states — **todo**, **doing**, @@ -53,4 +91,4 @@ Both Jira Cloud and Linear are fully supported kanban providers: ## Local Tickets -Operator also supports local-only tickets in `.tickets/queue/` for projects without external issue tracking. See [Tickets](/tickets/) for details. +Operator also supports local-only tickets in `.tickets/queue/` for projects without external issue tracking. See [Tickets](/getting-started/tickets/) for details. diff --git a/docs/tickets/index.md b/docs/getting-started/tickets/index.md similarity index 66% rename from docs/tickets/index.md rename to docs/getting-started/tickets/index.md index 27d85cb7..43d9424d 100644 --- a/docs/tickets/index.md +++ b/docs/getting-started/tickets/index.md @@ -1,10 +1,12 @@ --- -title: Tickets +title: "Tickets" description: "Create and manage tickets with markdown format, naming conventions, and best practices for LLM agents." layout: doc --- -Tickets are the core unit of work in Operator!. They describe tasks for LLM agents to complete. +# Tickets + +Tickets are the unit of work in Operator!. Each one describes a task for an agent to complete, and carries an **issue type** that decides *how* the work is done — see [Workflows](/workflows/) for the process behind the ticket. ## Ticket Format @@ -14,6 +16,8 @@ Tickets are markdown files with a specific naming convention: {TYPE}-{ID}-{project}-{description}.md ``` +`{TYPE}` is the issue type key (`FEAT`, `FIX`, `PRD`, …), which is why keys never contain hyphens — the hyphen separates the key from the ticket number. + ### Examples ``` @@ -48,6 +52,9 @@ Implement a dark mode toggle in the application settings. See design mockup in Figma: [link] ``` +The exact fields a ticket carries are defined by its issue type. See the +[ticket metadata schema](/schemas/metadata/) for the YAML frontmatter format. + ## Creating Tickets ### Manual Creation @@ -56,7 +63,7 @@ See design mockup in Figma: [link] 2. Follow the naming convention 3. Add ticket content -### Using Operator! CLI +### Using the CLI ```bash # Show current queue @@ -66,6 +73,11 @@ cargo run -- queue cargo run -- launch ``` +### From a Kanban Provider + +Tickets can also be synced from Jira, Linear, or GitHub Projects rather than +authored by hand. See [Supported Kanban Providers](/getting-started/kanban/). + ## Ticket Directories ``` @@ -78,7 +90,7 @@ cargo run -- launch ## Ticket Lifecycle 1. **Created** - Ticket added to `queue/` -2. **Assigned** - Moved to `in-progress/` when agent starts +2. **Assigned** - Moved to `in-progress/` when an agent starts 3. **Completed** - Moved to `completed/` when done ## Best Practices diff --git a/docs/getting-started/workflows/index.md b/docs/getting-started/workflows/index.md index cc997db2..187115f0 100644 --- a/docs/getting-started/workflows/index.md +++ b/docs/getting-started/workflows/index.md @@ -1,16 +1,20 @@ --- -title: "Workflow Formats" -description: "Render an Operator ticket + issue type into a workflow another LLM tool or model can run." +title: "Workflow Export Formats" +description: "Export an Operator workflow into a format another LLM tool or model can run." layout: doc --- -# Workflow Formats +# Workflow Export Formats Operator is a kanban-shaped orchestrator: each **ticket** carries the work, and -its **issue type** carries the *shape* of the work — an ordered set of steps -(tasks, classifiers, delegators, fan-outs, pipelines, human review gates). A -**workflow export** renders that `ticket + issue type` pair into a concrete -orchestration format another tool or model can execute. +its **issue type** carries an **Operator workflow** — an ordered graph of steps +(tasks, classifiers, delegators, fan-outs, pipelines, human review gates). That +JSON-defined workflow is the *native* format, and it is what +[collections](/workflows/) share. + +A **workflow export** renders a `ticket + issue type` pair into a concrete +orchestration format some *other* tool or model can execute. Exports are +derived from the native workflow — never the other way round. This is **export-only and lossy-by-design**: Operator emits the format; it does not parse one back. Shapes a target can't represent natively (human review diff --git a/docs/index.md b/docs/index.md index 2fde473e..a1378e06 100644 --- a/docs/index.md +++ b/docs/index.md @@ -29,11 +29,12 @@ Welcome friend! Operator! is an application ## Quick Links -- [Kanban](/kanban/) - Understand the kanban workflow +- [Workflows](/workflows/) - Browse shareable collections of AI composed workflows +- [Supported Kanban Providers](/getting-started/kanban/) - Sync work from Jira, Linear, or GitHub Projects +- [Tickets](/getting-started/tickets/) - Create and manage work tickets +- [Supported Coding Agents](/getting-started/agents/) - Agent lifecycle and modes - [LLM Tools](/llm-tools/) - Configure LLM integration -- [Tickets](/tickets/) - Create and manage work tickets -- [Agents](/agents/) - Agent lifecycle and modes -- [Tmux](/tmux/) - Terminal session management +- [Session Management](/getting-started/sessions/) - tmux, cmux, Zellij, and editors ## Similar diff --git a/docs/issue-types/index.md b/docs/issue-types/index.md deleted file mode 100644 index fbb4a6ec..00000000 --- a/docs/issue-types/index.md +++ /dev/null @@ -1,282 +0,0 @@ ---- -title: Issue Types -description: "Learn about INV, FIX, FEAT, SPIKE, and TASK issue types, custom definitions, and Jira imports." -layout: doc ---- - -Operator! supports five built-in issue types, organized into collections for different workflows. You can also define custom issue types and import from external kanban systems. - -## Built-in Issue Types - -### INV - Investigation - -**Priority:** 1 (highest) -**Mode:** Paired - -Investigation tickets are for diagnosing failures, understanding bugs, or exploring issues. They require human interaction and are worked on with operator pairing. - -``` -INV-001-project-investigate-login-failure.md -``` - -### FIX - Bug Fix - -**Priority:** 2 -**Mode:** Autonomous - -Bug fixes are addressed after investigations. Agents can work autonomously once the problem is understood. - -``` -FIX-042-project-fix-null-pointer-exception.md -``` - -### FEAT - Feature - -**Priority:** 3 -**Mode:** Autonomous - -New features are implemented after critical bugs are addressed. Agents can work autonomously with clear requirements. - -``` -FEAT-123-project-add-dark-mode.md -``` - -### SPIKE - Research - -**Priority:** 4 -**Mode:** Paired - -Spikes are for research, exploration, and proof-of-concept work. They require human interaction and discussion. - -``` -SPIKE-007-project-evaluate-new-framework.md -``` - -### TASK - General Task - -**Priority:** 5 (lowest) -**Mode:** Autonomous - -Tasks are general work items that don't fit other categories. Used in simple workflows or as a catch-all. - -``` -TASK-099-project-update-dependencies.md -``` - -## Collections - -Collections are named groupings of issue types that define which types are available and their priority order. This allows teams to customize their workflow. - -### Built-in Presets - -| Preset | Issue Types | Priority Order | -|--------|-------------|----------------| -| `simple` | TASK | TASK | -| `dev_kanban` | TASK, FEAT, FIX | FIX, FEAT, TASK | -| `devops_kanban` | TASK, SPIKE, INV, FEAT, FIX | INV, FIX, FEAT, SPIKE, TASK | - -The default collection is `devops_kanban`. - -### Using Collections - -Collections can be activated via configuration: - -```toml -# config.toml -[templates] -active_collection = "dev_kanban" -``` - -Or create a custom collection in `.tickets/operator/issuetypes/collections.toml`: - -```toml -[collections.agile] -name = "agile" -description = "Agile development workflow" -types = ["STORY", "BUG", "TASK", "SPIKE"] -priority_order = ["BUG", "STORY", "TASK", "SPIKE"] -``` - -## Custom Issue Types - -Define custom issue types in `.tickets/operator/issuetypes/`: - -``` -.tickets/operator/issuetypes/ - STORY.json # User-defined type - BUG.json # User-defined type - collections.toml # Collection definitions - imports/ # Imported types from Jira -``` - -### Issue Type Schema - -```json -{ - "key": "STORY", - "name": "User Story", - "description": "A user-facing feature from the user's perspective", - "mode": "autonomous", - "glyph": "S", - "color": "cyan", - "project_required": true, - "fields": [ - {"name": "id", "type": "string", "required": true, "auto": "id"}, - {"name": "summary", "type": "string", "required": true, "default": ""}, - {"name": "acceptance_criteria", "type": "string", "required": false} - ], - "steps": [ - { - "name": "plan", - "outputs": ["acceptance_criteria"], - "prompt": "Create a plan for this user story", - "allowed_tools": ["Read", "Grep", "Glob"] - }, - { - "name": "implement", - "outputs": ["code"], - "prompt": "Implement the user story", - "allowed_tools": ["*"] - } - ] -} -``` - -## Importing from Kanban Systems - -Import issue types from Jira to use their type definitions locally. - -### Environment Variables - -```bash -# Jira -export OPERATOR_JIRA_DOMAIN=your-domain.atlassian.net -export OPERATOR_JIRA_EMAIL=you@example.com -export OPERATOR_JIRA_TOKEN=your-api-token -``` - -### Imported Type Structure - -Imported types have fields from the external system but use a single default step: - -```json -{ - "key": "STORY", - "name": "Story", - "description": "Imported from Jira", - "mode": "autonomous", - "glyph": "S", - "fields": [ - {"name": "id", "type": "string", "required": true, "auto": "id"}, - {"name": "summary", "type": "string", "required": true, "default": ""} - ], - "steps": [ - {"name": "execute", "outputs": [], "prompt": "Execute this task", "allowed_tools": ["*"]} - ], - "source": {"import": {"provider": "jira", "project": "MYPROJECT"}}, - "external_id": "10001" -} -``` - -Imported types are stored in: -``` -.tickets/operator/issuetypes/imports/{provider}/{project}/ - Story.json - Bug.json -``` - -## Agent Modes - -### Autonomous Mode (FEAT, FIX, TASK) - -- Launch and monitor progress -- Minimal human intervention -- Can run multiple agents in parallel - -### Paired Mode (SPIKE, INV) - -- Requires active human participation -- Tracks "awaiting input" states -- One paired agent at a time per operator - -## Step Permissions - -Each step in an issue type can define permissions that control the LLM agent's access to tools, directories, and MCP servers. Permissions are provider-agnostic and translated to the appropriate format for Claude, Gemini, or Codex at runtime. - -### Permission Fields - -Steps can include these optional permission fields: - -```json -{ - "name": "build", - "outputs": ["code"], - "prompt": "Implement the feature...", - "allowed_tools": ["Read", "Write", "Edit", "Bash"], - "permissions": { - "tools": { - "allow": [ - { "tool": "Bash", "pattern": "cargo:*" }, - { "tool": "Bash", "pattern": "npm:*" } - ], - "deny": [ - { "tool": "Bash", "pattern": "rm -rf:*" }, - { "tool": "Bash", "pattern": "sudo:*" } - ] - }, - "directories": { - "allow": ["../shared-libs/"], - "deny": ["./.env", "./secrets/"] - }, - "mcp_servers": { - "enable": ["memory"], - "disable": ["filesystem"] - } - }, - "cli_args": { - "claude": ["--output-format", "json"], - "gemini": ["--sandbox", "docker"], - "codex": ["--approval-policy", "on-failure"] - } -} -``` - -### Permission Composition - -Step permissions are **additive** with project-level permissions: - -1. Project permissions are loaded from `.operator/permissions.json` -2. Step permissions are added to project permissions -3. Both allow and deny lists are concatenated -4. Custom flags: step values override project values for the same key - -### Provider Translation - -Permissions are automatically translated to each provider's format: - -| Provider | Config Format | Tool Syntax | -|----------|--------------|-------------| -| Claude | CLI flags | `--allowedTools "Bash(cargo:*)"` | -| Gemini | `.gemini/settings.json` | `"ShellTool(cargo:*)"` | -| Codex | `.codex/config.toml` | `[tools.exec].allow_patterns` | - -### Session Config Persistence - -All generated configs are stored for auditing at: -``` -.tickets/operator/sessions/{ticket-id}/ - claude-audit.txt - settings.json # Gemini config - config.toml # Codex config - launch-command.txt # Full command used -``` - -## Validation - -When loading collections, Operator! validates that all referenced issue types exist. Missing types are logged as warnings and skipped: - -``` -WARN: Collection 'agile' references unknown type 'STORY', skipping -``` - -This allows collections to reference types that may not yet be defined, enabling gradual adoption. diff --git a/docs/kanban/index.md b/docs/kanban/index.md deleted file mode 100644 index 10897024..00000000 --- a/docs/kanban/index.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: Kanban Workflow -description: "Understand the kanban-style workflow for managing tickets through queue, in-progress, and completed stages." -layout: doc ---- - -Operator! uses a **Kanban** style workflow to manage tickets through their lifecycle. Kanban is a visual framework used to organize workby displaying tasks on a board, organized into categories of _todo_, _doing_, and _done_. - -## Ticket Lifecycle - -Tickets flow through these stages: - -``` -.tickets/queue/ -> Work waiting to be picked up -.tickets/in-progress/ -> Currently being worked on -.tickets/completed/ -> Finished work -``` - -## Workflow Steps - -### 1. Queue - -New tickets are created in `.tickets/queue/`. They are sorted by: -1. **Priority** - INV > FIX > FEAT > SPIKE -2. **Timestamp** - FIFO within same priority - -### 2. Assignment - -When an agent slot is available, Operator!: -1. Selects the next ticket by priority -2. Prompts for launch confirmation -3. Moves ticket to `in-progress/` - -### 3. In Progress - -While work is in progress: -- Agent status is tracked -- Progress notifications are sent -- Operator! monitors for completion or awaiting input - -### 4. Completion - -When work finishes: -- Ticket moves to `completed/` -- Notification is sent -- Agent slot is freed for next ticket - -## Parallelism Rules - -Operator! enforces these rules for concurrent work: - -- **Max agents** = min(configured_max, cpu_cores - reserved_cores) -- **Autonomous agents** (FEAT, FIX) can run in parallel on different projects -- **Paired agents** (SPIKE, INV) run one at a time per operator -- **Same project** = sequential execution to avoid conflicts - -## External Providers - -In addition to the local `.tickets/` queue described above, Operator! can sync items from external kanban systems: - -- [**Jira Cloud**](../getting-started/kanban/jira.md) — REST API, project + issue type sync -- [**Linear**](../getting-started/kanban/linear.md) — GraphQL API, team-scoped sync -- [**GitHub Projects v2**](../getting-started/kanban/github.md) — GraphQL API, project node ID sync - -GitHub Projects integration uses a **separate token** from Operator!'s PR/git workflows — the kanban provider needs the `project` scope while the git provider needs `repo`. See the [GitHub Projects guide](../getting-started/kanban/github.md) for the full disambiguation. diff --git a/docs/llms.txt b/docs/llms.txt index da8843bd..88b83bcd 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -9,13 +9,14 @@ Operator runs from the root of your work directory, discovers projects by LLM ma ## Getting Started - [Getting Started](https://operator.untra.io/getting-started/): Install and configure Operator! for managing AI-assisted development workflows. +- [Tickets](https://operator.untra.io/getting-started/tickets/): Create and manage tickets with markdown format, naming conventions, and best practices for LLM agents. +- [Supported Kanban Providers](https://operator.untra.io/getting-started/kanban/): Kanban and issue tracking integrations for Operator. +- [Supported Coding Agents](https://operator.untra.io/getting-started/agents/): AI coding agents compatible with Operator. - [Downloads](https://operator.untra.io/downloads/): Download Operator! binaries for macOS, Linux, and Windows. -## Core Concepts -- [Kanban Workflow](https://operator.untra.io/kanban/): Understand the kanban-style workflow for managing tickets through queue, in-progress, and completed stages. -- [Tickets](https://operator.untra.io/tickets/): Create and manage tickets with markdown format, naming conventions, and best practices for LLM agents. -- [Issue Types](https://operator.untra.io/issue-types/): Learn about INV, FIX, FEAT, SPIKE, and TASK issue types, custom definitions, and Jira imports. -- [Agents](https://operator.untra.io/agents/): Understand agent lifecycle, states, autonomous vs paired modes, parallelism rules, and session tracking. +## Workflows +- [Workflows](https://operator.untra.io/workflows/): Shareable collections of Operator workflows. +- [Workflow Export Formats](https://operator.untra.io/getting-started/workflows/): Export an Operator workflow into a format another LLM tool or model can run. - [Delegators](https://operator.untra.io/delegators/): Named LLM tool + model pairings for autonomous ticket launching. ## Integrations @@ -28,7 +29,7 @@ Operator runs from the root of your work directory, discovers projects by LLM ma - [Schema Reference](https://operator.untra.io/schemas/): Ticket metadata, issue type, and OpenAPI schema reference. - [Keyboard Shortcuts](https://operator.untra.io/shortcuts/): TUI keyboard shortcuts by context. - [Project Taxonomy](https://operator.untra.io/taxonomy/): Project Kinds across five tiers. +- [Artifact Detection](https://operator.untra.io/artifact-detection/): How Operator uses file artifacts as positive signals for step completion. ## Optional -- [Architecture](https://operator.untra.io/architecture/): System design overview. - [GitHub Repository](https://github.com/untra/operator): Source code (Rust, MIT). diff --git a/docs/maturity/index.md b/docs/maturity/index.md index 1445b4eb..7cf22bed 100644 --- a/docs/maturity/index.md +++ b/docs/maturity/index.md @@ -83,7 +83,7 @@ Operator integrates with many providers and tools across several **verticals**. |---|---|---| | AGNT | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | [AGNT](https://operator.untra.io/getting-started/integrations/agnt/) | -## Workflow Format +## Workflow Export Format | Integration | Status | Docs | |---|---|---| diff --git a/docs/schemas/openapi.json b/docs/schemas/openapi.json index e161e984..0d903302 100644 --- a/docs/schemas/openapi.json +++ b/docs/schemas/openapi.json @@ -997,6 +997,59 @@ } } }, + "/api/v1/issuetypes/{key}/document": { + "get": { + "tags": [ + "Issue Types" + ], + "summary": "Get an issue type's Operator workflow document", + "description": "Returns the issue type verbatim, in the same shape as the `.json` files\nin a hosted collection bundle (`/schemas/issuetype.json`). This is the\n*native* Operator workflow — the ordered step graph every export format is\nderived from — so the web UI and the docs site render identical graphs from\nidentical bytes. Prefer [`get_one`] for display metadata; use this when you\nneed the full step structure including step types, reject edges, and\nper-type fan-out configuration.", + "operationId": "issuetypes_get_document", + "parameters": [ + { + "name": "key", + "in": "path", + "description": "Issue type key (e.g., FEAT, FIX)", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "collection", + "in": "query", + "description": "Collection to scope the lookup to (defaults to resolution-order lookup)", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + } + ], + "responses": { + "200": { + "description": "Operator workflow document", + "content": { + "application/json": { + "schema": {} + } + } + }, + "404": { + "description": "Issue type not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/api/v1/issuetypes/{key}/steps": { "get": { "tags": [ @@ -2842,15 +2895,44 @@ "name", "description", "types", - "is_active" + "is_active", + "tier" ], "properties": { + "author": { + "type": [ + "string", + "null" + ], + "description": "Human author/attribution (present for hosted collections)." + }, + "created": { + "type": [ + "string", + "null" + ], + "description": "ISO-8601 date the collection was first published." + }, "description": { "type": "string" }, + "icon_path": { + "type": [ + "string", + "null" + ], + "description": "Bare filename of the collection's SVG icon, next to its manifest." + }, "is_active": { "type": "boolean" }, + "license": { + "type": [ + "string", + "null" + ], + "description": "SPDX license id." + }, "name": { "type": "string" }, @@ -2861,12 +2943,30 @@ ], "description": "Publisher identifier (present for hosted collections)." }, + "tier": { + "type": "string", + "description": "Provenance tier: `official` or `community`." + }, "types": { "type": "array", "items": { "type": "string" } }, + "updated": { + "type": [ + "string", + "null" + ], + "description": "ISO-8601 date of the last substantive revision." + }, + "url": { + "type": [ + "string", + "null" + ], + "description": "Link to the collection's source repository or project page." + }, "version": { "type": [ "string", diff --git a/docs/workflows/coder/index.md b/docs/workflows/coder/index.md new file mode 100644 index 00000000..a3032666 --- /dev/null +++ b/docs/workflows/coder/index.md @@ -0,0 +1,49 @@ +--- +title: "Coder" +layout: doc +section: workflows +--- + + + + +Linear-synced engineering flow: Feature, Improvement, and Bug work delegated to coding agents. + +| | | +|---|---| +| **Tier** | community | +| **Author** | [untra](https://github.com/untra/operator) | +| **License** | MIT | +| **Version** | 1.0.0 | +| **Created** | 2026-08-01 | +| **Updated** | 2026-08-01 | +| **Loop shape** | `kanban_synced_single_pass` | +| **Review gates** | `plan_review`, `test_suite`, `pr_review` | +| **Stops when** | tests_green; pr_created | +| **Manifest** | [`collection.json`](/collections/coder/collection.json) | + +## Issue types + +| Key | Name | Mode | Steps | +|---|---|---|---| +| `FEATURE` | Feature | autonomous | 5 | +| `IMPROVEMENT` | Improvement | autonomous | 4 | +| `BUG` | Bug | autonomous | 5 | + +## Workflows + +Select an issue type to see the Operator workflow it defines. This is the same graph the Operator app draws, rendered from the same published JSON. + +
+ +
+ +## Install + +Operator reads the hosted catalog on startup, so this collection appears in the setup picker. To pin it explicitly: + +```toml +# config.toml +[templates] +active_collection = "coder" +``` diff --git a/docs/workflows/dev_kanban/index.md b/docs/workflows/dev_kanban/index.md new file mode 100644 index 00000000..efd1c59d --- /dev/null +++ b/docs/workflows/dev_kanban/index.md @@ -0,0 +1,49 @@ +--- +title: "Dev Kanban" +layout: doc +section: workflows +--- + + + + +Developer kanban with TASK, FEAT, FIX + +| | | +|---|---| +| **Tier** | official | +| **Author** | [Operator!](https://github.com/untra/operator) | +| **License** | MIT | +| **Version** | 1.0.0 | +| **Created** | 2026-01-08 | +| **Updated** | 2026-06-16 | +| **Loop shape** | `single_pass` | +| **Review gates** | `test_suite` | +| **Stops when** | tests_green | +| **Manifest** | [`collection.json`](/collections/dev_kanban/collection.json) | + +## Issue types + +| Key | Name | Mode | Steps | +|---|---|---|---| +| `TASK` | Task | autonomous | 1 | +| `FEAT` | Feature | autonomous | 5 | +| `FIX` | Fix | autonomous | 5 | + +## Workflows + +Select an issue type to see the Operator workflow it defines. This is the same graph the Operator app draws, rendered from the same published JSON. + +
+ +
+ +## Install + +Operator reads the hosted catalog on startup, so this collection appears in the setup picker. To pin it explicitly: + +```toml +# config.toml +[templates] +active_collection = "dev_kanban" +``` diff --git a/docs/workflows/devops_kanban/index.md b/docs/workflows/devops_kanban/index.md new file mode 100644 index 00000000..bef92fcb --- /dev/null +++ b/docs/workflows/devops_kanban/index.md @@ -0,0 +1,51 @@ +--- +title: "DevOps Kanban" +layout: doc +section: workflows +--- + + + + +DevOps kanban with TASK, FEAT, FIX, SPIKE, INV + +| | | +|---|---| +| **Tier** | official | +| **Author** | [Operator!](https://github.com/untra/operator) | +| **License** | MIT | +| **Version** | 1.0.0 | +| **Created** | 2026-01-08 | +| **Updated** | 2026-06-16 | +| **Loop shape** | `review_loop` | +| **Review gates** | `human`, `test_suite` | +| **Stops when** | tests_green; review_approved | +| **Manifest** | [`collection.json`](/collections/devops_kanban/collection.json) | + +## Issue types + +| Key | Name | Mode | Steps | +|---|---|---|---| +| `TASK` | Task | autonomous | 1 | +| `FEAT` | Feature | autonomous | 5 | +| `FIX` | Fix | autonomous | 5 | +| `SPIKE` | Spike | paired | 3 | +| `INV` | Investigation | paired | 5 | + +## Workflows + +Select an issue type to see the Operator workflow it defines. This is the same graph the Operator app draws, rendered from the same published JSON. + +
+ +
+ +## Install + +Operator reads the hosted catalog on startup, so this collection appears in the setup picker. To pin it explicitly: + +```toml +# config.toml +[templates] +active_collection = "devops_kanban" +``` diff --git a/docs/workflows/elves_overnight/index.md b/docs/workflows/elves_overnight/index.md new file mode 100644 index 00000000..e7ac0353 --- /dev/null +++ b/docs/workflows/elves_overnight/index.md @@ -0,0 +1,50 @@ +--- +title: "Elves Overnight" +layout: doc +section: workflows +--- + + + + +Long-running staged batch workflow with durable memory, validation, PR review, and reporting. + +| | | +|---|---| +| **Tier** | community | +| **Author** | [Aigora](https://github.com/aigorahub/elves) | +| **License** | MIT | +| **Version** | 1.0.0 | +| **Created** | 2026-06-16 | +| **Updated** | 2026-07-01 | +| **Loop shape** | `staged_long_running_batch_loop` | +| **Review gates** | `stage_review`, `batch_validation`, `fresh_review`, `judge_verdict`, `human_land_gate` | +| **Stops when** | batch complete and checkpointed; validation cannot be repaired safely; PR has unresolved requested changes; time/risk budget exhausted | +| **Manifest** | [`collection.json`](/collections/elves_overnight/collection.json) | + +## Issue types + +| Key | Name | Mode | Steps | +|---|---|---|---| +| `ELVSTAGE` | Elves Stage | paired | 4 | +| `ELVBATCH` | Elves Batch | autonomous | 9 | +| `LANDPR` | Land Pull Request | paired | 6 | +| `ELVRPT` | Elves Report | paired | 3 | + +## Workflows + +Select an issue type to see the Operator workflow it defines. This is the same graph the Operator app draws, rendered from the same published JSON. + +
+ +
+ +## Install + +Operator reads the hosted catalog on startup, so this collection appears in the setup picker. To pin it explicitly: + +```toml +# config.toml +[templates] +active_collection = "elves_overnight" +``` diff --git a/docs/workflows/example_chores/index.md b/docs/workflows/example_chores/index.md new file mode 100644 index 00000000..77a2d067 --- /dev/null +++ b/docs/workflows/example_chores/index.md @@ -0,0 +1,47 @@ +--- +title: "Example Chores" +layout: doc +section: workflows +--- + + + + +Minimal example community collection demonstrating the shareable format. + +| | | +|---|---| +| **Tier** | community | +| **Author** | [untra](https://github.com/untra/operator) | +| **License** | MIT | +| **Version** | 0.1.0 | +| **Created** | 2026-07-01 | +| **Updated** | 2026-07-01 | +| **Loop shape** | `single_pass` | +| **Review gates** | `test_suite` | +| **Stops when** | tests_green | +| **Manifest** | [`collection.json`](/collections/example_chores/collection.json) | + +## Issue types + +| Key | Name | Mode | Steps | +|---|---|---|---| +| `CHORE` | Chore | autonomous | 2 | + +## Workflows + +Select an issue type to see the Operator workflow it defines. This is the same graph the Operator app draws, rendered from the same published JSON. + +
+ +
+ +## Install + +Operator reads the hosted catalog on startup, so this collection appears in the setup picker. To pin it explicitly: + +```toml +# config.toml +[templates] +active_collection = "example_chores" +``` diff --git a/docs/workflows/index.md b/docs/workflows/index.md new file mode 100644 index 00000000..321b236f --- /dev/null +++ b/docs/workflows/index.md @@ -0,0 +1,287 @@ +--- +title: "Workflows" +layout: doc +section: workflows +--- + + + + + +An **Operator workflow** is a process defined once in JSON: an ordered graph of +typed steps, review gates, and retry edges that an LLM agent can follow. It is +the native format — Operator runs it directly, and every +[export format](/getting-started/workflows/) (Claude, AGNT) is derived from it. + +Three terms, three different things: + +| Term | What it is | +|------|-----------| +| **Operator workflow** | The step graph itself. Lives in an issue type's `steps`. | +| **Issue type** | One kind of work — `FEAT`, `PRD`, `ELVSTAGE`. Carries identity, input fields, and exactly one Operator workflow. | +| **Collection** | A named, versioned bundle of issue types: a complete, shareable way of working. This page lists them. | + +Collections are deliberately separate from your **kanban issue types**. Jira, +Linear, and GitHub Projects types describe how *your* team labels work; a +collection describes how the *agents* do it. Map one onto the other once, and +the workflow travels between projects, teams, and providers unchanged. + +Every collection below is installable from Operator directly — they are published from this site as a [machine-readable index](/collections/index.json) that operator instances read on startup. + + + +
+
+
+ + +

Simple

+
+

Simple workflow with TASK only

+

TASK

+

+ official + 1 issue type + Operator! + updated 2026-07-01 +

+
+
+ + +

Dev Kanban

+
+

Developer kanban with TASK, FEAT, FIX

+

TASK FEAT FIX

+

+ official + 3 issue types + Operator! + updated 2026-06-16 +

+
+
+ + +

DevOps Kanban

+
+

DevOps kanban with TASK, FEAT, FIX, SPIKE, INV

+

TASK FEAT FIX SPIKE INV

+

+ official + 5 issue types + Operator! + updated 2026-06-16 +

+
+
+ + +

Operator

+
+

Operator automation tasks: ASSESS, SYNC, INIT

+

ASSESS SYNC INIT AGENT_SETUP PROJECT_INIT

+

+ official + 5 issue types + Operator! + updated 2026-07-01 +

+
+
+ + +

Ralph Loop

+
+

PRD-to-story loop for completing one right-sized story per fresh agent context.

+

PRD STORY RLOOP

+

+ community + 3 issue types + snarktank + updated 2026-07-01 +

+
+
+ + +

JR Orchestration

+
+

Feature/task orchestration with coder, reviewer, architect, and rebase work units.

+

JRPLAN JRFEAT JRTASK JRREV JRREBASE

+

+ community + 5 issue types + snapwich + updated 2026-07-01 +

+
+
+ + +

Elves Overnight

+
+

Long-running staged batch workflow with durable memory, validation, PR review, and reporting.

+

ELVSTAGE ELVBATCH LANDPR ELVRPT

+

+ community + 4 issue types + Aigora + updated 2026-07-01 +

+
+
+ + +

Coder

+
+

Linear-synced engineering flow: Feature, Improvement, and Bug work delegated to coding agents.

+

FEATURE IMPROVEMENT BUG

+

+ community + 3 issue types + untra + updated 2026-08-01 +

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CollectionDescriptionIssue typesLoopAuthorTierCreatedUpdated
SimpleSimple workflow with TASK only1single_passOperator!official2026-01-082026-07-01
Dev KanbanDeveloper kanban with TASK, FEAT, FIX3single_passOperator!official2026-01-082026-06-16
DevOps KanbanDevOps kanban with TASK, FEAT, FIX, SPIKE, INV5review_loopOperator!official2026-01-082026-06-16
OperatorOperator automation tasks: ASSESS, SYNC, INIT5single_passOperator!official2026-01-082026-07-01
Ralph LoopPRD-to-story loop for completing one right-sized story per fresh agent context.3fresh_context_story_loopsnarktankcommunity2026-06-162026-07-01
JR OrchestrationFeature/task orchestration with coder, reviewer, architect, and rebase work units.5feature_task_review_graphsnapwichcommunity2026-06-162026-07-01
Elves OvernightLong-running staged batch workflow with durable memory, validation, PR review, and reporting.4staged_long_running_batch_loopAigoracommunity2026-06-162026-07-01
CoderLinear-synced engineering flow: Feature, Improvement, and Bug work delegated to coding agents.3kanban_synced_single_passuntracommunity2026-08-012026-08-01
Example ChoresMinimal example community collection demonstrating the shareable format.1single_passuntracommunity2026-07-012026-07-01
+
+ +## Contribute a collection + +There is no single best way to run agents — the right loop depends on the work. +That is exactly why these are shareable: a workflow that works for you is worth +publishing, and one that does not fit is worth forking. + +Official collections live in the [operator repository](https://github.com/untra/operator/tree/main/collections): + +1. Create `collections/community//`, where `` matches `^[a-z0-9_]{3,64}$`. +2. Add a `collection.json` conforming to [the collection schema](/collections/schema.json), + with `tier: "community"` plus `author`, `url`, and `license`. +3. Add one `.json` per issue type — see [the issue type schema](/schemas/issuetype/) — + and an optional `.md` ticket template. +4. Add an `icon.svg` following the + [Simple Icons](https://github.com/simple-icons/simple-icons) shape: a 24×24 + viewBox, a single ``, and no `fill` or `stroke` so it inherits the + page's color. +5. Leave checksums out — they are computed at publish time. +6. Run the CI gate locally, then open a pull request: + +```bash +cargo test --test community_collections +``` + +Submissions are reviewed for prompt quality and safety, not just schema +validity. A good collection describes a workflow shape worth sharing: what loop +it runs, what memory it keeps, what gates it enforces, and when it stops. diff --git a/docs/workflows/jr_orchestration/index.md b/docs/workflows/jr_orchestration/index.md new file mode 100644 index 00000000..220f61a3 --- /dev/null +++ b/docs/workflows/jr_orchestration/index.md @@ -0,0 +1,51 @@ +--- +title: "JR Orchestration" +layout: doc +section: workflows +--- + + + + +Feature/task orchestration with coder, reviewer, architect, and rebase work units. + +| | | +|---|---| +| **Tier** | community | +| **Author** | [snapwich](https://github.com/snapwich/jr) | +| **License** | MIT | +| **Version** | 1.0.0 | +| **Created** | 2026-06-16 | +| **Updated** | 2026-07-01 | +| **Loop shape** | `feature_task_review_graph` | +| **Review gates** | `code_review`, `architect_review`, `human_pr_review` | +| **Stops when** | feature PR ready for human review; review changes requested; blocked dependency documented; review escalated to human after repeated changes (operator stops; no auto-handoff) | +| **Manifest** | [`collection.json`](/collections/jr_orchestration/collection.json) | + +## Issue types + +| Key | Name | Mode | Steps | +|---|---|---|---| +| `JRPLAN` | JR Plan | paired | 4 | +| `JRFEAT` | JR Feature | paired | 4 | +| `JRTASK` | JR Task | autonomous | 5 | +| `JRREV` | JR Review | paired | 4 | +| `JRREBASE` | JR Rebase | autonomous | 4 | + +## Workflows + +Select an issue type to see the Operator workflow it defines. This is the same graph the Operator app draws, rendered from the same published JSON. + +
+ +
+ +## Install + +Operator reads the hosted catalog on startup, so this collection appears in the setup picker. To pin it explicitly: + +```toml +# config.toml +[templates] +active_collection = "jr_orchestration" +``` diff --git a/docs/workflows/operator/index.md b/docs/workflows/operator/index.md new file mode 100644 index 00000000..d1447d0c --- /dev/null +++ b/docs/workflows/operator/index.md @@ -0,0 +1,51 @@ +--- +title: "Operator" +layout: doc +section: workflows +--- + + + + +Operator automation tasks: ASSESS, SYNC, INIT + +| | | +|---|---| +| **Tier** | official | +| **Author** | [Operator!](https://github.com/untra/operator) | +| **License** | MIT | +| **Version** | 1.0.0 | +| **Created** | 2026-01-08 | +| **Updated** | 2026-07-01 | +| **Loop shape** | `single_pass` | +| **Review gates** | `human` | +| **Stops when** | setup_artifacts_written | +| **Manifest** | [`collection.json`](/collections/operator/collection.json) | + +## Issue types + +| Key | Name | Mode | Steps | +|---|---|---|---| +| `ASSESS` | Project Assessment | autonomous | 2 | +| `SYNC` | Catalog Sync | autonomous | 3 | +| `INIT` | Workspace Init | paired | 3 | +| `AGENT_SETUP` | Agent Setup | paired | 3 | +| `PROJECT_INIT` | Project Initialization | autonomous | 2 | + +## Workflows + +Select an issue type to see the Operator workflow it defines. This is the same graph the Operator app draws, rendered from the same published JSON. + +
+ +
+ +## Install + +Operator reads the hosted catalog on startup, so this collection appears in the setup picker. To pin it explicitly: + +```toml +# config.toml +[templates] +active_collection = "operator" +``` diff --git a/docs/workflows/ralph_loop/index.md b/docs/workflows/ralph_loop/index.md new file mode 100644 index 00000000..11a10d93 --- /dev/null +++ b/docs/workflows/ralph_loop/index.md @@ -0,0 +1,49 @@ +--- +title: "Ralph Loop" +layout: doc +section: workflows +--- + + + + +PRD-to-story loop for completing one right-sized story per fresh agent context. + +| | | +|---|---| +| **Tier** | community | +| **Author** | [snarktank](https://github.com/snarktank/ralph) | +| **License** | MIT | +| **Version** | 1.0.0 | +| **Created** | 2026-06-16 | +| **Updated** | 2026-07-01 | +| **Loop shape** | `fresh_context_story_loop` | +| **Review gates** | `plan_review`, `test_suite`, `story_completion_check` | +| **Stops when** | all stories have passes=true; blocked story documented; quality gates fail repeatedly; max_iterations reached (advisory; outer story loop is operator-queue-driven) | +| **Manifest** | [`collection.json`](/collections/ralph_loop/collection.json) | + +## Issue types + +| Key | Name | Mode | Steps | +|---|---|---|---| +| `PRD` | Product Requirements Document | paired | 4 | +| `STORY` | Ralph Story | autonomous | 5 | +| `RLOOP` | Ralph Loop Coordinator | paired | 4 | + +## Workflows + +Select an issue type to see the Operator workflow it defines. This is the same graph the Operator app draws, rendered from the same published JSON. + +
+ +
+ +## Install + +Operator reads the hosted catalog on startup, so this collection appears in the setup picker. To pin it explicitly: + +```toml +# config.toml +[templates] +active_collection = "ralph_loop" +``` diff --git a/docs/workflows/simple/index.md b/docs/workflows/simple/index.md new file mode 100644 index 00000000..1cafd6c8 --- /dev/null +++ b/docs/workflows/simple/index.md @@ -0,0 +1,46 @@ +--- +title: "Simple" +layout: doc +section: workflows +--- + + + + +Simple workflow with TASK only + +| | | +|---|---| +| **Tier** | official | +| **Author** | [Operator!](https://github.com/untra/operator) | +| **License** | MIT | +| **Version** | 1.0.0 | +| **Created** | 2026-01-08 | +| **Updated** | 2026-07-01 | +| **Loop shape** | `single_pass` | +| **Stops when** | task_complete | +| **Manifest** | [`collection.json`](/collections/simple/collection.json) | + +## Issue types + +| Key | Name | Mode | Steps | +|---|---|---|---| +| `TASK` | Task | autonomous | 1 | + +## Workflows + +Select an issue type to see the Operator workflow it defines. This is the same graph the Operator app draws, rendered from the same published JSON. + +
+ +
+ +## Install + +Operator reads the hosted catalog on startup, so this collection appears in the setup picker. To pin it explicitly: + +```toml +# config.toml +[templates] +active_collection = "simple" +``` diff --git a/scripts/cicdprep.sh b/scripts/cicdprep.sh index c9731f9d..97ea627d 100755 --- a/scripts/cicdprep.sh +++ b/scripts/cicdprep.sh @@ -164,12 +164,13 @@ needs_operator() { needs_opr8r() { has_changes '^opr8r/'; } needs_vscode() { has_changes '^(vscode-extension/|icons/)'; } needs_zed() { has_changes '^zed-extension/'; } -needs_docs() { has_changes '^(docs/|src/docs_gen/|src/taxonomy/taxonomy\.toml|src/templates/.*\.json)'; } +needs_docs() { has_changes '^(docs/|src/docs_gen/|src/taxonomy/taxonomy\.toml|src/templates/.*\.json|src/collections/|collections/|src/schemas/|webcomponents/|src/workflow_gen/)'; } # A bun project needs a lockfile check whenever its package.json or bun.lock # changed. Root project lives at the repo root; others under their dir. needs_bun_root() { has_changes '^(package\.json|bun\.lock)$'; } needs_bun_ui() { has_changes '^ui/(package\.json|bun\.lock)$'; } +needs_bun_webcomp() { has_changes '^webcomponents/(package\.json|bun\.lock)$'; } needs_bun_backstage() { has_changes '^backstage-server/(.*/)?(package\.json|bun\.lock)$'; } # --- 0. Bun lockfiles --- @@ -179,12 +180,13 @@ needs_bun_backstage() { has_changes '^backstage-server/(.*/)?(package\.json|bun # `bun install --frozen-lockfile`, and additionally covers the root and # backstage-server lockfiles that CI does not currently enforce. -if needs_bun_root || needs_bun_ui || needs_bun_backstage; then +if needs_bun_root || needs_bun_ui || needs_bun_webcomp || needs_bun_backstage; then section "Bun lockfiles" require_tool bun "bun lockfile sync" if needs_bun_root; then check_bun_lockfile "."; else skip "Lockfile sync: . (no changes)"; fi if needs_bun_ui; then check_bun_lockfile "ui"; else skip "Lockfile sync: ui (no changes)"; fi + if needs_bun_webcomp; then check_bun_lockfile "webcomponents"; else skip "Lockfile sync: webcomponents (no changes)"; fi if needs_bun_backstage; then check_bun_lockfile "backstage-server"; else skip "Lockfile sync: backstage-server (no changes)"; fi else skip "Bun lockfiles" @@ -198,10 +200,44 @@ if needs_operator; then require_tool bun "operator UI build" require_tool cargo-deny "operator dependency audit" + # The frontend is typed against types generated from Rust, so bindings come + # first — exactly the order .github/workflows/build.yaml uses. + step "Bindings up to date" + BINDINGS_BEFORE="$(find bindings -type f -name "*.ts" -exec shasum {} + 2>/dev/null | sort || true)" + if cargo test --locked export_bindings_ >/dev/null 2>&1; then + BINDINGS_AFTER="$(find bindings -type f -name "*.ts" -exec shasum {} + 2>/dev/null | sort || true)" + if [ "$BINDINGS_BEFORE" = "$BINDINGS_AFTER" ]; then + pass "Bindings up to date" + else + echo -e " ${YELLOW}bindings/ was stale and has been regenerated — review and commit it${RESET}" + diff <(echo "$BINDINGS_BEFORE") <(echo "$BINDINGS_AFTER") | head -20 | sed 's/^/ /' || true + fail "Bindings up to date" + fi + else + fail "Bindings up to date (generation failed)" + fi + + # CI additionally requires them committed; surface that here as a reminder + BINDING_CHANGES="$(git status --porcelain --untracked-files=all bindings/ || true)" + if [ -n "$BINDING_CHANGES" ]; then + echo -e " ${YELLOW}note: bindings/ has uncommitted changes — CI requires them committed:${RESET}" + echo "$BINDING_CHANGES" | sed 's/^/ /' + fi + + step "Web components build" + ( + cd webcomponents + bun install --frozen-lockfile + bun run typecheck + bun test + bun run build + ) && pass "Web components build" || fail "Web components build" + step "UI build" ( cd ui bun install --frozen-lockfile + bun run typecheck bun run build DIST_SIZE=$(du -sk dist/ | awk '{print $1 * 1024}') echo " UI dist size: ${DIST_SIZE}B ($(echo "scale=1; $DIST_SIZE/1048576" | bc)MB)" @@ -280,9 +316,30 @@ if needs_docs; then require_tool cargo "docs generation" require_tool bundle "docs Jekyll build" + require_tool bun "docs web components" + run_step "docs generate" cargo run --locked -- docs + + # The site loads the shared components bundle; build it the way docs.yml does + step "Docs web components" + ( + cargo test --locked export_bindings_ >/dev/null + cd webcomponents && bun install --frozen-lockfile && bun run build + ) && pass "Docs web components" || fail "Docs web components" + step "Jekyll build" - (cd docs && bundle install && bundle exec jekyll build) && pass "Jekyll build" || fail "Jekyll build" + ( + mkdir -p docs/assets/js + cp webcomponents/dist/elements.js webcomponents/dist/elements.css docs/assets/js/ + cd docs && bundle install && bundle exec jekyll build + ) && pass "Jekyll build" || fail "Jekyll build" + + # The hosted collection bundle is excluded from Jekyll and copied in verbatim + step "Collection bundle served verbatim" + ( + cp -R docs/collections docs/_site/ + diff -r docs/collections docs/_site/collections + ) && pass "Collection bundle served verbatim" || fail "Collection bundle served verbatim" else skip "docs" fi diff --git a/src/app/tickets.rs b/src/app/tickets.rs index 176d28dc..3e708c87 100644 --- a/src/app/tickets.rs +++ b/src/app/tickets.rs @@ -92,15 +92,16 @@ impl App { .map(|s| { s.selected_hosted_collections() .into_iter() - .map(|r| (r.manifest.clone(), r.files.clone())) + .map(|r| (r.manifest.clone(), r.files.clone(), r.icon_svg.clone())) .collect() }) .unwrap_or_default(); - for (manifest, files) in &hosted { + for (manifest, files, icon_svg) in &hosted { crate::startup::templates::write_fetched_collection( &tickets_path.join("templates"), manifest, files, + icon_svg.as_deref(), )?; } if let [single] = hosted.as_slice() { diff --git a/src/collections/coder/BUG.json b/src/collections/coder/BUG.json new file mode 100644 index 00000000..9a1a54a9 --- /dev/null +++ b/src/collections/coder/BUG.json @@ -0,0 +1,139 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "BUG", + "name": "Bug", + "description": "Defect report synced from the Linear Bug label with reproduce-first workflow", + "mode": "autonomous", + "glyph": "x", + "color": "red", + "project_required": true, + "agent_prompt": "Review this project and prepare to create an agent for fixing bugs. The agent should read tickets from .tickets/, reproduce issues with failing tests, implement minimal fixes, verify with tests, and create PRs. Output ONLY the agent system prompt.", + "prompt": "You are fixing a bug in the {{ project }} project.\n\nThis ticket may have been synced from Linear; the ticket body carries the source description and a link back to the Linear issue.\n\n## Current Behavior\n{{ current_behavior }}\n\n## Expected Behavior\n{{ expected_behavior }}\n\n## Steps to Reproduce\n{{ steps_to_reproduce }}\n\nReproduce first, then fix the root cause with the smallest change that resolves it.", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "priority", + "description": "Ticket priority level", + "type": "enum", + "required": false, + "default": "P2-medium", + "options": ["P0-critical", "P1-high", "P2-medium", "P3-low"], + "display_order": 1 + }, + { + "name": "severity", + "description": "Bug severity", + "type": "enum", + "required": false, + "default": "N/A", + "options": ["S0-outage", "S1-major", "S2-minor", "S3-cosmetic", "N/A"], + "display_order": 2 + }, + { + "name": "branch", + "description": "Git branch name", + "type": "string", + "required": true, + "auto": "branch", + "display_order": 3, + "user_editable": false + }, + { + "name": "summary", + "description": "Name or summary of this bug", + "type": "string", + "required": true, + "default": "", + "placeholder": "Brief description of the defect", + "max_length": 120, + "display_order": 4 + }, + { + "name": "current_behavior", + "description": "What happens today (the defect)", + "type": "text", + "required": false, + "default": "", + "placeholder": "Observed behavior, error messages, log excerpts", + "display_order": 5 + }, + { + "name": "expected_behavior", + "description": "What should happen instead", + "type": "text", + "required": false, + "default": "", + "placeholder": "Correct behavior once fixed", + "display_order": 6 + }, + { + "name": "steps_to_reproduce", + "description": "How to trigger the defect", + "type": "text", + "required": false, + "default": "", + "placeholder": "1. Do X\n2. Do Y\n3. Observe Z", + "display_order": 7 + } + ], + "steps": [ + { + "name": "plan", + "display_name": "Planning", + "outputs": ["plan"], + "prompt": "Assess the project and plan reproduction:\n\n1. Understand the project structure\n2. Find how to run the project and tests\n3. Locate code likely involved in the defect\n\n## Current Behavior\n{{ current_behavior }}\n\n## Expected Behavior\n{{ expected_behavior }}\n\n## Steps to Reproduce\n{{ steps_to_reproduce }}\n\nWrite a plan to `.tickets/plans/{{ id }}.md` with:\n- How to run the project and relevant test commands\n- Steps to reproduce the issue\n- Initial hypotheses about the root cause", + "allowed_tools": ["Read", "Glob", "Grep", "Write", "Bash"], + "review_type": "plan", + "artifact_patterns": [".tickets/plans/{{ id }}.md"], + "on_reject": { + "goto_step": "plan", + "prompt": "Plan rejected: {{ rejection_reason }}\n\nRevise the reproduction plan." + }, + "next_step": "reproduce" + }, + { + "name": "reproduce", + "display_name": "Reproducing", + "outputs": ["test", "report"], + "prompt": "Reproduce the bug:\n\n1. Follow the plan to reproduce the issue\n2. Write a failing test that demonstrates the defect\n3. Document the reproduction in `.tickets/notes/{{ id }}.md`\n4. Confirm the test fails for the expected reason", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], + "next_step": "test" + }, + { + "name": "test", + "display_name": "Baseline Testing", + "outputs": ["report"], + "prompt": "Establish test baseline:\n\n1. Run the full test suite to understand current state\n2. Document which tests pass/fail\n3. Identify any related test coverage gaps\n4. Note any flaky or slow tests", + "allowed_tools": ["Read", "Bash"], + "next_step": "fix" + }, + { + "name": "fix", + "display_name": "Fixing", + "outputs": ["code"], + "prompt": "Implement the fix:\n\n1. Apply a minimal fix addressing the root cause\n2. Verify the previously failing test now passes\n3. Run the project's full test suite\n4. Run the project's linter and formatter\n\nKeep the fix focused and minimal.", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], + "next_step": "deploy" + }, + { + "name": "deploy", + "display_name": "Deploying", + "outputs": ["pr"], + "prompt": "Create a pull request for the fix:\n\n1. Commit all changes with message: `fix({{ project }}): {{ summary }}`\n2. Push the fix branch\n3. Create a PR with:\n - Root cause analysis\n - Description of the fix\n - Test verification\n - Link to ticket: {{ id }}\n\nIf the ticket links a Linear issue, reference it in the PR body so Linear links the PR.\n\nFix complete. Move ticket to completed after PR is merged.", + "allowed_tools": ["Bash", "Read"], + "review_type": "pr", + "on_reject": { + "goto_step": "fix", + "prompt": "Deploy rejected: {{ rejection_reason }}\n\nAddress feedback and revise the fix." + } + } + ] +} diff --git a/src/collections/coder/BUG.md b/src/collections/coder/BUG.md new file mode 100644 index 00000000..e054b47d --- /dev/null +++ b/src/collections/coder/BUG.md @@ -0,0 +1,23 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +branch: {{ branch }} +{{#if priority }}priority: {{ priority }} +{{/if}}--- + +# Bug: {{ summary }} + +{{#if current_behavior }} +## Current Behavior +{{ current_behavior }} +{{/if}} +{{#if expected_behavior }} +## Expected Behavior +{{ expected_behavior }} +{{/if}} +{{#if steps_to_reproduce }} +## Steps to Reproduce +{{ steps_to_reproduce }} +{{/if}} diff --git a/src/collections/coder/FEATURE.json b/src/collections/coder/FEATURE.json new file mode 100644 index 00000000..e79eeacd --- /dev/null +++ b/src/collections/coder/FEATURE.json @@ -0,0 +1,128 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "FEATURE", + "name": "Feature", + "description": "New functionality synced from the Linear Feature label", + "mode": "autonomous", + "glyph": "+", + "color": "green", + "project_required": true, + "agent_prompt": "Review this project and prepare to create an agent for implementing new features. The agent should read tickets from .tickets/, create feature branches, implement code following existing patterns, run tests and linting, and create PRs. Output ONLY the agent system prompt.", + "prompt": "You are implementing a new feature for the {{ project }} project.\n\nThis ticket may have been synced from Linear; the ticket body carries the source description and a link back to the Linear issue.\n\n## User Story\n{{ user_story }}\n\nBefore starting, understand the requirements and how success will be judged.", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "priority", + "description": "Ticket priority level", + "type": "enum", + "required": false, + "default": "P2-medium", + "options": ["P0-critical", "P1-high", "P2-medium", "P3-low"], + "display_order": 1 + }, + { + "name": "branch", + "description": "Git branch name", + "type": "string", + "required": true, + "auto": "branch", + "display_order": 2, + "user_editable": false + }, + { + "name": "summary", + "description": "Name or summary of this feature", + "type": "string", + "required": true, + "default": "", + "placeholder": "Brief description of the feature", + "max_length": 120, + "display_order": 3 + }, + { + "name": "user_story", + "description": "Why is this feature needed? User story format preferred", + "type": "text", + "required": false, + "default": "", + "placeholder": "As a USER, I want to X, so I can Y", + "display_order": 4 + }, + { + "name": "estimate", + "description": "Estimate points (from Linear)", + "type": "integer", + "required": false, + "display_order": 5 + }, + { + "name": "customer", + "description": "Requesting customer (from Linear)", + "type": "string", + "required": false, + "default": "", + "placeholder": "Customer name if applicable", + "display_order": 6 + } + ], + "steps": [ + { + "name": "plan", + "display_name": "Planning", + "outputs": ["plan"], + "prompt": "You are implementing a new feature. First, read the ticket and explore the codebase to understand:\n1. Where the feature should be implemented\n2. What existing code/patterns to follow\n3. What tests need to be added\n\nCreate a detailed implementation plan in `.tickets/plans/{{ id }}.md` with:\n- Files to create/modify\n- Key implementation steps\n- Test coverage requirements\n- Any dependencies or risks", + "allowed_tools": ["Read", "Glob", "Grep", "Write"], + "review_type": "plan", + "artifact_patterns": [".tickets/plans/{{ id }}.md"], + "on_reject": { + "goto_step": "plan", + "prompt": "Plan rejected: {{ rejection_reason }}\n\nRevise the plan based on feedback." + }, + "next_step": "build" + }, + { + "name": "build", + "display_name": "Building", + "outputs": ["code"], + "prompt": "Build the feature structure based on the plan in `.tickets/plans/{{ id }}.md`.\n\nIn this step, focus on:\n- Creating new files and modules\n- Setting up the basic structure\n- Adding type definitions and interfaces\n- Creating stub implementations\n\nDo not implement full logic yet - that comes in the next step.", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], + "next_step": "code" + }, + { + "name": "code", + "display_name": "Coding", + "outputs": ["code"], + "prompt": "Implement the feature logic based on the plan and structure.\n\nGuidelines:\n- Follow existing code patterns and conventions\n- Keep changes minimal and focused\n- Run formatters after making changes", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], + "next_step": "test" + }, + { + "name": "test", + "display_name": "Testing", + "outputs": ["test", "code"], + "prompt": "Add tests for the new feature and ensure all tests pass.\n\n1. Write unit tests for new functions/modules\n2. Add integration tests if applicable\n3. Run the project's full test suite\n4. Run the project's linter and formatter\n\nFix any failures before proceeding.", + "allowed_tools": ["Read", "Write", "Edit", "Bash"], + "next_step": "deploy" + }, + { + "name": "deploy", + "display_name": "Deploying", + "outputs": ["pr"], + "prompt": "Create a pull request for the feature:\n\n1. Commit all changes with a descriptive message\n2. Push the feature branch\n3. Create a PR with:\n - Clear title: `feat({{ project }}): {{ summary }}`\n - Description of changes\n - Link to ticket: {{ id }}\n - Test instructions\n\nIf the ticket links a Linear issue, reference it in the PR body so Linear links the PR.\n\nFeature complete. Move ticket to completed after PR is merged.", + "allowed_tools": ["Bash", "Read"], + "review_type": "pr", + "on_reject": { + "goto_step": "code", + "prompt": "Deploy rejected: {{ rejection_reason }}\n\nAddress feedback and revise the implementation." + } + } + ] +} diff --git a/src/collections/coder/FEATURE.md b/src/collections/coder/FEATURE.md new file mode 100644 index 00000000..7c5d680b --- /dev/null +++ b/src/collections/coder/FEATURE.md @@ -0,0 +1,19 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +branch: {{ branch }} +{{#if priority }}priority: {{ priority }} +{{/if}}--- + +# Feature: {{ summary }} + +{{#if user_story }} +## User Story +{{ user_story }} +{{/if}} +{{#if customer }} +## Customer +{{ customer }} +{{/if}} diff --git a/src/collections/coder/IMPROVEMENT.json b/src/collections/coder/IMPROVEMENT.json new file mode 100644 index 00000000..959d5094 --- /dev/null +++ b/src/collections/coder/IMPROVEMENT.json @@ -0,0 +1,120 @@ +{ + "$schema": "../../schemas/issuetype_schema.json", + "key": "IMPROVEMENT", + "name": "Improvement", + "description": "Enhancement to existing behavior synced from the Linear Improvement label", + "mode": "autonomous", + "glyph": "^", + "color": "blue", + "project_required": true, + "agent_prompt": "Review this project and prepare to create an agent for improving existing functionality. The agent should read tickets from .tickets/, scope the smallest change that delivers the improvement, implement it following existing patterns, run tests and linting, and create PRs. Output ONLY the agent system prompt.", + "prompt": "You are improving existing functionality in the {{ project }} project.\n\nThis ticket may have been synced from Linear; the ticket body carries the source description and a link back to the Linear issue.\n\n## Motivation\n{{ motivation }}\n\nPrefer the smallest change that delivers the improvement. Do not expand scope.", + "fields": [ + { + "name": "id", + "description": "Unique ticket identifier", + "type": "string", + "required": true, + "auto": "id", + "display_order": 0, + "user_editable": false + }, + { + "name": "priority", + "description": "Ticket priority level", + "type": "enum", + "required": false, + "default": "P2-medium", + "options": ["P0-critical", "P1-high", "P2-medium", "P3-low"], + "display_order": 1 + }, + { + "name": "branch", + "description": "Git branch name", + "type": "string", + "required": true, + "auto": "branch", + "display_order": 2, + "user_editable": false + }, + { + "name": "summary", + "description": "Name or summary of this improvement", + "type": "string", + "required": true, + "default": "", + "placeholder": "Brief description of the improvement", + "max_length": 120, + "display_order": 3 + }, + { + "name": "motivation", + "description": "What is inadequate today, and what does better look like?", + "type": "text", + "required": false, + "default": "", + "placeholder": "Current shortcoming and the expected improvement", + "display_order": 4 + }, + { + "name": "estimate", + "description": "Estimate points (from Linear)", + "type": "integer", + "required": false, + "display_order": 5 + }, + { + "name": "customer", + "description": "Requesting customer (from Linear)", + "type": "string", + "required": false, + "default": "", + "placeholder": "Customer name if applicable", + "display_order": 6 + } + ], + "steps": [ + { + "name": "plan", + "display_name": "Planning", + "outputs": ["plan"], + "prompt": "You are improving existing functionality. First, read the ticket and locate the current behavior in the codebase:\n1. Find the code that implements today's behavior\n2. Understand why it is inadequate ({{ motivation }})\n3. Identify the smallest change that delivers the improvement\n\nCreate a focused plan in `.tickets/plans/{{ id }}.md` with:\n- The current behavior and where it lives\n- The proposed change, scoped minimally - no scope creep\n- How existing tests must change, and what new coverage is needed\n- Any behavior changes callers/users will notice", + "allowed_tools": ["Read", "Glob", "Grep", "Write"], + "review_type": "plan", + "artifact_patterns": [".tickets/plans/{{ id }}.md"], + "on_reject": { + "goto_step": "plan", + "prompt": "Plan rejected: {{ rejection_reason }}\n\nRevise the plan based on feedback." + }, + "next_step": "code" + }, + { + "name": "code", + "display_name": "Coding", + "outputs": ["code"], + "prompt": "Implement the improvement per the plan in `.tickets/plans/{{ id }}.md`.\n\nGuidelines:\n- Change only what the plan scoped; resist adjacent cleanups\n- Follow existing code patterns and conventions\n- Preserve existing behavior outside the improvement\n- Run formatters after making changes", + "allowed_tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], + "next_step": "test" + }, + { + "name": "test", + "display_name": "Testing", + "outputs": ["test", "code"], + "prompt": "Verify the improvement and guard against regressions.\n\n1. Update tests that asserted the old behavior\n2. Add tests demonstrating the improved behavior\n3. Run the project's full test suite\n4. Run the project's linter and formatter\n\nFix any failures before proceeding.", + "allowed_tools": ["Read", "Write", "Edit", "Bash"], + "next_step": "deploy" + }, + { + "name": "deploy", + "display_name": "Deploying", + "outputs": ["pr"], + "prompt": "Create a pull request for the improvement:\n\n1. Commit all changes with a descriptive message\n2. Push the branch\n3. Create a PR with:\n - Clear title: `improve({{ project }}): {{ summary }}`\n - Before/after description of the behavior\n - Link to ticket: {{ id }}\n - Test instructions\n\nIf the ticket links a Linear issue, reference it in the PR body so Linear links the PR.\n\nImprovement complete. Move ticket to completed after PR is merged.", + "allowed_tools": ["Bash", "Read"], + "review_type": "pr", + "on_reject": { + "goto_step": "code", + "prompt": "Deploy rejected: {{ rejection_reason }}\n\nAddress feedback and revise the implementation." + } + } + ] +} diff --git a/src/collections/coder/IMPROVEMENT.md b/src/collections/coder/IMPROVEMENT.md new file mode 100644 index 00000000..13b5e7ef --- /dev/null +++ b/src/collections/coder/IMPROVEMENT.md @@ -0,0 +1,19 @@ +--- +id: {{ id }} +{{#if step }}step: {{ step }} +{{/if}}status: {{ status }} +created: {{ created_datetime }} +branch: {{ branch }} +{{#if priority }}priority: {{ priority }} +{{/if}}--- + +# Improvement: {{ summary }} + +{{#if motivation }} +## Motivation +{{ motivation }} +{{/if}} +{{#if customer }} +## Customer +{{ customer }} +{{/if}} diff --git a/src/collections/coder/collection.json b/src/collections/coder/collection.json new file mode 100644 index 00000000..ca03a839 --- /dev/null +++ b/src/collections/coder/collection.json @@ -0,0 +1,72 @@ +{ + "schema_version": 1, + "id": "coder", + "name": "Coder", + "description": "Linear-synced engineering flow: Feature, Improvement, and Bug work delegated to coding agents.", + "version": "1.0.0", + "publisher": "untra", + "author": "untra", + "url": "https://github.com/untra/operator", + "license": "MIT", + "tags": [ + "community", + "linear", + "kanban", + "engineering" + ], + "tier": "community", + "icon_path": "icon.svg", + "created": "2026-08-01", + "updated": "2026-08-01", + "kanban_defaults": { + "suggested_type_mappings": { + "Feature": "FEATURE", + "Improvement": "IMPROVEMENT", + "Bug": "BUG" + } + }, + "issue_types": [ + { + "key": "FEATURE", + "schema_path": "FEATURE.json", + "template_path": "FEATURE.md" + }, + { + "key": "IMPROVEMENT", + "schema_path": "IMPROVEMENT.json", + "template_path": "IMPROVEMENT.md" + }, + { + "key": "BUG", + "schema_path": "BUG.json", + "template_path": "BUG.md" + } + ], + "workflow_hints": { + "loop_kind": "kanban_synced_single_pass", + "memory_surfaces": [ + "ticket", + ".tickets/plans/{{ id }}.md" + ], + "review_gates": [ + "plan_review", + "test_suite", + "pr_review" + ], + "external_tools": [ + "git", + "gh", + "linear" + ], + "stop_conditions": [ + "tests_green", + "pr_created" + ], + "runner_semantics": "prompt_driven" + }, + "default_selected": [ + "FEATURE", + "IMPROVEMENT", + "BUG" + ] +} diff --git a/src/collections/coder/icon.svg b/src/collections/coder/icon.svg new file mode 100644 index 00000000..5356a1cb --- /dev/null +++ b/src/collections/coder/icon.svg @@ -0,0 +1 @@ +Coder diff --git a/src/collections/dev_kanban/collection.json b/src/collections/dev_kanban/collection.json index 407d6af7..c1145b1d 100644 --- a/src/collections/dev_kanban/collection.json +++ b/src/collections/dev_kanban/collection.json @@ -9,6 +9,9 @@ "url": "https://github.com/untra/operator", "license": "MIT", "tags": ["builtin", "kanban", "dev"], + "icon_path": "icon.svg", + "created": "2026-01-08", + "updated": "2026-06-16", "issue_types": [ { "key": "TASK", "schema_path": "TASK.json", "template_path": "TASK.md" }, { "key": "FEAT", "schema_path": "FEAT.json", "template_path": "FEAT.md" }, diff --git a/src/collections/dev_kanban/icon.svg b/src/collections/dev_kanban/icon.svg new file mode 100644 index 00000000..13516744 --- /dev/null +++ b/src/collections/dev_kanban/icon.svg @@ -0,0 +1 @@ +Dev Kanban diff --git a/src/collections/devops_kanban/collection.json b/src/collections/devops_kanban/collection.json index bbcd3e08..a430f3d8 100644 --- a/src/collections/devops_kanban/collection.json +++ b/src/collections/devops_kanban/collection.json @@ -9,6 +9,9 @@ "url": "https://github.com/untra/operator", "license": "MIT", "tags": ["builtin", "kanban", "devops"], + "icon_path": "icon.svg", + "created": "2026-01-08", + "updated": "2026-06-16", "issue_types": [ { "key": "TASK", "schema_path": "TASK.json", "template_path": "TASK.md" }, { "key": "FEAT", "schema_path": "FEAT.json", "template_path": "FEAT.md" }, diff --git a/src/collections/devops_kanban/icon.svg b/src/collections/devops_kanban/icon.svg new file mode 100644 index 00000000..db59a314 --- /dev/null +++ b/src/collections/devops_kanban/icon.svg @@ -0,0 +1 @@ +DevOps Kanban diff --git a/src/collections/elves_overnight/collection.json b/src/collections/elves_overnight/collection.json index dda70c50..abe5e3e6 100644 --- a/src/collections/elves_overnight/collection.json +++ b/src/collections/elves_overnight/collection.json @@ -8,13 +8,16 @@ "author": "Aigora", "url": "https://github.com/aigorahub/elves", "license": "MIT", - "tier": "community", "tags": [ "agentic-loop", "overnight", "batch", "elves" ], + "tier": "community", + "icon_path": "icon.svg", + "created": "2026-06-16", + "updated": "2026-07-01", "issue_types": [ { "key": "ELVSTAGE", diff --git a/src/collections/elves_overnight/icon.svg b/src/collections/elves_overnight/icon.svg new file mode 100644 index 00000000..0e288457 --- /dev/null +++ b/src/collections/elves_overnight/icon.svg @@ -0,0 +1 @@ +Elves Overnight diff --git a/src/collections/fetch.rs b/src/collections/fetch.rs index fdcb6516..99141ee3 100644 --- a/src/collections/fetch.rs +++ b/src/collections/fetch.rs @@ -100,6 +100,11 @@ pub struct FetchedCollection { pub manifest: CollectionManifest, /// (key, `schema_json`, `template_md`) for each issue type, in manifest order. pub files: Vec<(String, String, Option)>, + /// The collection's SVG icon, if it declared one and the fetch succeeded. + /// + /// Best-effort and unverified by design: the icon is presentational and + /// never executed, so a missing or malformed one must not fail an install. + pub icon_svg: Option, } fn http_client(timeout_secs: u64) -> Result { @@ -212,7 +217,27 @@ pub async fn fetch_collection( files.push((it.key.clone(), schema_json, template_md)); } - Ok(FetchedCollection { manifest, files }) + // 5. The icon, best-effort. Deliberately after verification and outside it: + // a collection that renders without its glyph is still a good install. + let icon_svg = match &manifest.icon_path { + Some(path) => { + let url = resolve_url(&manifest_url, path); + match get_bytes(&client, &url).await { + Ok(bytes) => String::from_utf8(bytes).ok(), + Err(e) => { + tracing::debug!(error = %e, collection = %entry.id, "collection icon fetch failed"); + None + } + } + } + None => None, + }; + + Ok(FetchedCollection { + manifest, + files, + icon_svg, + }) } /// Where a resolved collection's definition came from. @@ -232,6 +257,8 @@ pub struct ResolvedCollection { pub origin: CollectionOrigin, /// Why we fell back to embedded, if applicable (e.g. checksum failure). pub note: Option, + /// The collection's SVG icon, if available (best-effort, unverified). + pub icon_svg: Option, } /// Build a [`FetchedCollection`] from an embedded collection, computing @@ -256,7 +283,11 @@ pub fn embedded_fetched(embedded: &EmbeddedCollection) -> Result { @@ -295,6 +327,7 @@ pub async fn resolve_for_setup( files: fc.files, origin: CollectionOrigin::Embedded, note: Some(e.to_string()), + icon_svg: fc.icon_svg, }); } } @@ -315,6 +348,7 @@ pub async fn resolve_for_setup( files: fc.files, origin: CollectionOrigin::Embedded, note: None, + icon_svg: fc.icon_svg, }); } } @@ -333,7 +367,6 @@ mod tests { schema_checksum: schema_sum.to_string(), template_path: None, template_checksum: None, - workflow_preview_path: None, } } diff --git a/src/collections/full/collection.json b/src/collections/full/collection.json index c9af00bc..398ecaa1 100644 --- a/src/collections/full/collection.json +++ b/src/collections/full/collection.json @@ -9,6 +9,9 @@ "url": "https://github.com/untra/operator", "license": "MIT", "tags": ["builtin"], + "icon_path": "icon.svg", + "created": "2026-05-27", + "updated": "2026-06-16", "issue_types": [ { "key": "TASK", "schema_path": "TASK.json", "template_path": "TASK.md" }, { "key": "FEAT", "schema_path": "FEAT.json", "template_path": "FEAT.md" }, diff --git a/src/collections/full/icon.svg b/src/collections/full/icon.svg new file mode 100644 index 00000000..86889735 --- /dev/null +++ b/src/collections/full/icon.svg @@ -0,0 +1 @@ +Full diff --git a/src/collections/jr_orchestration/collection.json b/src/collections/jr_orchestration/collection.json index 25f22e6d..156c618c 100644 --- a/src/collections/jr_orchestration/collection.json +++ b/src/collections/jr_orchestration/collection.json @@ -8,13 +8,16 @@ "author": "snapwich", "url": "https://github.com/snapwich/jr", "license": "MIT", - "tier": "community", "tags": [ "agentic-loop", "feature-graph", "review", "jr" ], + "tier": "community", + "icon_path": "icon.svg", + "created": "2026-06-16", + "updated": "2026-07-01", "issue_types": [ { "key": "JRPLAN", diff --git a/src/collections/jr_orchestration/icon.svg b/src/collections/jr_orchestration/icon.svg new file mode 100644 index 00000000..2ebdb4bb --- /dev/null +++ b/src/collections/jr_orchestration/icon.svg @@ -0,0 +1 @@ +JR Orchestration diff --git a/src/collections/manifest.rs b/src/collections/manifest.rs index b4e57bb1..ec9a1e80 100644 --- a/src/collections/manifest.rs +++ b/src/collections/manifest.rs @@ -11,7 +11,7 @@ //! embedded in the binary. use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::BTreeMap; /// Current manifest schema version. Manifests with an unknown version are /// rejected by the fetcher and fall back to the embedded copy. @@ -114,6 +114,16 @@ pub struct CollectionManifest { /// Provenance tier (defaults to official for older manifests). #[serde(default)] pub tier: CollectionTier, + /// Bare filename of the collection's Simple Icons-shaped SVG, next to the + /// manifest. Deliberately not checksummed; icon must pass standards. + #[serde(default)] + pub icon_path: Option, + /// ISO-8601 date (`YYYY-MM-DD`) the collection was first published. + #[serde(default)] + pub created: Option, + /// ISO-8601 date (`YYYY-MM-DD`) of the last substantive revision. + #[serde(default)] + pub updated: Option, /// Descriptive kanban onboarding defaults (v1: metadata only). #[serde(default)] pub kanban_defaults: Option, @@ -157,11 +167,6 @@ pub struct IssueTypeEntry { /// SHA-256 (lowercase hex) of the markdown template bytes, if present. #[serde(default)] pub template_checksum: Option, - /// Path to a pre-generated workflow preview (`.js`), relative to the - /// manifest. Filled by the docs producer for visualization; excluded - /// from checksum derivation. - #[serde(default)] - pub workflow_preview_path: Option, } /// Descriptive kanban onboarding defaults for a collection. @@ -171,8 +176,10 @@ pub struct IssueTypeEntry { #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct KanbanDefaults { /// Suggested provider issue-type NAME -> collection issuetype key. + /// `BTreeMap` keeps serialization order deterministic so the hosted + /// bundle's manifest checksum is stable across generation runs. #[serde(default)] - pub suggested_type_mappings: HashMap, + pub suggested_type_mappings: BTreeMap, } /// Descriptive metadata about a collection's intended agentic loop shape. @@ -390,26 +397,141 @@ mod tests { } #[test] - fn test_issue_type_entry_workflow_preview_path_defaults_to_none() { + fn test_manifest_icon_and_dates_default_to_none() { + // Older manifests without icon/date fields stay valid. let m = CollectionManifest::from_json(DEV_KANBAN_JSON).unwrap(); - assert!(m.issue_types[0].workflow_preview_path.is_none()); + assert!(m.icon_path.is_none()); + assert!(m.created.is_none()); + assert!(m.updated.is_none()); } #[test] - fn test_issue_type_entry_workflow_preview_path_round_trip() { + fn test_manifest_icon_and_dates_round_trip() { let json = r#"{ - "key": "TASK", - "schema_path": "TASK.json", - "workflow_preview_path": "TASK.preview.workflow.js" + "schema_version": 1, + "id": "ralph_loop", + "name": "Ralph Loop", + "icon_path": "icon.svg", + "created": "2026-05-01", + "updated": "2026-08-01", + "issue_types": [ + {"key": "PRD", "schema_path": "PRD.json"} + ] }"#; - let entry: IssueTypeEntry = serde_json::from_str(json).unwrap(); - assert_eq!( - entry.workflow_preview_path.as_deref(), - Some("TASK.preview.workflow.js") - ); - let round: IssueTypeEntry = - serde_json::from_str(&serde_json::to_string(&entry).unwrap()).unwrap(); - assert_eq!(round.workflow_preview_path, entry.workflow_preview_path); + let m = CollectionManifest::from_json(json).unwrap(); + assert_eq!(m.icon_path.as_deref(), Some("icon.svg")); + assert_eq!(m.created.as_deref(), Some("2026-05-01")); + assert_eq!(m.updated.as_deref(), Some("2026-08-01")); + let m2 = CollectionManifest::from_json(&m.to_json().unwrap()).unwrap(); + assert_eq!(m2.icon_path, m.icon_path); + assert_eq!(m2.created, m.created); + assert_eq!(m2.updated, m.updated); + } + + #[test] + fn test_icon_and_dates_are_outside_the_manifest_checksum() { + // The checksum derives from issue-type file checksums only, so changing + // presentational metadata must not invalidate a hosted collection. + let mut m = CollectionManifest::from_json(DEV_KANBAN_JSON).unwrap(); + let before = crate::collections::fetch::derive_manifest_checksum(&m.issue_types); + + m.icon_path = Some("icon.svg".to_string()); + m.created = Some("2026-05-01".to_string()); + m.updated = Some("2026-08-01".to_string()); + let after = crate::collections::fetch::derive_manifest_checksum(&m.issue_types); + + assert_eq!(before, after); + } + + /// Every field of a fully-populated manifest, so the parity tests below + /// compare complete key sets rather than whatever `DEV_KANBAN_JSON` happens + /// to set. + fn fully_populated() -> CollectionManifest { + let mut m = CollectionManifest::from_json(DEV_KANBAN_JSON).unwrap(); + m.author = Some("Operator!".to_string()); + m.url = Some("https://github.com/untra/operator".to_string()); + m.license = Some("MIT".to_string()); + m.compatibility = Some(Compatibility { + operator_version: Some(">=0.2.0".to_string()), + }); + m.icon_path = Some("icon.svg".to_string()); + m.created = Some("2026-01-15".to_string()); + m.updated = Some("2026-08-01".to_string()); + m.kanban_defaults = Some(KanbanDefaults::default()); + m.checksum = Some("deadbeef".to_string()); + m + } + + fn schema_properties(pointer: &str) -> serde_json::Map { + let schema: serde_json::Value = + serde_json::from_str(include_str!("../schemas/issuetype_collection_schema.json")) + .expect("schema parses"); + schema + .pointer(pointer) + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_else(|| panic!("schema has no properties at {pointer}")) + } + + /// The published schema sets `additionalProperties: false`, so a Rust-side + /// field addition that is not mirrored there silently breaks validation of + /// every hosted manifest. Compare the key sets in both directions. + #[test] + fn test_manifest_fields_match_published_json_schema() { + let serialized = serde_json::to_value(fully_populated()).unwrap(); + let rust_keys: Vec<&str> = serialized + .as_object() + .unwrap() + .keys() + .map(|k| &**k) + .collect(); + let schema_props = schema_properties("/properties"); + + for key in &rust_keys { + assert!( + schema_props.contains_key(*key), + "CollectionManifest field '{key}' is missing from \ + src/schemas/issuetype_collection_schema.json" + ); + } + for key in schema_props.keys() { + assert!( + rust_keys.contains(&&**key), + "schema property '{key}' has no CollectionManifest field" + ); + } + } + + #[test] + fn test_issue_type_entry_fields_match_published_json_schema() { + let entry = IssueTypeEntry { + key: "TASK".to_string(), + schema_path: "TASK.json".to_string(), + schema_checksum: "aaa".to_string(), + template_path: Some("TASK.md".to_string()), + template_checksum: Some("bbb".to_string()), + }; + let serialized = serde_json::to_value(&entry).unwrap(); + let rust_keys: Vec<&str> = serialized + .as_object() + .unwrap() + .keys() + .map(|k| &**k) + .collect(); + let schema_props = schema_properties("/properties/issue_types/items/properties"); + + for key in &rust_keys { + assert!( + schema_props.contains_key(*key), + "IssueTypeEntry field '{key}' is missing from the published schema" + ); + } + for key in schema_props.keys() { + assert!( + rust_keys.contains(&&**key), + "schema property '{key}' has no IssueTypeEntry field" + ); + } } #[test] diff --git a/src/collections/mod.rs b/src/collections/mod.rs index 69ee0e47..21ae2548 100644 --- a/src/collections/mod.rs +++ b/src/collections/mod.rs @@ -20,11 +20,14 @@ pub struct EmbeddedIssueType { pub template_md: &'static str, } -/// An embedded collection with manifest and issuetypes +/// An embedded collection with manifest, icon, and issuetypes #[derive(Debug, Clone)] pub struct EmbeddedCollection { pub name: &'static str, pub manifest: &'static str, + /// Simple Icons-shaped SVG identifying the collection, matching the + /// manifest's `icon_path`. Shape is enforced by `tests/collection_icons.rs`. + pub icon_svg: &'static str, pub issuetypes: &'static [EmbeddedIssueType], } @@ -41,6 +44,7 @@ pub static EMBEDDED_COLLECTIONS: &[EmbeddedCollection] = &[ EmbeddedCollection { name: "simple", manifest: include_str!("simple/collection.json"), + icon_svg: include_str!("simple/icon.svg"), issuetypes: &[EmbeddedIssueType { key: "TASK", schema_json: include_str!("simple/TASK.json"), @@ -51,6 +55,7 @@ pub static EMBEDDED_COLLECTIONS: &[EmbeddedCollection] = &[ EmbeddedCollection { name: "dev_kanban", manifest: include_str!("dev_kanban/collection.json"), + icon_svg: include_str!("dev_kanban/icon.svg"), issuetypes: &[ EmbeddedIssueType { key: "TASK", @@ -73,6 +78,7 @@ pub static EMBEDDED_COLLECTIONS: &[EmbeddedCollection] = &[ EmbeddedCollection { name: "devops_kanban", manifest: include_str!("devops_kanban/collection.json"), + icon_svg: include_str!("devops_kanban/icon.svg"), issuetypes: &[ EmbeddedIssueType { key: "TASK", @@ -105,6 +111,7 @@ pub static EMBEDDED_COLLECTIONS: &[EmbeddedCollection] = &[ EmbeddedCollection { name: "operator", manifest: include_str!("operator/collection.json"), + icon_svg: include_str!("operator/icon.svg"), issuetypes: &[ EmbeddedIssueType { key: "ASSESS", @@ -140,6 +147,7 @@ pub static EMBEDDED_COLLECTIONS: &[EmbeddedCollection] = &[ EmbeddedCollection { name: "ralph_loop", manifest: include_str!("ralph_loop/collection.json"), + icon_svg: include_str!("ralph_loop/icon.svg"), issuetypes: &[ EmbeddedIssueType { key: "PRD", @@ -162,6 +170,7 @@ pub static EMBEDDED_COLLECTIONS: &[EmbeddedCollection] = &[ EmbeddedCollection { name: "jr_orchestration", manifest: include_str!("jr_orchestration/collection.json"), + icon_svg: include_str!("jr_orchestration/icon.svg"), issuetypes: &[ EmbeddedIssueType { key: "JRPLAN", @@ -194,6 +203,7 @@ pub static EMBEDDED_COLLECTIONS: &[EmbeddedCollection] = &[ EmbeddedCollection { name: "elves_overnight", manifest: include_str!("elves_overnight/collection.json"), + icon_svg: include_str!("elves_overnight/icon.svg"), issuetypes: &[ EmbeddedIssueType { key: "ELVSTAGE", @@ -217,6 +227,29 @@ pub static EMBEDDED_COLLECTIONS: &[EmbeddedCollection] = &[ }, ], }, + // Coder collection: Linear-synced FEATURE, IMPROVEMENT, BUG + EmbeddedCollection { + name: "coder", + manifest: include_str!("coder/collection.json"), + icon_svg: include_str!("coder/icon.svg"), + issuetypes: &[ + EmbeddedIssueType { + key: "FEATURE", + schema_json: include_str!("coder/FEATURE.json"), + template_md: include_str!("coder/FEATURE.md"), + }, + EmbeddedIssueType { + key: "IMPROVEMENT", + schema_json: include_str!("coder/IMPROVEMENT.json"), + template_md: include_str!("coder/IMPROVEMENT.md"), + }, + EmbeddedIssueType { + key: "BUG", + schema_json: include_str!("coder/BUG.json"), + template_md: include_str!("coder/BUG.md"), + }, + ], + }, ]; /// Embedded schema files for issue types that need structured output @@ -270,7 +303,7 @@ mod tests { #[test] fn test_embedded_collections_count() { - assert_eq!(EMBEDDED_COLLECTIONS.len(), 7); + assert_eq!(EMBEDDED_COLLECTIONS.len(), 8); } #[test] @@ -286,7 +319,9 @@ mod tests { for collection in EMBEDDED_COLLECTIONS { let m = collection.manifest_parsed().unwrap(); let expected = match collection.name { - "ralph_loop" | "jr_orchestration" | "elves_overnight" => CollectionTier::Community, + "ralph_loop" | "jr_orchestration" | "elves_overnight" | "coder" => { + CollectionTier::Community + } _ => CollectionTier::Official, }; assert_eq!(m.tier, expected, "tier mismatch for {}", collection.name); @@ -347,6 +382,10 @@ mod tests { let elves = get_embedded_collection("elves_overnight").unwrap(); assert_eq!(elves.name, "elves_overnight"); assert_eq!(elves.issuetypes.len(), 4); + + let coder = get_embedded_collection("coder").unwrap(); + assert_eq!(coder.name, "coder"); + assert_eq!(coder.issuetypes.len(), 3); } #[test] diff --git a/src/collections/operator/collection.json b/src/collections/operator/collection.json index cb46bacf..01be66e0 100644 --- a/src/collections/operator/collection.json +++ b/src/collections/operator/collection.json @@ -12,6 +12,9 @@ "builtin", "automation" ], + "icon_path": "icon.svg", + "created": "2026-01-08", + "updated": "2026-07-01", "issue_types": [ { "key": "ASSESS", diff --git a/src/collections/operator/icon.svg b/src/collections/operator/icon.svg new file mode 100644 index 00000000..7ce0d33f --- /dev/null +++ b/src/collections/operator/icon.svg @@ -0,0 +1 @@ +Operator diff --git a/src/collections/ralph_loop/collection.json b/src/collections/ralph_loop/collection.json index 8f951ba7..65519ea7 100644 --- a/src/collections/ralph_loop/collection.json +++ b/src/collections/ralph_loop/collection.json @@ -8,13 +8,16 @@ "author": "snarktank", "url": "https://github.com/snarktank/ralph", "license": "MIT", - "tier": "community", "tags": [ "agentic-loop", "prd", "stories", "ralph" ], + "tier": "community", + "icon_path": "icon.svg", + "created": "2026-06-16", + "updated": "2026-07-01", "issue_types": [ { "key": "PRD", diff --git a/src/collections/ralph_loop/icon.svg b/src/collections/ralph_loop/icon.svg new file mode 100644 index 00000000..55e80009 --- /dev/null +++ b/src/collections/ralph_loop/icon.svg @@ -0,0 +1 @@ +Ralph Loop diff --git a/src/collections/simple/collection.json b/src/collections/simple/collection.json index dfa5151a..15819539 100644 --- a/src/collections/simple/collection.json +++ b/src/collections/simple/collection.json @@ -11,6 +11,9 @@ "tags": [ "builtin" ], + "icon_path": "icon.svg", + "created": "2026-01-08", + "updated": "2026-07-01", "issue_types": [ { "key": "TASK", diff --git a/src/collections/simple/icon.svg b/src/collections/simple/icon.svg new file mode 100644 index 00000000..35bac239 --- /dev/null +++ b/src/collections/simple/icon.svg @@ -0,0 +1 @@ +Simple diff --git a/src/collections/validate.rs b/src/collections/validate.rs index e32f538a..aa2bde2a 100644 --- a/src/collections/validate.rs +++ b/src/collections/validate.rs @@ -26,6 +26,12 @@ fn key_regex() -> &'static Regex { RE.get_or_init(|| Regex::new(r"^[A-Z][A-Z0-9_]{1,15}$").expect("valid regex")) } +/// Publication dates: ISO-8601 calendar dates, `YYYY-MM-DD`. +fn date_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"^\d{4}-\d{2}-\d{2}$").expect("valid regex")) +} + /// Validate a manifest against the shareable-collection rules. /// /// `dir_name` is the name of the directory holding `collection.json`; the @@ -77,8 +83,27 @@ pub fn validate_manifest(manifest: &CollectionManifest, dir_name: &str) -> Resul if let Some(template) = &entry.template_path { validate_path(template)?; } - if let Some(preview) = &entry.workflow_preview_path { - validate_path(preview)?; + } + + if let Some(icon) = &manifest.icon_path { + validate_path(icon)?; + // Extensions are matched exactly: hosted paths are served verbatim, so + // `icon.SVG` would 404 against the manifest's own reference. + if std::path::Path::new(icon) + .extension() + .is_none_or(|e| e != "svg") + { + bail!("icon_path '{icon}' must be an .svg file"); + } + } + for (value, field) in [ + (&manifest.created, "created"), + (&manifest.updated, "updated"), + ] { + if let Some(date) = value { + if !date_regex().is_match(date) { + bail!("invalid {field} date '{date}': must be YYYY-MM-DD"); + } } } @@ -87,6 +112,9 @@ pub fn validate_manifest(manifest: &CollectionManifest, dir_name: &str) -> Resul (&manifest.license, "license"), (&manifest.author, "author"), (&manifest.url, "url"), + // Every hosted card needs an icon; community submissions are + // hosted-only, so this is where the requirement bites. + (&manifest.icon_path, "icon_path"), ] { if value.as_deref().is_none_or(|v| v.trim().is_empty()) { bail!("community collections require a {field}"); @@ -178,6 +206,9 @@ mod tests { "author": "someone", "url": "https://example.com/repo", "license": "MIT", + "icon_path": "icon.svg", + "created": "2026-01-15", + "updated": "2026-08-01", "issue_types": [ {{"key": "TASK", "schema_path": "TASK.json", "template_path": "TASK.md"}} ] @@ -295,12 +326,13 @@ mod tests { } #[test] - fn test_community_requires_license_author_url() { - for field in ["license", "author", "url"] { + fn test_community_requires_license_author_url_and_icon() { + for field in ["license", "author", "url", "icon_path"] { let mut m = parse(&manifest_json("example_loop")); match field { "license" => m.license = None, "author" => m.author = Some(" ".to_string()), + "icon_path" => m.icon_path = None, _ => m.url = None, } let err = validate_manifest(&m, "example_loop").unwrap_err(); @@ -319,6 +351,53 @@ mod tests { m.license = None; m.author = None; m.url = None; + m.icon_path = None; + assert!(validate_manifest(&m, "example_loop").is_ok()); + } + + #[test] + fn test_unsafe_icon_path_rejected() { + for bad in ["../icon.svg", "/etc/icon.svg", "sub/icon.svg", ".icon.svg"] { + let mut m = parse(&manifest_json("example_loop")); + m.icon_path = Some(bad.to_string()); + let err = validate_manifest(&m, "example_loop").unwrap_err(); + assert!( + err.to_string().contains("unsafe path"), + "icon path '{bad}' should be rejected, got: {err}" + ); + } + } + + #[test] + fn test_non_svg_icon_path_rejected() { + let mut m = parse(&manifest_json("example_loop")); + m.icon_path = Some("icon.png".to_string()); + let err = validate_manifest(&m, "example_loop").unwrap_err(); + assert!(err.to_string().contains("must be an .svg file")); + } + + #[test] + fn test_malformed_dates_rejected() { + for (field, bad) in [("created", "2026-1-5"), ("updated", "01/15/2026")] { + let mut m = parse(&manifest_json("example_loop")); + match field { + "created" => m.created = Some(bad.to_string()), + _ => m.updated = Some(bad.to_string()), + } + let err = validate_manifest(&m, "example_loop").unwrap_err(); + assert!( + err.to_string() + .contains(&format!("invalid {field} date '{bad}'")), + "date '{bad}' should be rejected, got: {err}" + ); + } + } + + #[test] + fn test_dates_are_optional() { + let mut m = parse(&manifest_json("example_loop")); + m.created = None; + m.updated = None; assert!(validate_manifest(&m, "example_loop").is_ok()); } diff --git a/src/docs_gen/collections_manifest.rs b/src/docs_gen/collections_manifest.rs index 5ffd03e9..f17ef3af 100644 --- a/src/docs_gen/collections_manifest.rs +++ b/src/docs_gen/collections_manifest.rs @@ -7,24 +7,38 @@ //! ├── index.json (CollectionIndex of all collections) //! └── / //! ├── collection.json (CollectionManifest with checksums) +//! ├── icon.svg (Simple Icons-shaped collection glyph) //! ├── .json (issuetype schema, byte-identical to embedded) //! └── .md (issuetype template) -//! ``` //! -//! The per-issuetype files are written byte-for-byte from the embedded -//! collections, and their SHA-256 checksums are computed and recorded in the -//! manifest. The runtime fetcher verifies these checksums, so the hosted bundle -//! is guaranteed identical to the offline fallback. +//! Two sources feed the bundle: +//! +//! * **Embedded** collections (`src/collections/`) are compiled into the binary +//! and republished here byte-for-byte, so the hosted copy is guaranteed +//! identical to the offline fallback. +//! * **Community** collections (`collections/community/`) are hosted-only. They +//! are validated during generation, so a broken submission fails a PR's CI +//! rather than a user's install. +//! +//! Per-issuetype SHA-256 checksums are computed and recorded in each manifest; +//! the runtime fetcher verifies them before trusting any fetched bytes. Icons +//! are deliberately excluded: they are presentational and never executed, and a +//! malformed one must not be able to fail an install. +//! +//! No workflow previews are emitted. The graph renders from `.json` — the +//! native Operator workflow that is already published and already checksummed — +//! so there is nothing per-workflow to pre-generate. -use std::path::Path; +use std::path::{Path, PathBuf}; -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, Context, Result}; use super::DocGenerator; use crate::collections::fetch::{derive_manifest_checksum, sha256_hex}; use crate::collections::manifest::{ CollectionIndex, CollectionIndexEntry, CollectionManifest, SCHEMA_VERSION, }; +use crate::collections::validate::validate_collection_dir; use crate::collections::{EmbeddedCollection, EMBEDDED_COLLECTIONS}; /// Generates the hosted collection bundle under `docs/collections/`. @@ -33,58 +47,150 @@ pub struct CollectionsManifestGenerator; /// A fully-resolved hosted manifest plus the byte payloads it references. struct HostedCollection { manifest: CollectionManifest, - /// (relative path, bytes) for each issuetype schema/template file. + /// (relative path, bytes) for each issuetype schema/template file, plus the + /// collection icon when the manifest declares one. files: Vec<(String, Vec)>, } -/// Build a hosted manifest for `embedded`: copy metadata from the embedded -/// `collection.json`, fill in per-file checksums from the embedded bytes, and -/// derive the manifest-level checksum. -fn build_hosted(embedded: &EmbeddedCollection) -> Result { +/// Where community submissions live, relative to the repo root. +/// +/// Resolved from the crate directory rather than the process CWD: this is a +/// repo-maintenance generator, and a missing directory is a no-op rather than +/// an error so the bundle still builds outside a checkout. +fn community_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("collections/community") +} + +/// The docs-site path a collection's page is served from. +fn docs_path_for(id: &str) -> String { + format!("/workflows/{id}/") +} + +/// Fill in per-file checksums and the derived manifest checksum, given a way to +/// resolve each referenced file's bytes. +/// +/// Shared by the embedded and community paths so both produce byte-identical +/// manifest structure and identical checksum derivation. +fn finalize(manifest: &mut CollectionManifest, mut read: F) -> Result)>> +where + F: FnMut(&str) -> Result>, +{ + let mut files = Vec::new(); + + for entry in &mut manifest.issue_types { + let schema_bytes = read(&entry.schema_path)?; + entry.schema_checksum = sha256_hex(&schema_bytes); + files.push((entry.schema_path.clone(), schema_bytes)); + + if let Some(template_path) = entry.template_path.clone() { + let md_bytes = read(&template_path)?; + entry.template_checksum = Some(sha256_hex(&md_bytes)); + files.push((template_path, md_bytes)); + } + } + + // Icon last, and outside the checksum derivation below. + if let Some(icon_path) = manifest.icon_path.clone() { + let icon_bytes = read(&icon_path)?; + files.push((icon_path, icon_bytes)); + } + + manifest.checksum = Some(derive_manifest_checksum(&manifest.issue_types)); + Ok(files) +} + +/// Build a hosted manifest for an embedded collection, resolving files from the +/// bytes compiled into the binary. +fn build_embedded(embedded: &EmbeddedCollection) -> Result { let mut manifest = embedded .manifest_parsed() .map_err(|e| anyhow!("parsing embedded manifest for {}: {e}", embedded.name))?; - let mut files = Vec::new(); + let icon_path = manifest.icon_path.clone(); - for entry in &mut manifest.issue_types { + let files = finalize(&mut manifest, |path| { + if Some(path) == icon_path.as_deref() { + return Ok(embedded.icon_svg.as_bytes().to_vec()); + } + let key = path.rsplit_once('.').map_or(path, |(stem, _)| stem); let it = embedded .issuetypes .iter() - .find(|it| it.key == entry.key) + .find(|it| it.key == key) .ok_or_else(|| { anyhow!( - "collection {} manifest references {} but no embedded file exists", - embedded.name, - entry.key + "collection {} manifest references {path} but no embedded file exists", + embedded.name ) })?; + // Manifest paths are exact filenames, so an exact extension match is + // what we want: `TASK.MD` is a different reference, not the template. + let is_template = std::path::Path::new(path) + .extension() + .is_some_and(|ext| ext == "md"); + Ok(if is_template { + it.template_md.as_bytes().to_vec() + } else { + it.schema_json.as_bytes().to_vec() + }) + })?; - let schema_bytes = it.schema_json.as_bytes().to_vec(); - entry.schema_checksum = sha256_hex(&schema_bytes); - files.push((entry.schema_path.clone(), schema_bytes)); + Ok(HostedCollection { manifest, files }) +} - if let Some(template_path) = entry.template_path.clone() { - let md_bytes = it.template_md.as_bytes().to_vec(); - entry.template_checksum = Some(sha256_hex(&md_bytes)); - files.push((template_path, md_bytes)); - } - } +/// Build a hosted manifest for a community collection, resolving files from +/// disk. The directory is validated first, so an invalid submission fails +/// generation (and therefore CI) rather than shipping. +fn build_community(dir: &Path) -> Result { + let mut manifest = validate_collection_dir(dir) + .with_context(|| format!("invalid community collection at {}", dir.display()))?; + + let files = finalize(&mut manifest, |path| { + std::fs::read(dir.join(path)) + .with_context(|| format!("reading {path} in {}", dir.display())) + })?; - manifest.checksum = Some(derive_manifest_checksum(&manifest.issue_types)); Ok(HostedCollection { manifest, files }) } +/// Every community collection directory, sorted by id so generation is +/// deterministic. A missing `collections/community/` yields an empty list. +fn community_collections() -> Result> { + let base = community_dir(); + let Ok(entries) = std::fs::read_dir(&base) else { + return Ok(Vec::new()); + }; + + let mut dirs: Vec = entries + .flatten() + .map(|e| e.path()) + .filter(|p| p.join("collection.json").is_file()) + .collect(); + dirs.sort(); + + dirs.iter().map(|dir| build_community(dir)).collect() +} + /// Serialize a hosted manifest to its canonical on-disk form (pretty JSON, /// trailing newline). fn manifest_json(manifest: &CollectionManifest) -> Result { Ok(format!("{}\n", manifest.to_json()?)) } -/// Build the top-level index over all embedded collections. +/// Every collection to publish: embedded first (in `EMBEDDED_COLLECTIONS` +/// order), then community (sorted by id). +fn all_hosted() -> Result> { + let mut hosted: Vec = EMBEDDED_COLLECTIONS + .iter() + .map(build_embedded) + .collect::>()?; + hosted.extend(community_collections()?); + Ok(hosted) +} + +/// Build the top-level index over every published collection. fn build_index() -> Result { let mut collections = Vec::new(); - for embedded in EMBEDDED_COLLECTIONS { - let hosted = build_hosted(embedded)?; + for hosted in all_hosted()? { let json = manifest_json(&hosted.manifest)?; collections.push(CollectionIndexEntry { id: hosted.manifest.id.clone(), @@ -95,8 +201,7 @@ fn build_index() -> Result { manifest_path: format!("{}/collection.json", hosted.manifest.id), checksum: sha256_hex(json.as_bytes()), tier: hosted.manifest.tier, - // Filled once per-collection docs pages are generated. - docs_path: None, + docs_path: Some(docs_path_for(&hosted.manifest.id)), }); } Ok(CollectionIndex { @@ -113,7 +218,7 @@ impl DocGenerator for CollectionsManifestGenerator { } fn source(&self) -> &'static str { - "src/collections/*/collection.json (EMBEDDED_COLLECTIONS)" + "src/collections/*/collection.json + collections/community/*/collection.json" } fn output_path(&self) -> &'static str { @@ -129,8 +234,7 @@ impl DocGenerator for CollectionsManifestGenerator { let collections_dir = docs_dir.join("collections"); // Per-collection bundles. - for embedded in EMBEDDED_COLLECTIONS { - let hosted = build_hosted(embedded)?; + for hosted in all_hosted()? { let dir = collections_dir.join(&hosted.manifest.id); std::fs::create_dir_all(&dir)?; std::fs::write( @@ -163,6 +267,7 @@ impl DocGenerator for CollectionsManifestGenerator { mod tests { use super::*; use crate::collections::get_embedded_collection; + use crate::collections::manifest::CollectionTier; #[test] fn test_index_lists_all_embedded_collections() { @@ -174,10 +279,60 @@ mod tests { assert_eq!(index.schema_version, SCHEMA_VERSION); } + #[test] + fn test_index_publishes_community_collections() { + let index = build_index().unwrap(); + let community: Vec<&str> = index + .collections + .iter() + .filter(|c| c.tier == CollectionTier::Community && c.id == "example_chores") + .map(|c| c.id.as_str()) + .collect(); + assert_eq!( + community, + vec!["example_chores"], + "collections/community/ must reach the published index" + ); + } + + #[test] + fn test_index_orders_embedded_before_community_and_sorts_community() { + let index = build_index().unwrap(); + let embedded_count = EMBEDDED_COLLECTIONS.len(); + let (embedded, community) = index.collections.split_at(embedded_count); + + let embedded_ids: Vec<&str> = embedded.iter().map(|c| c.id.as_str()).collect(); + assert_eq!( + embedded_ids, + crate::collections::embedded_collection_names(), + "embedded collections must keep EMBEDDED_COLLECTIONS order" + ); + + let community_ids: Vec<&str> = community.iter().map(|c| c.id.as_str()).collect(); + let mut sorted = community_ids.clone(); + sorted.sort_unstable(); + assert_eq!( + community_ids, sorted, + "community entries must be sorted by id" + ); + } + + #[test] + fn test_every_index_entry_links_to_its_docs_page() { + for entry in build_index().unwrap().collections { + assert_eq!( + entry.docs_path.as_deref(), + Some(format!("/workflows/{}/", entry.id).as_str()), + "{} must deep-link to its docs page", + entry.id + ); + } + } + #[test] fn test_hosted_files_are_byte_identical_to_embedded() { let embedded = get_embedded_collection("dev_kanban").unwrap(); - let hosted = build_hosted(embedded).unwrap(); + let hosted = build_embedded(embedded).unwrap(); for entry in &hosted.manifest.issue_types { let it = embedded .issuetypes @@ -196,12 +351,45 @@ mod tests { } } + #[test] + fn test_every_collection_publishes_its_icon() { + for hosted in all_hosted().unwrap() { + let icon_path = hosted + .manifest + .icon_path + .clone() + .unwrap_or_else(|| panic!("{} declares no icon_path", hosted.manifest.id)); + let (_, bytes) = hosted + .files + .iter() + .find(|(p, _)| p == &icon_path) + .unwrap_or_else(|| panic!("{} did not publish {icon_path}", hosted.manifest.id)); + let svg = std::str::from_utf8(bytes).unwrap(); + assert!( + svg.contains(r#"viewBox="0 0 24 24""#), + "{} published a non-Simple-Icons icon", + hosted.manifest.id + ); + } + } + + #[test] + fn test_icons_are_excluded_from_the_manifest_checksum() { + // Icons are presentational and unverified; the derived checksum must + // cover only the issue-type files the fetcher validates. + let embedded = get_embedded_collection("ralph_loop").unwrap(); + let hosted = build_embedded(embedded).unwrap(); + assert_eq!( + hosted.manifest.checksum.as_deref(), + Some(derive_manifest_checksum(&hosted.manifest.issue_types).as_str()) + ); + } + #[test] fn test_manifest_checksum_matches_verifier_derivation() { // The producer's manifest.checksum must equal the value the runtime // verifier derives from the same entries. - for embedded in EMBEDDED_COLLECTIONS { - let hosted = build_hosted(embedded).unwrap(); + for hosted in all_hosted().unwrap() { let derived = derive_manifest_checksum(&hosted.manifest.issue_types); assert_eq!(hosted.manifest.checksum.as_deref(), Some(derived.as_str())); } @@ -218,4 +406,10 @@ mod tests { assert_eq!(entry.checksum.len(), 64); } } + + #[test] + fn test_generation_is_deterministic() { + let generator = CollectionsManifestGenerator; + assert_eq!(generator.generate().unwrap(), generator.generate().unwrap()); + } } diff --git a/src/docs_gen/collections_pages.rs b/src/docs_gen/collections_pages.rs new file mode 100644 index 00000000..0859fdf8 --- /dev/null +++ b/src/docs_gen/collections_pages.rs @@ -0,0 +1,459 @@ +//! Workflow catalog page generator. +//! +//! Emits the browsable face of the hosted collection bundle: +//! +//! ```text +//! docs/workflows/ +//! ├── index.md the catalog: vocabulary, cards, table, contributor CTA +//! └── /index.md one collection: metadata + the split-view explorer +//! ``` +//! +//! Cards and table rows are rendered **here**, at generation time, each carrying +//! a `data-search` haystack. `` then filters the DOM +//! that already exists, so the catalog renders in full with `JavaScript` +//! disabled +//! and the search never depends on a fetch. +//! +//! The graph is the one exception: `` loads the +//! collection's `.json` at runtime and draws it with the same component the +//! SPA uses. Nothing per-workflow is pre-rendered. + +use anyhow::Result; +use std::path::Path; + +use super::{format_header, DocGenerator}; +use crate::docs_gen::collections_search::{build_catalog, CatalogEntry}; + +/// The vocabulary preamble. "Workflow" is overloaded across the ecosystem, so +/// the catalog opens by saying exactly what each term means here. +const VOCABULARY: &str = r" +An **Operator workflow** is a process defined once in JSON: an ordered graph of +typed steps, review gates, and retry edges that an LLM agent can follow. It is +the native format — Operator runs it directly, and every +[export format](/getting-started/workflows/) (Claude, AGNT) is derived from it. + +Three terms, three different things: + +| Term | What it is | +|------|-----------| +| **Operator workflow** | The step graph itself. Lives in an issue type's `steps`. | +| **Issue type** | One kind of work — `FEAT`, `PRD`, `ELVSTAGE`. Carries identity, input fields, and exactly one Operator workflow. | +| **Collection** | A named, versioned bundle of issue types: a complete, shareable way of working. This page lists them. | + +Collections are deliberately separate from your **kanban issue types**. Jira, +Linear, and GitHub Projects types describe how *your* team labels work; a +collection describes how the *agents* do it. Map one onto the other once, and +the workflow travels between projects, teams, and providers unchanged. +"; + +/// The contributor call to action, mirroring `collections/README.md`. +const CONTRIBUTING: &str = r#" +## Contribute a collection + +There is no single best way to run agents — the right loop depends on the work. +That is exactly why these are shareable: a workflow that works for you is worth +publishing, and one that does not fit is worth forking. + +Official collections live in the [operator repository](https://github.com/untra/operator/tree/main/collections): + +1. Create `collections/community//`, where `` matches `^[a-z0-9_]{3,64}$`. +2. Add a `collection.json` conforming to [the collection schema](/collections/schema.json), + with `tier: "community"` plus `author`, `url`, and `license`. +3. Add one `.json` per issue type — see [the issue type schema](/schemas/issuetype/) — + and an optional `.md` ticket template. +4. Add an `icon.svg` following the + [Simple Icons](https://github.com/simple-icons/simple-icons) shape: a 24×24 + viewBox, a single ``, and no `fill` or `stroke` so it inherits the + page's color. +5. Leave checksums out — they are computed at publish time. +6. Run the CI gate locally, then open a pull request: + +```bash +cargo test --test community_collections +``` + +Submissions are reviewed for prompt quality and safety, not just schema +validity. A good collection describes a workflow shape worth sharing: what loop +it runs, what memory it keeps, what gates it enforces, and when it stops. +"#; + +/// Generates `docs/workflows/index.md` and the per-collection pages. +pub struct CollectionsPagesGenerator; + +/// Escape text destined for an HTML attribute or text node. +fn escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +/// `1 issue type` / `3 issue types`. +fn issue_type_count(count: usize) -> String { + if count == 1 { + "1 issue type".to_string() + } else { + format!("{count} issue types") + } +} + +/// The provenance badge shown on a card and in the table. +fn tier_badge(tier: &str) -> String { + let class = if tier == "community" { + "badge alpha" + } else { + "badge recommended" + }; + format!(r#"{tier}"#) +} + +/// The collection's icon, inlined into the page. +/// +/// Inlined rather than linked with an ``: an `` loads the SVG as a +/// separate document where `currentColor` cannot resolve, so a linked icon +/// would stay black and vanish against the dark theme. Inline, it tints from +/// the surrounding text color. Marked `aria-hidden` because the collection name +/// sits right beside it. +/// +/// The markup is the same file published at `/collections//icon.svg`, and +/// `tests/collection_icons.rs` constrains it to a single `` with no +/// scripting, so inlining introduces nothing the bundle does not already serve. +fn icon_svg(entry: &CatalogEntry) -> String { + let Some(svg) = crate::docs_gen::collections_search::icon_svg_for(&entry.id) else { + return String::new(); + }; + svg.trim().replacen( + "{}", escape(&it.key))) + .collect::>() + .join(" "); + + format!( + r#"
+ + {icon} +

{name}

+
+

{description}

+

{types}

+

+ {badge} + {count} + {author} + updated {updated} +

+
+"#, + search = escape(&entry.search_text), + docs_path = escape(&entry.docs_path), + icon = icon_svg(entry), + name = escape(&entry.name), + description = escape(&entry.description), + types = types, + badge = tier_badge(&entry.tier), + count = issue_type_count(entry.issue_type_count), + author = escape(entry.author.as_deref().unwrap_or("Operator!")), + updated = escape(entry.updated.as_deref().unwrap_or("—")), + ) +} + +fn table_row(entry: &CatalogEntry) -> String { + format!( + r#" + {icon}{name} + {description} + {count} + {loop_kind} + {author} + {badge} + {created} + {updated} + +"#, + search = escape(&entry.search_text), + icon = icon_svg(entry), + docs_path = escape(&entry.docs_path), + name = escape(&entry.name), + description = escape(&entry.description), + count = entry.issue_type_count, + loop_kind = escape(entry.loop_kind.as_deref().unwrap_or("—")), + author = escape(entry.author.as_deref().unwrap_or("Operator!")), + badge = tier_badge(&entry.tier), + created = escape(entry.created.as_deref().unwrap_or("—")), + updated = escape(entry.updated.as_deref().unwrap_or("—")), + ) +} + +/// The catalog page: vocabulary, the two statically-rendered views, the CTA. +fn hub_page(entries: &[CatalogEntry]) -> String { + let mut out = format_header("Workflows", "src/collections/ + collections/community/"); + // Drives the sidebar's active state without matching on URL substrings, + // which would also light up /getting-started/workflows/. + out = out.replace("layout: doc\n", "layout: doc\nsection: workflows\n"); + + out.push_str(VOCABULARY); + out.push_str( + "\nEvery collection below is installable from Operator directly — they are \ + published from this site as a [machine-readable index](/collections/index.json) \ + that operator instances read on startup.\n\n", + ); + + out.push_str(&format!( + "\n\n\ +
\n\ +
\n{}
\n", + entries.iter().map(card).collect::() + )); + + out.push_str( + "\n \n \ + \ + \n \n \n", + ); + out.push_str(&entries.iter().map(table_row).collect::()); + out.push_str(" \n
CollectionDescriptionIssue typesLoopAuthorTierCreatedUpdated
\n
\n"); + + out.push_str(CONTRIBUTING); + out +} + +/// A single collection's page: metadata, then the split-view explorer. +fn detail_page(entry: &CatalogEntry) -> String { + let mut out = format_header( + &entry.name, + &format!("the {} collection manifest", entry.id), + ); + out = out.replace("layout: doc\n", "layout: doc\nsection: workflows\n"); + + out.push_str(&format!("{}\n\n", entry.description)); + + out.push_str("| | |\n|---|---|\n"); + out.push_str(&format!("| **Tier** | {} |\n", entry.tier)); + if let Some(author) = &entry.author { + let rendered = entry + .url + .as_ref() + .map_or_else(|| author.clone(), |url| format!("[{author}]({url})")); + out.push_str(&format!("| **Author** | {rendered} |\n")); + } + if let Some(license) = &entry.license { + out.push_str(&format!("| **License** | {license} |\n")); + } + out.push_str(&format!("| **Version** | {} |\n", entry.version)); + if let Some(created) = &entry.created { + out.push_str(&format!("| **Created** | {created} |\n")); + } + if let Some(updated) = &entry.updated { + out.push_str(&format!("| **Updated** | {updated} |\n")); + } + if let Some(loop_kind) = &entry.loop_kind { + out.push_str(&format!("| **Loop shape** | `{loop_kind}` |\n")); + } + if !entry.review_gates.is_empty() { + out.push_str(&format!( + "| **Review gates** | {} |\n", + entry + .review_gates + .iter() + .map(|g| format!("`{g}`")) + .collect::>() + .join(", ") + )); + } + if !entry.stop_conditions.is_empty() { + out.push_str(&format!( + "| **Stops when** | {} |\n", + entry.stop_conditions.join("; ") + )); + } + out.push_str(&format!( + "| **Manifest** | [`collection.json`](/collections/{}) |\n\n", + entry.manifest_path + )); + + out.push_str("## Issue types\n\n| Key | Name | Mode | Steps |\n|---|---|---|---|\n"); + for it in &entry.issue_types { + out.push_str(&format!( + "| `{}` | {} | {} | {} |\n", + it.key, it.name, it.mode, it.step_count + )); + } + + out.push_str( + "\n## Workflows\n\nSelect an issue type to see the Operator workflow it defines. \ + This is the same graph the Operator app draws, rendered from the same \ + published JSON.\n\n", + ); + out.push_str(&format!( + "
\n \ + \n\ +
\n\n", + entry.id + )); + + out.push_str(&format!( + "## Install\n\nOperator reads the hosted catalog on startup, so this collection \ + appears in the setup picker. To pin it explicitly:\n\n\ + ```toml\n# config.toml\n[templates]\nactive_collection = \"{}\"\n```\n", + entry.id + )); + + out +} + +impl DocGenerator for CollectionsPagesGenerator { + fn name(&self) -> &'static str { + "collections-pages" + } + + fn source(&self) -> &'static str { + "src/collections/*/collection.json + collections/community/*/collection.json" + } + + fn output_path(&self) -> &'static str { + "workflows/index.md" + } + + fn generate(&self) -> Result { + Ok(hub_page(&build_catalog()?.collections)) + } + + fn write(&self, docs_dir: &Path) -> Result<()> { + let catalog = build_catalog()?; + let workflows_dir = docs_dir.join("workflows"); + std::fs::create_dir_all(&workflows_dir)?; + + std::fs::write( + workflows_dir.join("index.md"), + hub_page(&catalog.collections), + )?; + + for entry in &catalog.collections { + let dir = workflows_dir.join(&entry.id); + std::fs::create_dir_all(&dir)?; + std::fs::write(dir.join("index.md"), detail_page(entry))?; + } + + tracing::info!( + generator = self.name(), + output = %workflows_dir.display(), + collections = catalog.collections.len(), + "Generated workflow catalog pages" + ); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn catalog() -> Vec { + build_catalog().unwrap().collections + } + + #[test] + fn test_hub_page_renders_every_collection_in_both_views() { + let entries = catalog(); + let page = hub_page(&entries); + for entry in &entries { + // Once as a card, once as a table row — both filterable. + assert_eq!( + page.matches(&format!("data-search=\"{}\"", escape(&entry.search_text))) + .count(), + 2, + "{} should appear in both the card grid and the table", + entry.id + ); + assert!(page.contains(&entry.docs_path), "{} needs a link", entry.id); + } + } + + #[test] + fn test_hub_page_works_without_javascript() { + // The search element only filters DOM that is already present; if cards + // were rendered client-side this assertion would fail. + let page = hub_page(&catalog()); + assert!(page.contains("class=\"collection-grid\"")); + assert!(page.contains("class=\"collection-card\"")); + assert!( + !page.contains("fetch("), + "the catalog must not be fetched at runtime" + ); + } + + #[test] + fn test_hub_page_states_the_vocabulary() { + let page = hub_page(&catalog()); + for term in ["Operator workflow", "Issue type", "Collection"] { + assert!(page.contains(term), "vocabulary must define '{term}'"); + } + // The distinction from kanban types is the point of the page. + assert!(page.contains("kanban issue types")); + } + + #[test] + fn test_pages_are_tagged_for_the_sidebar() { + let page = hub_page(&catalog()); + assert!( + page.contains("section: workflows"), + "front matter must carry the section flag the sidebar keys on" + ); + let detail = detail_page(&catalog()[0]); + assert!(detail.contains("section: workflows")); + } + + #[test] + fn test_detail_page_mounts_the_explorer_at_the_published_bundle() { + let ralph = catalog() + .into_iter() + .find(|c| c.id == "ralph_loop") + .unwrap(); + let page = detail_page(&ralph); + assert!(page.contains(r#""#)); + assert!(page.contains("| `PRD` |")); + assert!(page.contains("snarktank")); + assert!(page.contains("active_collection = \"ralph_loop\"")); + } + + #[test] + fn test_icons_are_inlined_so_they_tint_with_the_theme() { + let page = hub_page(&catalog()); + // An would load the SVG as its own document, where currentColor + // cannot resolve — the icon would stay black and vanish in dark mode. + assert!( + !page.contains("alert("x") & more"#.to_string(); + let page = card(&entry); + assert!(!page.contains("