From 1d7557cb31928b23f94ddc4e2e85076f3aef84d5 Mon Sep 17 00:00:00 2001 From: untra Date: Tue, 16 Jun 2026 10:38:49 -0600 Subject: [PATCH] zed, agnt versions must rev define loop structures, elves, ralph, jr, setup refinement integration maturity and measurement, feature alignment agnt plugin fixes, workflow refinement and adjustment no commithook Co-Authored-By: Claude Opus 4.5 --- .github/workflows/build.yaml | 28 +- .github/workflows/docs.yml | 2 + CLAUDE.md | 32 +- Makefile | 33 ++ README.md | 66 +-- agnt-plugin/alert.js | 3 + agnt-plugin/create-ticket.js | 3 + agnt-plugin/export.js | 3 + agnt-plugin/launch.js | 3 + agnt-plugin/manifest.json | 2 +- agnt-plugin/package.json | 2 +- agnt-plugin/queue.js | 3 + agnt-plugin/run-step.js | 3 + backstage-server/package.json | 2 +- bindings/CollectionResponse.ts | 15 +- bindings/Delegator.ts | 4 +- bindings/IntegrationCatalogEntryDto.ts | 36 ++ bindings/RemoteAgentRef.ts | 2 +- bindings/SectionId.ts | 2 +- bindings/SupportStatus.ts | 6 + bindings/TemplatesConfig.ts | 16 +- bindings/WorkflowFormatDto.ts | 31 ++ bindings/WorkflowHintsDto.ts | 6 + bump-version.sh | 4 + codecov.yml | 12 +- 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/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 | 91 ++++ docs/schemas/config.json | 24 +- docs/schemas/config.md | 7 +- docs/schemas/index.md | 2 + docs/schemas/openapi.json | 211 +++++++- shared/types.ts | 92 +++- src/api/providers/kanban/mod.rs | 87 ++++ src/app/keyboard.rs | 20 +- src/app/tickets.rs | 45 ++ src/bin/generate_types.rs | 12 +- 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/config/llm_tools.rs | 6 +- src/config/sessions.rs | 14 + src/docs_gen/collections_manifest.rs | 218 +++++++++ src/docs_gen/integrations.rs | 126 +++++ src/docs_gen/mod.rs | 4 + src/docs_gen/schema_index.rs | 10 + src/integrations/catalog.rs | 433 +++++++++++++++++ src/integrations/mod.rs | 4 + src/integrations/support_status.rs | 130 +++++ src/issuetypes/collection.rs | 25 + src/issuetypes/loader.rs | 124 ++++- src/issuetypes/mod.rs | 5 + src/lib.rs | 2 +- src/main.rs | 23 +- 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/integrations.rs | 77 +++ src/rest/dto/issue_types.rs | 229 +++++++++ src/rest/dto/kanban.rs | 177 +++++++ src/rest/dto/mod.rs | 2 + src/rest/dto/workflow.rs | 66 ++- src/rest/mod.rs | 4 + src/rest/openapi.rs | 14 +- src/rest/routes/integrations.rs | 39 ++ src/rest/routes/mod.rs | 1 + src/rest/routes/workflow.rs | 31 +- src/schemas/issuetype_collection_schema.json | 77 +++ src/startup/templates.rs | 8 +- src/templates/mod.rs | 69 +++ src/types/pr.rs | 20 + src/ui/sections/mod.rs | 2 + src/ui/sections/workflows_section.rs | 117 +++++ src/ui/setup/mod.rs | 398 +++++++++------ src/ui/setup/steps/collection.rs | 153 +----- src/ui/setup/steps/hosted.rs | 193 ++++++++ src/ui/setup/steps/mod.rs | 2 + src/ui/setup/tests.rs | 163 ++++++- src/ui/setup/types.rs | 140 ++++-- src/ui/status_panel.rs | 27 +- src/workflow_gen/agnt.rs | 188 +++++--- src/workflow_gen/command.rs | 4 +- src/workflow_gen/export.rs | 258 ++++++++++ src/workflow_gen/format.rs | 59 +++ tests/agnt_plugin_tool_names.rs | 69 +++ tests/feature_parity_test.rs | 140 +++++- tests/version_parity.rs | 108 +++++ tests/vertical_parity.rs | 266 ++++++++++ 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 | 72 +++ vscode-extension/src/status-provider.ts | 6 +- .../test/suite/status-provider.test.ts | 4 +- vscode-extension/webview-ui/types/defaults.ts | 3 + zed-extension/Cargo.lock | 2 +- zed-extension/Cargo.toml | 2 +- zed-extension/extension.toml | 2 +- 221 files changed, 12483 insertions(+), 605 deletions(-) create mode 100644 Makefile create mode 100644 bindings/IntegrationCatalogEntryDto.ts create mode 100644 bindings/SupportStatus.ts create mode 100644 bindings/WorkflowFormatDto.ts create mode 100644 bindings/WorkflowHintsDto.ts 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 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 docs/maturity/index.md 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/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 src/schemas/issuetype_collection_schema.json create mode 100644 src/ui/sections/workflows_section.rs create mode 100644 src/ui/setup/steps/hosted.rs create mode 100644 tests/agnt_plugin_tool_names.rs create mode 100644 tests/version_parity.rs create mode 100644 tests/vertical_parity.rs create mode 100644 vscode-extension/src/sections/workflows-section.ts 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..017a38f2 100644 --- a/README.md +++ b/README.md @@ -13,13 +13,15 @@ * **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/) * **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 @@ -98,7 +100,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 +298,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/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/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/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/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/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/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/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/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/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/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/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/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/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 new file mode 100644 index 00000000..1445b4eb --- /dev/null +++ b/docs/maturity/index.md @@ -0,0 +1,91 @@ +--- +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/) | + +## 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 c0b8bd4f..6e87c1e9 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 } } }, @@ -1420,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" @@ -1530,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 596d8ce1..197b7aa3 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 @@ -504,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. | @@ -541,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/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 aff5b61a..ba45f550 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": { @@ -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": [ @@ -2344,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": { @@ -2688,11 +2738,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)." + } + ] } } }, @@ -3639,6 +3714,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", @@ -4965,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", @@ -5501,6 +5621,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.", @@ -5990,6 +6120,83 @@ "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).", + "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..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, }; @@ -744,7 +746,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 +915,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, /** @@ -971,6 +1001,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"). @@ -1029,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/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..d6c1a2e8 100644 --- a/src/bin/generate_types.rs +++ b/src/bin/generate_types.rs @@ -40,9 +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, WorkflowPreviewResponse, + FieldResponse, HealthResponse, IntegrationCatalogEntryDto, IssueTypeResponse, IssueTypeSummary, + KanbanProviderCatalogEntry, SectionDto, SectionRowDto, SkillEntry, SkillsResponse, + StatusResponse, StepResponse, UpdateIssueTypeRequest, UpdateStepRequest, + WorkflowExportResponse, WorkflowFormatDto, WorkflowHintsDto, WorkflowPreviewResponse, }; use operator::state::{AgentState, CompletedTicket, State}; use operator::types::{ @@ -141,15 +142,20 @@ 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), 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 WorkflowExportResponse::decl(&cfg), WorkflowPreviewResponse::decl(&cfg), + WorkflowFormatDto::decl(&cfg), // Ticket creation + alert DTOs CreateTicketRequest::decl(&cfg), CreateTicketResponse::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/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/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/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/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 6c219978..baa77747 100644 --- a/src/docs_gen/mod.rs +++ b/src/docs_gen/mod.rs @@ -14,8 +14,10 @@ //! 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 integrations; pub mod issuetype; pub mod issuetype_json_schema; pub mod jira_api; @@ -92,6 +94,8 @@ 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(integrations::MaturityDocGenerator), 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/integrations/catalog.rs b/src/integrations/catalog.rs new file mode 100644 index 00000000..fb43d66a --- /dev/null +++ b/src/integrations/catalog.rs @@ -0,0 +1,433 @@ +//! 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, + Workflows, +} + +impl Vertical { + /// All verticals, in README display order. + pub const ALL: [Vertical; 9] = [ + Vertical::Kanban, + Vertical::Model, + Vertical::Git, + Vertical::Session, + Vertical::Editor, + Vertical::LlmTool, + Vertical::Platform, + Vertical::Integration, + Vertical::Workflows, + ]; + + /// 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", + Vertical::Workflows => "workflows", + } + } + + /// 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", + Vertical::Workflows => "Workflow Format", + } + } +} + +/// 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, Workflows, + }; + 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, + ), + // --- 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, + ), + ] +} + +/// 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/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 9a01bd48..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; @@ -112,7 +116,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)] @@ -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, 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; @@ -878,9 +883,15 @@ 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("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" + "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(()); } @@ -903,6 +914,8 @@ 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), + Box::new(integrations::MaturityDocGenerator), ] } }; 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/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/issue_types.rs b/src/rest/dto/issue_types.rs index 8074c92d..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,196 @@ 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), } } } + +#[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, + 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/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/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/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 3d47be46..8a8024d0 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, @@ -122,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 9a58c212..56333e75 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, @@ -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, WorkflowFormatDto, WorkflowHintsDto, + WorkflowPreviewResponse, WriteKanbanConfigRequest, WriteKanbanConfigResponse, }; // AgentProfile interchange types live in `crate::config`, not `rest::dto`. use crate::config::{AgentProfile, DelegatorLaunchConfig, RemoteAgentRef, XOperator}; @@ -54,11 +54,14 @@ use crate::rest::error::ErrorResponse; StatusResponse, SectionDto, SectionRowDto, + IntegrationCatalogEntryDto, + crate::integrations::SupportStatus, IssueTypeResponse, IssueTypeSummary, FieldResponse, StepResponse, CollectionResponse, + WorkflowHintsDto, LaunchTicketResponse, ErrorResponse, // Request types @@ -104,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/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/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/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/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/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..b78afd8e --- /dev/null +++ b/src/ui/sections/workflows_section.rs @@ -0,0 +1,117 @@ +//! The **Workflows** status section: the export formats a ticket + issuetype can +//! be rendered into (Claude dynamic workflow `.js`, AGNT graph `.json`). +//! +//! Info-only — formats carry no credentials, so the section is `Gray`. It gates +//! on `Connections`: preview/export run against the hosted API, so the section +//! only appears once connections are ready. 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] { + &[SectionId::Connections] + } + + 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); + // Gated on Connections: preview/export run against the hosted API. + assert_eq!(WorkflowsSection.prerequisites(), &[SectionId::Connections]); + } + + #[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/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 new file mode 100644 index 00000000..2ede3bc6 --- /dev/null +++ b/src/ui/setup/steps/hosted.rs @@ -0,0 +1,193 @@ +//! 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( + "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: checkbox + name + version + workflow count + badge, + // with author + description on the second line. + 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)) + } + }; + 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), + ), + Span::raw(" "), + Span::styled( + format!("v{}", r.manifest.version), + 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::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), + ), + ]), + ]) + }) + .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(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}"), + Style::default().fg(Color::Yellow), + )])); + } + let details = Paragraph::new(lines).style(Style::default().fg(Color::Gray)); + 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(" confirm "), + 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/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) diff --git a/src/ui/status_panel.rs b/src/ui/status_panel.rs index e0791593..80c1b3ca 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,16 @@ mod tests { #[test] fn test_select_next_wraps() { let mut panel = StatusPanel::new("Status".into()); - // Collapse config so only the header is visible - panel - .tree_state - .expanded - .insert(SectionId::Configuration, false); - // Use a snapshot where only Configuration is green but Connections prerequisites fail - let mut snap = test_snapshot(); - snap.config_file_found = false; // Makes Configuration red, hiding Connections + // A fully-ready snapshot (config found + API running) so connections are + // Green and several prerequisite-gated sections — including Workflows — + // are visible, giving us multiple rows to wrap across. + let snap = test_snapshot(); let count = panel.visible_count(&snap); - assert_eq!(count, 1, "Only 1 row visible"); + assert!(count >= 2, "need multiple visible rows to test wrap"); - 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/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/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/feature_parity_test.rs b/tests/feature_parity_test.rs index c1074b61..c9e0fb60 100644 --- a/tests/feature_parity_test.rs +++ b/tests/feature_parity_test.rs @@ -184,16 +184,55 @@ 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, parsed from the committed ts-rs export +/// `bindings/SectionId.ts` — itself generated from the `SectionId` enum in +/// `src/ui/status_panel.rs` (the single source of truth). The VS Code copy under +/// `vscode-extension/src/generated/` is gitignored (it's produced by +/// `npm run copy-types`), so we read the tracked `bindings/` original to stay +/// reproducible on a fresh checkout. Parsing the 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!("../bindings/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 +258,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/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/tests/vertical_parity.rs b/tests/vertical_parity.rs new file mode 100644 index 00000000..8d619055 --- /dev/null +++ b/tests/vertical_parity.rs @@ -0,0 +1,266 @@ +//! 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; +use operator::workflow_gen::WorkflowFormat; + +/// 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}'" + ); + } + 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 +/// 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!(); +} 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..b39d4a78 --- /dev/null +++ b/vscode-extension/src/sections/workflows-section.ts @@ -0,0 +1,72 @@ +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 (`Gray`), gated on `connections`: + * preview/export run against the hosted API (`/api/v1/workflow-formats`), so the + * section only appears once connections are ready. 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[] = ['connections']; + + 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..d8756d9b 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, Workflows/workflows (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/test/suite/status-provider.test.ts b/vscode-extension/test/suite/status-provider.test.ts index fda80083..7701e56b 100644 --- a/vscode-extension/test/suite/status-provider.test.ts +++ b/vscode-extension/test/suite/status-provider.test.ts @@ -338,7 +338,7 @@ suite('Status Provider Test Suite', () => { const labels = getSectionLabels(provider.getChildren()); assert.deepStrictEqual( labels, - ['Configuration', 'Connections', 'Kanban', 'LLM Tools', 'Model Servers', 'Git', 'Delegators'] + ['Configuration', 'Connections', 'Kanban', 'LLM Tools', 'Model Servers', 'Git', 'Delegators', 'Workflows'] ); }); @@ -442,7 +442,7 @@ suite('Status Provider Test Suite', () => { const labels = getSectionLabels(provider.getChildren()); assert.deepStrictEqual( labels, - ['Configuration', 'Connections', 'Kanban', 'LLM Tools', 'Model Servers', 'Git', 'Issue Types', 'Delegators', 'Managed Projects'] + ['Configuration', 'Connections', 'Kanban', 'LLM Tools', 'Model Servers', 'Git', 'Issue Types', 'Delegators', 'Managed Projects', 'Workflows'] ); }); 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), 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"