Compare commits

..

1 commit

Author SHA1 Message Date
Randall Stillwell
6581f6a482 wip: agent-coordination iteration (runs+backlog+plans UI + 2 task plans + db-drift SQL)
In-flight work from 2026-06-05 that was sitting uncommitted on
feat/agent-pipeline-bridge for 10 days. Moved here to a clean branch
off main so the bridge branch can stay at its committed state (the
DEFERRED Phase 2a snapshot, see stwl-labs/ubiquitous-invention#3).

Touched areas:

- apps/web/app/(app)/[workspaceSlug]/settings/runs/page.tsx — runs page UI iteration
- apps/web/server/routers/{backlog,runs}.ts — router updates
- apps/web/app/(app)/[workspaceSlug]/plans/[planSlug]/[epicSlug]/[taskSlug]/page.tsx — new deep-link route
- apps/web/components/backlog/workflow-prompt-section.tsx — new component
- config/CursorSync.md — coordination doc tweak
- docs/operations/README.md + 2026-06-05-db-drift-reconcile-oauth-tables.sql — operations ops note + reconcile script
- plans/Plan-agent-coordination/Epic-task-as-runnable-unit/Task-{workflow-prompt-task-detail-ui,deep-link-runs-to-task-detail}.md — 2 task specs

This is a working-tree snapshot, not a finished PR. Rebase, split, or
amend as needed when picking it back up.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 15:35:39 -05:00
432 changed files with 1611 additions and 96479 deletions

View file

@ -1,297 +0,0 @@
---
name: backlog-sync
description: Work with the plans/ markdown backlog - understand the strict layout (Plan/Epic/Task hierarchy), parse frontmatter, sync to markdown_backlog_items, and maintain the DB ↔ .md contract. Use when creating or modifying backlog items, understanding the import process, or working with the markdown-backlog package.
---
# Backlog Sync
The `plans/` directory is the **source of truth** for product work. It's a strict markdown hierarchy parsed into the `markdown_backlog_items` database table. This skill teaches you the layout rules, frontmatter contract, and how the markdown ↔ DB sync works.
## When to Activate
Use this skill when:
- Creating or editing files under `plans/`
- Working with `markdown_backlog_items` or `cursor_sync_mappings` tables
- Running or debugging the markdown import (`pnpm --filter @tasks/database watch:markdown-backlog`)
- Understanding how `agent_prompt` inheritance works (plan → epic → task)
- Troubleshooting backlog sync issues
- Implementing features that read/write backlog data
## Core Rules
### 1. Directory Structure is Strict
The parser depends on this **exact** layout. Do not improvise.
```
plans/
Plan-<slug>/
Plan-<slug>.md ← Plan overview
Epic-<slug>/
Epic-<slug>.md ← Epic definition
Task-<slug>.md ← Executable work item
Task-<another>.md
```
**Key constraints:**
- Slugs are lowercase-hyphenated (`feature-x`, not `Feature_X` or `featureX`)
- Slugs are stable — renaming breaks Cursor sync mappings
- One task per file
- File names must match the pattern: `Plan-<slug>.md`, `Epic-<slug>.md`, `Task-<slug>.md`
### 2. Frontmatter Contract
Every Plan/Epic/Task markdown file must start with YAML frontmatter. The parser extracts these fields:
**Required across all kinds:**
```yaml
---
kind: plan | epic | task
slug: matches-the-filename-slug
title: Human-readable title
status: draft | ready | in_progress | blocked | done | cancelled
priority: P0 | P1 | P2 | P3
tenant_id: UUID or "global"
updated_at: "YYYY-MM-DD"
---
```
**Plan-specific additions:**
```yaml
cursor_plan_id: null # populated by Cursor sync
```
**Epic-specific additions:**
```yaml
plan_slug: parent-plan-slug
cursor_epic_id: null
```
**Task-specific additions:**
```yaml
plan_slug: parent-plan-slug
epic_slug: parent-epic-slug
owner: unassigned | github-username
cursor_todo_id: null
agent_prompt: |
Optional multi-line prompt for agents working on this task.
Inherits from epic → plan if not set here.
```
**What the parser does:**
- Extracts frontmatter via regex: `/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/`
- Parses YAML (tolerates syntax errors → empty object)
- Infers `kind` from filename if frontmatter omits it (`Task-*.md` → `task`)
- Infers `slug` from filename if frontmatter omits it (strips `Plan-`/`Epic-`/`Task-` prefix)
- Infers `plan_slug` from path if frontmatter omits it (`plans/Plan-foo/...` → `foo`)
- Infers `epic_slug` from path for tasks if frontmatter omits it (`Epic-bar/Task-x.md` → `bar`)
- Computes `contentHash` (SHA-256 of full file) for idempotency
- Stores `workflowPrompt` from `agent_prompt:` frontmatter field
### 3. Agent Prompt Inheritance
Tasks can define `agent_prompt:` in frontmatter. If missing, the system inherits:
```
Task (no agent_prompt) → Epic (check agent_prompt) → Plan (check agent_prompt) → Default
```
The `resolveWorkflowPrompt` function in `packages/database/src/markdown-backlog/resolve-prompt.ts` implements this cascade. When an agent claims a task via MCP `claim_task`, the resolved prompt is returned as context.
**Default prompt** (when nothing is set):
```
You are an AI assistant helping implement a software task.
Follow the acceptance criteria and constraints in the task description.
Run pnpm lint && pnpm type-check after code changes.
```
### 4. Database Sync Flow
**Import (Markdown → DB):**
1. Run `pnpm --filter @tasks/database watch:markdown-backlog`
2. Scanner walks `plans/` for `*.md` files matching the pattern
3. Parser extracts frontmatter + body + contentHash
4. Syncer upserts rows to `markdown_backlog_items` by `(workspace_id, repo_path)` unique key
5. Hierarchical `parent_id` links are computed (task → epic → plan)
**Key columns in `markdown_backlog_items`:**
| Column | Source |
|--------|--------|
| `workspace_id` | Env var `MARKDOWN_BACKLOG_WORKSPACE_ID` |
| `repo_path` | Relative posix path: `plans/Plan-foo/Epic-bar/Task-baz.md` |
| `kind` | Frontmatter `kind:` or inferred from filename |
| `slug` | Frontmatter `slug:` or inferred from filename |
| `title` | Frontmatter `title:` or first `# Heading` or filename |
| `body` | Markdown body after frontmatter |
| `content_hash` | SHA-256 of full file |
| `parent_id` | Computed by matching `plan_slug`/`epic_slug` to parent rows |
| `workflow_prompt` | Frontmatter `agent_prompt:` (null if not set) |
**Export (DB → Markdown):**
Not yet fully automated. The `exportBacklogItemToMarkdown` function can generate markdown from a DB row, but writes are manual or triggered via tRPC `backlog.createPlan/createEpic/createTask`.
### 5. Don't Break the Contract
**What NOT to do:**
- Don't nest tasks under other tasks (only Plan → Epic → Task)
- Don't put secrets or API keys in frontmatter (files are committed)
- Don't hand-edit `cursor_plan_id`, `cursor_epic_id`, `cursor_todo_id` (Cursor owns these)
- Don't rename slugs without understanding the Cursor sync impact (breaks mappings)
- Don't mix `snake_case` and `camelCase` in frontmatter keys (parser expects `plan_slug`, `epic_slug`, `agent_prompt`)
- Don't create files outside the `plans/Plan-*/` structure (parser ignores them)
**What TO do:**
- Match existing style (see `docs/templates/plan-template.md`, `epic-template.md`, `task-template.md`)
- Keep tasks **bead-scale** (one agent session can complete or materially advance)
- Cross-link with relative paths: tasks link to epic via `./Epic-<slug>.md`, to plan via `../Plan-<slug>.md`
- Use `tenant_id: global` for repo-wide work that's not tenant-specific
- Run `pnpm --filter @tasks/database watch:markdown-backlog` after adding/editing files to sync
## Common Patterns
### Creating a New Task
1. Navigate to the Epic folder: `plans/Plan-<plan>/Epic-<epic>/`
2. Create `Task-<slug>.md` (lowercase-hyphenated slug)
3. Copy frontmatter template from `docs/templates/task-template.md`
4. Fill required fields: `kind: task`, `slug`, `title`, `status: draft`, `priority`, `plan_slug`, `epic_slug`, `tenant_id`, `updated_at`
5. Write task body (## Description, ## Acceptance criteria, etc.)
6. Run `pnpm --filter @tasks/database watch:markdown-backlog` to import
7. Verify row exists: `SELECT * FROM markdown_backlog_items WHERE slug = '<slug>'`
### Adding an Agent Prompt to a Task
If the task needs custom context for agents:
```yaml
---
kind: task
slug: implement-feature-x
# ... other required fields ...
agent_prompt: |
This task extends the existing authentication system.
Key constraints:
- Use NextAuth v5 (not v4)
- Follow patterns in apps/web/lib/auth.ts
- Add tests for new session logic
---
```
When an agent calls MCP `claim_task` with this task's `backlogItemId`, the prompt is returned and injected into the session context.
### Checking Sync Status
```bash
# Watch mode (auto-imports on file change)
pnpm --filter @tasks/database watch:markdown-backlog
# One-shot import
MARKDOWN_BACKLOG_WORKSPACE_ID=<uuid> node packages/database/dist/markdown-backlog-cli.js
```
**What it logs:**
- Created: new rows inserted
- Updated: contentHash changed
- Skipped: contentHash matches (idempotent)
- Errors: parse failures, missing parent refs
### Understanding the Hierarchy via SQL
```sql
-- Get full tree for a plan
WITH RECURSIVE tree AS (
SELECT id, kind, slug, title, parent_id, 0 AS depth
FROM markdown_backlog_items
WHERE kind = 'plan' AND slug = 'automation-foundation'
UNION ALL
SELECT c.id, c.kind, c.slug, c.title, c.parent_id, p.depth + 1
FROM markdown_backlog_items c
JOIN tree p ON c.parent_id = p.id
)
SELECT kind, slug, title, depth FROM tree ORDER BY depth, slug;
```
## Examples
### Example 1: Valid Task Frontmatter
```yaml
---
kind: task
slug: db-convoy-events-migration
title: Drizzle convoy_events table and migration
plan_slug: automation-foundation
epic_slug: convoy-lifecycle-mcp
status: done
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-08-12"
---
# Task summary
Add Drizzle schema + migration for convoy_events...
```
### Example 2: Task with Agent Prompt
```yaml
---
kind: task
slug: trpc-backlog-tree
title: tRPC backlog.list / tree / get for markdown_backlog_items
plan_slug: automation-foundation
epic_slug: plans-roadmap-ui
status: ready
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-08-12"
agent_prompt: |
Extend apps/web backlog router: list/tree/get for markdown_backlog_items.
Always filter by workspace_id. Zod inputs. No Plans UI yet — API only.
Reuse existing schema. claim_task / complete_task; lint + type-check + test.
---
```
### Example 3: Debugging Import Issues
**Problem:** Task not appearing in DB after markdown edit.
**Diagnosis:**
1. Check file structure: `ls plans/Plan-<plan>/Epic-<epic>/Task-<slug>.md`
2. Validate frontmatter YAML syntax (common issue: unbalanced quotes, missing colons)
3. Check watcher logs: `pnpm --filter @tasks/database watch:markdown-backlog` (look for parse errors)
4. Query DB: `SELECT * FROM markdown_backlog_items WHERE slug = '<slug>' AND workspace_id = '<uuid>'`
5. Check `content_hash`: if it matches, sync is idempotent (no change detected)
**Fix:** Correct frontmatter YAML, re-run import, verify row updated.
## References
- **Parser source:** `packages/database/src/markdown-backlog/parse.ts`
- **Sync logic:** `packages/database/src/markdown-backlog/sync.ts`
- **Prompt resolution:** `packages/database/src/markdown-backlog/resolve-prompt.ts`
- **Templates:** `docs/templates/plan-template.md`, `epic-template.md`, `task-template.md`
- **Glossary:** `docs/Glossary.md` (backlog hierarchy, sync vocabulary)
- **Cursor sync notes:** `config/CursorSync.md` (push/pull behavior, mappings)
- **Schema:** `packages/database/src/schema/markdown_backlog_items.ts`
## Related Skills
- `convoy-lifecycle` — when backlog tasks map to convoy briefs
- `model-routing` — how agent_prompt + model_policy interact
- `code-review-two-axis` — reviewing changes to backlog sync code

View file

@ -1,243 +0,0 @@
---
name: code-review-two-axis
description: Review code changes along two axes: Standards (does code follow repo conventions?) and Spec (does it match the originating issue/requirement?). Uses parallel sub-agents to keep concerns separate, then aggregates findings. Use when role-reviewer runs, a human requests PR review, or you're asked to review "changes since X".
---
# Code Review: Two-Axis Method
A rigorous but practical code review method that separates **Standards** from **Spec**, runs them as parallel sub-agents, and aggregates findings. Adapted from the approach in [mattpocock/skills](https://github.com/mattpocock/skills/blob/main/skills/engineering/code-review/SKILL.md) for Echodo's convoy pipeline.
## When to Activate
Use this skill when:
- **Role-reviewer** is triggered during convoy `pr_review` gate
- A human requests a PR review in Mission Control
- Someone asks "review the changes since commit X" or "review branch Y"
- You're asked to evaluate if code meets the acceptance criteria from the originating brief
## Core Method
### Two Axes
**1. Standards (Does the code follow repo conventions?)**
- Follows `AGENTS.md` and codebase style
- Uses existing patterns (file locations, naming, imports)
- Passes linter/formatter checks
- No obvious code smells (from Fowler's refactoring book)
**2. Spec (Does the code match what was asked for?)**
- Implements acceptance criteria from the originating brief
- Matches the description in `.convoys/<slug>/brief-*.md`
- Addresses the issue/story/task it claims to solve
- No missing functionality or edge cases
### Why Two Separate?
- **Separation of concerns:** A change might be well-written (Standards) but not solve the problem (Spec), or vice versa
- **Clearer feedback:** "This is good code but doesn't match the spec" vs "This matches the spec but violates our style"
- **Parallel sub-agents:** Run both in parallel to avoid context pollution between concerns
## The Process
### Step 1: Pin the Fixed Point
Whatever the user specifies as the comparison base:
- Commit SHA: `abc1234`
- Branch: `feature-x`
- Tag: `v1.2.0`
- Relative: `HEAD~5`, `main`
**Capture:**
- Diff command: `git diff <fixed-point>...HEAD` (three-dot for merge-base comparison)
- Commit log: `git log <fixed-point>..HEAD --oneline`
**Validate:**
- `git rev-parse <fixed-point>` must resolve
- Diff must be non-empty
### Step 2: Identify the Spec Source
Find what the change is supposed to implement, in this order:
1. **Issue references in commits:** `#123`, `Closes #45`, etc.
2. **Brief path:** User passed `.convoys/<slug>/brief-N-*.md`
3. **Matching spec file:** Look in `docs/`, `specs/`, `.scratch/` for files matching branch name
4. **Ask the user:** If none found, ask where the spec is
### Step 3: Identify the Standards Sources
- `AGENTS.md` at repo root
- `.cursor/rules/*.mdc` files (if not migrated to `.agents/skills/`)
- `docs/` for architecture decisions
- Existing code patterns in the same module
- **Fowler code smells baseline** (below)
### Step 4: Run Both Sub-Agents in Parallel
Run **Standards agent** and **Spec agent** as separate conversations with their own context. Each receives:
- The diff
- Its specific criteria
- Output format instructions
**Standards sub-agent receives:**
- Diff
- Standards sources (AGENTS.md, rules, patterns)
- Code smell baseline
- Instruction: "Review ONLY for standards violations"
**Spec sub-agent receives:**
- Diff
- Brief/spec source
- Acceptance criteria from originating task
- Instruction: "Review ONLY for spec conformance"
### Step 5: Aggregate Findings
After both sub-agents complete, merge their reports:
```markdown
# Code Review: <convoy/branch>
## Summary
<Combined 2-3 sentence summary>
## Standards Findings
<Issues from Standards axis, grouped by severity>
## Spec Findings
<Issues from Spec axis, grouped by severity>
## Recommendations
<Combined recommendations, prioritized>
## Verdict
[ ] Approve - meets standards and spec
[ ] Approve with comments - minor issues, not blocking
[ ] Request changes - significant issues must be addressed
[ ] Block - blocker-level issues (security, correctness)
```
## Code Smell Baseline (Standards)
Even if repo docs say nothing, apply this baseline. **Repo rules always override.**
### The 11 Smells
| Smell | What It Is | How to Fix |
|-------|------------|------------|
| **Mysterious Name** | Function/variable/type name doesn't reveal purpose | Rename to something descriptive |
| **Duplicated Code** | Same logic in multiple files/hunks | Extract to shared function |
| **Feature Envy** | Method reaches into another object's data more than its own | Move method to the data it envys |
| **Data Clumps** | Same fields/params travel together | Bundle into a type |
| **Primitive Obsession** | Primitives standing in for domain concepts | Create small types |
| **Repeated Switches** | Same switch/if-cascade repeats | Use polymorphism or map |
| **Shotgun Surgery** | One logical change forces edits in many files | Gather related changes together |
| **Divergent Change** | One file edited for unrelated reasons | Split by concern |
| **Speculative Generality** | Abstraction/params added for imagined future needs | Delete until real need appears |
| **Message Chains** | Long `a.b().c().d()` navigation | Hide behind method on first object |
| **Middle Man** | Class/function that mostly just delegates | Call target directly |
| **Refused Bequest** | Subclass ignores most of what it inherits | Use composition, not inheritance |
## Severity Levels
| Level | Meaning | Action |
|-------|---------|--------|
| **Blocker** | Security vulnerability, correctness bug, data loss risk | Must fix before merge |
| **Major** | Significant issue affecting functionality or maintainability | Should fix before merge |
| **Minor** | Nits, style issues, minor improvements | Fix before merge or follow-up |
| **Nit** | Style preference, whitespace, naming convention | Optional, can defer |
## Categories
| Category | What It Covers |
|----------|----------------|
| **Correctness** | Logic errors, bugs, edge cases not handled |
| **Security** | Vulnerabilities, input validation, auth issues |
| **Performance** | Inefficiency, unnecessary work, memory issues |
| **Readability** | Clarity, naming, documentation |
| **Style** | Formatting, lint violations (let linter handle these) |
| **Test-Coverage** | Missing tests for new functionality |
## Example Review Output
### Input
- **Convoy:** `feature-add-auth-endpoint`
- **Fixed point:** `main`
- **Brief:** `.convoys/feature-add-auth-endpoint/brief-1-implement.md`
- **Acceptance criteria from brief:**
- [ ] Route at `apps/web/app/api/auth/register/route.ts`
- [ ] zod schema validates email/password
- [ ] Database insert creates user row
- [ ] Returns 201 on success, 400 on validation error
### Output
```markdown
# Code Review: feature-add-auth-endpoint
## Summary
Implements POST /api/auth/register endpoint with zod validation and database insert. Route file correctly placed. Meets most acceptance criteria. One validation edge case and missing test coverage identified.
## Standards Findings
### Minor
- **[style]** `apps/web/app/api/auth/register/route.ts:15` — Consider extracting validation schema to `lib/validations/auth.ts` for reusability
- **[readability]** `apps/web/app/api/auth/register/route.ts:8` — `createUser` function name could be more descriptive: `createUserFromCredentials`
### Nit
- **[style]** Missing blank line between imports
## Spec Findings
### Major
- **[correctness]** `apps/web/app/api/auth/register/route.ts:28` — Returns 500 on database error instead of appropriate 4xx. Should return 409 if email already exists.
### Minor
- **[test-coverage]** No test file created for this route. Brief AC didn't explicitly require tests but Echodo standard is to add tests.
## Recommendations
1. **High priority:** Handle duplicate email (409) and database errors (500 → 500, but logged)
2. **Medium:** Consider extracting validation schema
3. **Low:** Add minimal test coverage
## Verdict
[ ] Approve
[X] Approve with comments - one major issue should be addressed
[ ] Request changes
[ ] Block
## Metadata
- **Reviewed by:** role-reviewer (qwen-coder-mlx)
- **Model:** qwen-coder-mlx
- **Files reviewed:** 1
- **Lines changed:** ~45
```
## Running as Role-Reviewer
When triggered as `role-reviewer` in a convoy:
1. **Find the brief:** Look at convoy's current brief in `.convoys/<slug>/brief-*.md`
2. **Extract AC:** Get acceptance criteria from brief body
3. **Pin base:** Use `main` as default fixed point, or ask if different
4. **Run parallel agents:** Standards + Spec in parallel
5. **Aggregate:** Merge into final review per format above
6. **Log:** Call `log_convoy_event` with event type `role_complete`, actor `role-reviewer`
7. **Transition:** If blocker/major issues, transition convoy back to `in_progress`. If minor/nits, transition to `pr_review`.
## Interaction with Other Skills
- **backlog-sync:** When review involves changes to markdown import logic
- **convoy-lifecycle:** When logging role_complete and transitioning status
- **model-routing:** When determining which model runs the review
- **tdd-red-green:** When suggesting tests that should exist
## References
- **Original skill:** https://github.com/mattpocock/skills/blob/main/skills/engineering/code-review/SKILL.md
- **Fowler Refactoring:** Chapter 3 - "Bad Smells in Code"
- **AGENTS.md:** Root of repo for Echodo-specific standards
- **Echodo patterns:** Use existing code in `apps/web/`, `packages/` as standards reference

View file

@ -1,440 +0,0 @@
---
name: convoy-lifecycle
description: Work with the convoy system - understand status transitions, gate actors, MCP tools, and checkpoint/resume. Use when creating/managing convoys, transitioning status, logging events, or implementing the worker harness.
---
# Convoy Lifecycle
Convoys are the pipeline execution model for Echodo. A convoy groups related briefs into a cohesive unit with a success metric, model policy, and human gates. This skill teaches you the status machine, actor rules, MCP tools, and how the orchestrator drives the flow.
## When to Activate
Use this skill when:
- Creating or managing `.convoys/*.md` files
- Transitioning convoy status (draft → ready → merged, etc.)
- Understanding gate actors and what they can/cannot do
- Implementing the worker harness (Epic 7)
- Logging events to `convoy_events` or `agent_runs`
- Checking checkpoint/resume behavior
- Debugging convoy state issues
## Core Rules
### 1. Status Machine (The Contract)
Convoys have a strict status lifecycle. Transitions are enforced by the server.
**Code flow (from → to):**
```
draft ──→ convoy_review ──→ ready ──→ in_progress ──→ pr_review ──→ merged ──→ released
↑ │ ↑
│ │ │
│ └──→ blocked └──→ blocked
│ ↑
└──→ cancelled └──→ cancelled
```
**Non-code variant (docs/SOPs):**
```
draft ──→ convoy_review ──→ ready ──→ in_progress ──→ doc_review ──→ published ──→ released
↑ │ ↑
│ └──→ blocked └──→ blocked
│
└──→ cancelled
```
**Terminal states:** `released`, `cancelled`
**Exceptional state:** `blocked` (can resume from most states)
### 2. Actor Rules (Who Can Do What)
The `ALLOWED_TRANSITIONS` table in `packages/shared/src/convoy-lifecycle/state-machine.ts` defines explicit actor permissions. **Pipeline roles cannot perform gate transitions.**
| Actor | Can Do |
|-------|--------|
| `role-architect` | Submit draft to convoy_review |
| `role-conductor` | Cancel convoy, unblock from blocked |
| `role-implementer` | Submit ready to in_progress, move pr_review back to in_progress, block/unblock |
| `human-gate-1` | Approve/reject convoy_review → ready, block/unblock, cancel |
| `human-gate-2` | Approve pr_review → merged, doc_review → published, block/unblock, cancel |
| `human-gate-3` | Approve merged → released |
| `doc-approver` | Alias for human-gate-2 on doc flows |
| `automation` | Orchestrator-driven transitions (checkpoint resume, stall recovery) |
**Key principle:** `role-architect` ≠ human-gate-1. The conductor/architect roles plan and kick off; humans approve and release.
### 3. Gate Actors are Not Pipeline Roles
This is a common mistake. Do NOT let pipeline roles perform gates:
- ❌ `role-implementer` cannot approve PR merge (must be `human-gate-2`)
- ❌ `role-architect` cannot release to production (must be `human-gate-3`)
- ❌ `role-conductor` cannot approve convoy_review (must be `human-gate-1`)
**Exception:** The orchestrator runs as `automation` actor for internal transitions (checkpoint resume, stall recovery).
### 4. Reason Requirement
Some transitions require a `reason` string (e.g., blocking, cancelling, unblocking):
```typescript
// From state-machine.ts
{ from: "blocked", to: "ready", actors: ["human-gate-1", "role-conductor"], requiresReason: true }
```
When calling MCP `transition_convoy_status` for these, pass `reason: "blocking for redesign"`.
### 5. File-Based Convoys (`.convoys/*.md`)
Convoys are defined in markdown with YAML frontmatter. Location: `.convoys/<slug>.md`.
**Frontmatter structure:**
```yaml
---
name: <slug>
classification: server-only | web | full-stack
success_metric: One sentence explaining what "done" looks like
skip:
- ia # skip IA review
- ux # skip UX review
- design
- a11y
- visual
- smoke
status: draft | in_progress | merged | shipped | abandoned
created: YYYY-MM-DD
model_policy:
roles:
role-conductor: claude-planning
role-architect: claude-planning
role-implementer: qwen-coder
role-reviewer: qwen-coder
escalate_to: claude-planning
---
```
**Body sections:**
- **Why:** Problem statement and motivation
- **Scope:** What's in/out
- **Roles invoked:** Who runs which phases
- **Todos:** Brief mapping
- **Architecture:** Technical notes
**Briefs live in:** `.convoys/<slug>/brief-<N>-<title>.md`
### 6. Checkpoint/Resume (Worker Feature)
When a worker job runs, it can checkpoint state to `worker_checkpoints`:
```typescript
// Checkpoint payload (example from docs/worker.md)
{
toolHistory: [{ tool: "read_file", input: { path: "..." }, output: "..." }],
status: "in_progress",
lastCheckpointAt: "2026-08-12T10:00:00Z"
}
```
**Resume behavior:**
- Worker kills → orchestrator detects stall (`ORCHESTRATOR_STALL_MINUTES` default 120)
- Next dequeue reads checkpoint for same job → resumes from where it left off
- `toolHistory` enables context replay without re-running everything
### 7. Events Table (`convoy_events`)
Every transition and role completion is logged to `convoy_events`:
```sql
-- Example log entries
| id | convoy_slug | event_type | actor | metadata |
|----|-------------|-----------------|---------------|-------------------------------|
| 1 | feature-x | status_change | role-architect| { from: "draft", to: "ready" }|
| 2 | feature-x | role_complete | role-architect| { brief: "brief-1", ... } |
| 3 | feature-x | role_complete | role-implementer| { brief: "brief-2", model: ...}|
| 4 | feature-x | status_change | human-gate-2 | { from: "pr_review", to: "merged" } |
```
**Event types:**
- `status_change` — transition from one convoy status to another
- `role_complete` — a brief finished with metrics (model, tokens, duration)
- `stall_recovered` — orchestrator resumed from checkpoint
- `gate_action` — human approved/requested changes
## MCP Tools
### create_convoy
**Purpose:** Create a new convoy at `draft` status with metadata.
**Signature:**
```typescript
create_convoy({
workspace: string, // Workspace UUID or slug
slug: string, // Convoy slug (kebab-case)
title: string,
successMetric: string, // Frontmatter success_metric
classification: string, // server-only | web | full-stack
skip?: string[], // Optional: ["ia", "ux", "design"]
modelPolicy?: { // Optional: set model routes
roles: Record<string, string>, // role -> alias
escalateTo?: string,
}
})
```
**Returns:** Convoy object with ID, slug, current status (`draft`), created_at.
**Example usage:**
```
"Create a convoy called 'test-omniroute-integration' with success metric 'OmniRoute routes all 5 roles successfully'. Classification: server-only. Skip IA, UX, design."
```
### transition_convoy_status
**Purpose:** Move convoy between statuses. Enforces actor rules + reason requirements.
**Signature:**
```typescript
transition_convoy_status({
workspace: string,
convoySlug: string,
fromStatus: string,
toStatus: string,
actor: string, // role-architect, human-gate-1, etc.
reason?: string, // Required for block/unblock/cancel
})
```
**Returns:** Updated convoy status, event log entry.
**Example usage:**
```
"Transition convoy 'test-e2e-autonomous' from 'ready' to 'in_progress'. Actor: role-implementer."
```
### log_convoy_event
**Purpose:** Append a role completion or custom event to `convoy_events`.
**Signature:**
```typescript
log_convoy_event({
workspace: string,
convoySlug: string,
eventType: string, // role_complete, gate_action, etc.
actor: string, // Who performed the event
metadata: Record<string, unknown>, // Additional context
})
```
**Example usage:**
```
"Log role_complete for convoy 'test-e2e-autonomous', actor: role-implementer, brief: brief-2-implement-feature, model: qwen-coder, tokens: 12345"
```
### query_manifest_status
**Purpose:** Get convoy status, briefs, and recent events for Mission Control UI.
**Signature:**
```typescript
query_manifest_status({
workspace: string,
convoySlug?: string, // Optional: filter to one convoy
})
```
**Returns:** Convoy object with status, briefs (with status), recent events.
### reconcile_from_files
**Purpose:** Import `.convoys/*.md` into the DB and report pending sync outbox.
**Signature:**
```typescript
reconcile_from_files({
repoPath?: string, // Optional: scan specific path
})
```
**Returns:** Summary of created/updated convoys, any sync errors.
## Common Patterns
### Creating a Convoy
1. **Define the convoy file:** `.convoys/feature-x.md`
2. **Write frontmatter:** name, classification, success_metric, model_policy
3. **Write body:** Why, Scope, Roles, Todos
4. **Create briefs:** `.convoys/feature-x/brief-1-*.md` for each task
5. **Import to DB:** `reconcile_from_files` or `create_convoy` MCP
6. **Start work:** `transition_convoy_status` to `convoy_review`
### Running Through Gates
1. **Draft → Convoy Review:** `role-architect` submits (or `create_convoy` with status)
2. **Convoy Review:** `human-gate-1` approves (→ ready) or rejects (→ draft)
3. **Ready → In Progress:** `role-implementer` starts work
4. **In Progress → PR Review:** `role-implementer` submits code
5. **PR Review:** `human-gate-2` approves (→ merged) or requests changes (→ in_progress)
6. **Merged → Released:** `human-gate-3` promotes to production
### Logging Role Completion
When an agent finishes a brief:
```typescript
// In worker, after complete_task MCP
await log_convoy_event({
workspace: workspaceId,
convoySlug: convoy.slug,
eventType: "role_complete",
actor: roleId, // e.g., "role-implementer"
metadata: {
briefSlug: brief.slug,
model: modelSpec.alias, // e.g., "qwen-coder"
modelTier: tier, // "coding" or "coding-pro"
tokensIn: tokens.input,
tokensOut: tokens.output,
durationMs: duration,
outcome: "success" | "failure",
}
});
```
### Handling Blocks
To block a convoy (any gate actor or role-implementer):
```typescript
await transition_convoy_status({
workspace: workspaceId,
convoySlug: "feature-x",
fromStatus: "in_progress",
toStatus: "blocked",
actor: "human-gate-2", // or role-implementer
reason: "Design changed - need API redesign before continuing"
});
```
To unblock:
```typescript
await transition_convoy_status({
workspace: workspaceId,
convoySlug: "feature-x",
fromStatus: "blocked",
toStatus: "in_progress",
actor: "human-gate-2", // or role-conductor
reason: "API redesign complete"
});
```
## Examples
### Example 1: Simple Code Convoy
**`.convoys/add-auth-endpoint.md`:**
```yaml
---
name: add-auth-endpoint
classification: server-only
success_metric: POST /api/auth/register returns 201 and creates user row
skip:
- ia
- ux
- design
status: draft
created: 2026-08-12
model_policy:
roles:
role-architect: claude-planning
role-implementer: qwen-coder
role-reviewer: qwen-coder
---
```
**Brief:** `.convoys/add-auth-endpoint/brief-1-implement.md`
```markdown
## Description
Implement the POST /api/auth/register endpoint.
## Scope
- Create route handler at apps/web/app/api/auth/register/route.ts
- Add validation with zod
- Call database insert
- Return 201 on success, 400 on validation error
## Acceptance Criteria
- [ ] Route file exists at correct path
- [ ] zod schema validates email/password
- [ ] Database insert creates user row
- [ ] pnpm lint && pnpm type-check pass
- [ ] Brief is done
```
### Example 2: Doc-Only Convoy
```yaml
---
name: write-sops
classification: server-only
success_metric: 5 SOP documents are published under docs/sops/
skip:
- ia
- ux
- design
- visual
- smoke
status: draft
created: 2026-08-12
model_policy:
roles:
role-doc-writer: auto/coding
---
Use the template at docs/templates/doc-sop.md to write SOPs for:
- How to run a conveyor
- How to add a new model provider
- How to configure OmniRoute
- How to add a new harness
```
### Example 3: Status Transition Flow
```
1. Architect submits: transition(ready → convoy_review) by role-architect
2. Gate approves: transition(convoy_review → ready) by human-gate-1
3. Implementer starts: transition(ready → in_progress) by role-implementer
4. Implementer submits: transition(in_progress → pr_review) by role-implementer
5. Gate merges: transition(pr_review → merged) by human-gate-2
6. Gate releases: transition(merged → released) by human-gate-3
```
## References
- **State machine:** `packages/shared/src/convoy-lifecycle/state-machine.ts`
- **Schemas:** `packages/shared/src/convoy-lifecycle/schemas.ts`
- **Kanban view:** `packages/shared/src/convoy-lifecycle/kanban.ts`
- **Tests:** `packages/shared/src/convoy-lifecycle/state-machine.test.ts`
- **MCP tools:** `apps/mcp-server/src/tools/create-convoy.ts`, `transition_convoy_status.ts`, `log_convoy_event.ts`
- **Worker docs:** `docs/worker.md`
- **Orchestrator docs:** `docs/orchestrator.md`
- **Convoy lifecycle doc:** `docs/convoy-lifecycle.md`
- **Example convoys:** `.convoys/second-brain-os-v01.md`, `.convoys/polish-dag-tiers-cost.md`
## Related Skills
- `backlog-sync` — when briefs map to markdown_backlog_items
- `model-routing` — how model_policy aliases resolve via omniroute
- `openhands-worker` — worker implementation that uses this lifecycle
- `code-review-two-axis` — what role-reviewer does during pr_review

View file

@ -1,463 +0,0 @@
---
name: model-routing
description: Route pipeline roles to AI models via workspace registry and OmniRoute profiles. Understand resolution order, convoy overrides, tier mapping, and OmniRoute integration. Use when configuring models, debugging route failures, or working with model_policy.
---
# Model Routing
Echodo's model routing system connects **pipeline roles** (conductor, architect, implementer, reviewer) to **AI models** via a workspace-scoped registry with layered overrides. The system integrates with OmniRoute for dynamic model selection across 5 profiles (`auto`, `auto/coding`, `auto/coding-pro`, `auto/reasoning`, `auto/reasoning-pro`). This skill teaches you how routes resolve, where to configure them, and how the orchestrator dispatches work.
## When to Activate
Use this skill when:
- Configuring model providers or role routes in Settings → AI
- Understanding `model_policy` in `.convoys/*.md`
- Debugging model resolution failures
- Integrating OmniRoute (cloud or local)
- Logging model metadata in `agent_runs` or `convoy_events`
- Adding new pipeline roles that need model routing
- Testing provider connectivity (`testProvider`)
## Core Concepts
### 1. Four-Layer Resolution Order (First Match Wins)
**Priority from highest to lowest:**
```
1. Brief override ──→ recommended_model in .convoys/<slug>/brief-<N>-*.md
(Direct per-task model selection)
2. Convoy override ──→ model_policy.roles in .convoys/<slug>.md
(Per-convoy role→alias mapping)
3. Workspace route ──→ role_model_routes row in DB
(Settings → AI → Role Routing table)
4. Pipeline template ──→ default role mapping
(Shipped seed data for new workspaces)
```
**Example:** A task has `recommended_model: qwen-coder-mlx`, its convoy has `model_policy.roles: { role-implementer: claude-planning }`, and the workspace route maps `role-implementer → qwen-coder`. The system picks **`qwen-coder-mlx`** (brief override wins).
### 2. Registry Components
| Component | Table | Purpose |
|-----------|-------|---------|
| **Model Provider** | `model_providers` | Concrete model config: API endpoint, auth, params |
| **Role Route** | `role_model_routes` | Which alias serves which role |
| **Convoy Override** | `.convoys/*.md` frontmatter | Per-convoy role mapping |
| **Brief Override** | `.convoys/<slug>/brief-*.md` frontmatter | Per-task specific model |
### 3. OmniRoute Integration
**OmniRoute profiles** map to Echodo roles:
| OmniRoute Profile | Echodo Role | Use Case |
|-------------------|-------------|----------|
| `auto/reasoning` | `role-conductor`, `role-architect` | Planning, classification, design |
| `auto/reasoning-pro` | `role-architect` (escalation), `gate-copilot` | Complex reasoning, escalation path |
| `auto/coding` | `role-implementer`, `role-doc-writer` | Code/docs implementation |
| `auto/coding-pro` | `role-reviewer`, `role-implementer` (escalation) | Code review, complex refactoring |
| `auto` | `chat-default`, fallback | General purpose, fastest |
**How it works:**
1. Echodo resolves alias via `resolveModel(workspace, roleId)`
2. Returns `ResolvedModel` with provider type, model ID, base URL, env var name
3. If provider is `omni_route` type (or base URL points to OmniRoute), OmniRoute resolves the alias to a live endpoint
4. Worker uses resolved endpoint for inference
### 4. The `ResolvedModel` Contract
```typescript
// Runtime structure (not persisted)
type ResolvedModel = {
alias: string; // "qwen-coder-mlx"
providerType: ProviderType; // "openai_compatible"
modelId: string; // "mlx-community/Qwen2.5-Coder-32B-Instruct-4bit"
baseUrl?: string; // "http://192.168.1.42:8000/v1"
apiKey: string; // From process.env[apiKeyEnvVar]
extraHeaders?: Record<string, string>;
defaultParams?: { temperature?: number; maxTokens?: number };
tier?: "premium" | "fast" | "local" | "auto";
};
```
**Key invariants:**
- Never store API keys in DB → only `apiKeyEnvVar` names
- Worker must have env var populated at runtime
- `baseUrl` required for `openai_compatible` provider type
- `tier` is advisory (cost/performance grouping)
## Model Provider Types
### Provider Type Matrix
| Type | Client | API Format | Example Alias | Use For |
|------|--------|------------|---------------|---------|
| `anthropic` | `@ai-sdk/anthropic` | Anthropic Messages API | `claude-planning` | Planning, reasoning, complex tasks |
| `openai` | `@ai-sdk/openai` | OpenAI Chat Completion | `gpt-4o-mini` | General, chat |
| `google` | `@ai-sdk/google` | Gemini REST API | `gemini-flash` | Multimodal, reasoning |
| `openai_compatible` | `@ai-sdk/openai` + `baseURL` | OpenAI-compatible | `qwen-coder-mlx`, `nim-llama-70b` | Local inference, NIM |
| `azure_openai` | `@ai-sdk/openai` + Azure config | Azure OpenAI | `azure-gpt4` | Enterprise Azure deployments |
| `omni_route` | Custom client | OmniRoute API | `auto/coding` | Dynamic routing across 929 models |
### Local Inference Setup Examples
**Ollama (Mac):**
```yaml
alias: qwen-coder
provider_type: openai_compatible
model_id: qwen2.5-coder:32b
base_url: http://192.168.1.42:11434/v1
api_key_env_var: OLLAMA_API_KEY # placeholder, any non-empty string
metadata: { tier: local, runtime: ollama }
```
**MLX (Apple Silicon via mlx-openai-server):**
```yaml
alias: qwen-coder-mlx
provider_type: openai_compatible
model_id: mlx-community/Qwen2.5-Coder-32B-Instruct-4bit
base_url: http://192.168.1.42:8000/v1
api_key_env_var: MLX_API_KEY
metadata: { tier: local, runtime: mlx }
```
**Parallel implementers:** Use `mlx-openai-server` (port 8000) over `mlx_lm.server` (8080) for better batching when orchestrator fans out to multiple implementers.
**OmniRoute Cloud:**
```yaml
alias: auto/coding
provider_type: omni_route
model_id: auto/coding
base_url: https://omniroute-cloud.example.com/v1
api_key_env_var: OMNIROUTE_API_KEY
metadata: { tier: auto }
```
## How to Configure
### 1. Create Provider (Settings → AI → Providers)
```typescript
// Example: Add local MLX provider
{
alias: "qwen-coder-mlx",
display_name: "Qwen2.5 Coder MLX",
provider_type: "openai_compatible",
model_id: "mlx-community/Qwen2.5-Coder-32B-Instruct-4bit",
base_url: "http://192.168.1.42:8000/v1",
api_key_env_var: "MLX_API_KEY",
metadata: { tier: "local", runtime: "mlx" }
}
```
**Test:** Click "Test" button in UI. It sends a small token request to verify connectivity.
### 2. Map Role to Alias (Settings → AI → Role Routing)
```typescript
// Example: Route implementers to local MLX
{
role_id: "role-implementer",
provider_alias_id: "qwen-coder-mlx-id"
}
```
**Supported role IDs:**
- `role-conductor`, `role-architect`, `role-implementer`, `role-reviewer`
- `role-ia-architect`, `role-ux-reviewer`, `role-design-system-auditor`, `role-a11y-auditor`, `role-doc-writer`
- `gate-copilot`, `chat-default`
- Custom roles (string IDs, no migration needed)
### 3. Convoy Override (`.convoys/*.md`)
```yaml
---
name: feature-x
model_policy:
roles:
role-conductor: claude-planning
role-architect: claude-planning
role-implementer: qwen-coder-mlx
role-reviewer: qwen-coder-mlx
escalate_to: claude-planning
---
```
### 4. Brief Override (`.convoys/<slug>/brief-*.md`)
```yaml
---
brief: implement-feature
recommended_model: qwen-coder-mlx
model_tier: local
---
```
## Resolution API
### `resolveModel(workspaceId, roleId, context?)`
```typescript
// In packages/shared/src/orchestrator/resolve.ts
function resolveModel(input: {
workspaceId: string;
roleId: string;
briefContext?: { recommended_model?: string };
convoyContext?: { model_policy?: Record<string, string> };
}): ResolvedModel
```
**Implementation steps:**
1. If `briefContext?.recommended_model` exists, use that alias
2. Else if `convoyContext?.model_policy?.[roleId]` exists, use that alias
3. Else lookup `role_model_routes` row for `(workspaceId, roleId)`
4. Else use default mapping (pipeline template)
5. Load provider config for alias from `model_providers`
6. Validate provider is enabled
7. Read API key from env var
8. Return `ResolvedModel`
### Orchestrator Usage
```typescript
// When dispatching a job
const resolved = await resolveModel({
workspaceId: convoy.workspace_id,
roleId: brief.role_id,
briefContext: { recommended_model: brief.recommended_model },
convoyContext: { model_policy: convoy.model_policy }
});
// Job payload includes model spec
await enqueueJob({
roleId: brief.role_id,
briefSlug: brief.slug,
modelSpec: resolved,
// ...
});
```
## Escalation Logic
When `model_policy.escalate_to` is set or workspace policy defines escalation:
```typescript
// After N implementer failures (default 2)
if (attempts >= 2 && convoy.model_policy?.escalate_to) {
const escalated = await resolveModel({
workspaceId: convoy.workspace_id,
roleId: roleId,
convoyContext: { model_policy: { [roleId]: convoy.model_policy.escalate_to } }
});
// Re-dispatch with escalated model
}
```
## Logging Model Metadata
**`agent_runs` columns:**
| Column | Source | Example |
|--------|--------|---------|
| `model_alias` | `ResolvedModel.alias` | `qwen-coder-mlx` |
| `provider_type` | `ResolvedModel.providerType` | `openai_compatible` |
| `model_id` | `ResolvedModel.modelId` | `mlx-community/Qwen2.5-Coder-32B-Instruct-4bit` |
| `model_tier` | `ResolvedModel.tier` | `local` |
**MCP `complete_task` fields:**
```typescript
await complete_task({
// ...
modelAlias: "qwen-coder-mlx",
modelTier: "local",
tokensTotal: tokens.input + tokens.output,
});
```
**`convoy_events` role_complete:**
```typescript
await log_convoy_event({
// ...
metadata: {
model: "qwen-coder-mlx",
tier: "local",
tokens_in: 12345,
tokens_out: 6789,
}
});
```
## Default Mappings (Pipeline Template)
New workspaces get these seed routes:
| Role ID | Default Alias | Tier | Purpose |
|---------|---------------|------|---------|
| `role-conductor` | `claude-planning` | premium | Kickoff, classification |
| `role-architect` | `claude-planning` | premium | Design, brief decomposition |
| `role-ia-architect` | `claude-planning` | premium | Information architecture |
| `role-ux-reviewer` | `qwen-coder` | fast | UI/UX review |
| `role-implementer` | `qwen-coder` | fast | Code implementation |
| `role-reviewer` | `qwen-coder` | fast | Code review |
| `role-design-system-auditor` | `qwen-coder` | fast | Design system audit |
| `role-a11y-auditor` | `qwen-coder` | fast | Accessibility audit |
| `role-doc-writer` | `qwen-coder` | fast | Documentation writing |
| `gate-copilot` | `claude-planning` | premium | Gate decision support |
| `chat-default` | workspace choice | — | Web chat default |
Operators can override these defaults in Settings.
## Common Patterns
### Setting Up Local MLX + OmniRoute Hybrid
**Goal:** Use local MLX for coding, OmniRoute for reasoning.
1. **Create local MLX provider:** `qwen-coder-mlx` (port 8000)
2. **Create OmniRoute provider:** `auto/reasoning` (cloud endpoint)
3. **Route roles:**
- `role-implementer` → `qwen-coder-mlx`
- `role-conductor` → `auto/reasoning`
- `role-architect` → `auto/reasoning-pro`
4. **Convoy example:**
```yaml
model_policy:
roles:
role-conductor: auto/reasoning
role-implementer: qwen-coder-mlx
```
### Debugging Resolution Failures
**Symptoms:** Job fails with "Cannot resolve model for role-implementer"
**Debug steps:**
1. Check brief frontmatter: `recommended_model` present?
2. Check convoy frontmatter: `model_policy.roles[role-implementer]` present?
3. Query DB: `SELECT * FROM role_model_routes WHERE workspace_id = ? AND role_id = 'role-implementer'`
4. Check provider: Is alias enabled in `model_providers`?
5. Test provider: Run `testProvider` MCP tool for the alias
6. Check env var: Is `api_key_env_var` populated in worker process?
### Migration from Cursor Model Slugs
Legacy convoys may have Cursor slugs (`claude-4.6-opus-high-thinking`). Add legacy alias mapping in workspace metadata:
```json
{
"legacy_model_aliases": {
"claude-4.6-opus-high-thinking": "claude-planning",
"composer-2.5-fast": "qwen-coder"
}
}
```
During resolution, check `legacy_model_aliases[alias]` before failing.
### OmniRoute as the Sole Provider
If you want all routing via OmniRoute:
1. **Create OmniRoute providers** for all 5 profiles
- `auto`, `auto/coding`, `auto/coding-pro`, `auto/reasoning`, `auto/reasoning-pro`
2. **Route roles** accordingly
3. **All inference** goes through OmniRoute cloud endpoint
4. **Advantages:** Dynamic model selection, load balancing, fallbacks
5. **Disadvantages:** Network dependency, cost (vs local free)
## Examples
### Example 1: Workspace with Local + Cloud Mix
**Providers:**
```yaml
# Local MLX (free, fast for coding)
- alias: qwen-coder-mlx
provider_type: openai_compatible
base_url: http://mac:8000/v1
tier: local
# Cloud Claude (costly, good for planning)
- alias: claude-planning
provider_type: anthropic
tier: premium
# OmniRoute fallback
- alias: auto/reasoning
provider_type: omni_route
tier: auto
```
**Role routes:**
- `role-conductor` → `claude-planning`
- `role-implementer` → `qwen-coder-mlx`
- `role-reviewer` → `qwen-coder-mlx`
- `gate-copilot` → `auto/reasoning` (fallback when Claude unavailable)
**Convoy policy:**
```yaml
model_policy:
roles:
role-conductor: claude-planning
role-implementer: qwen-coder-mlx
escalate_to: auto/reasoning
```
### Example 2: Brief Override for Complex Task
**Brief:** `.convoys/optimize-db/brief-5-complex-migration.md`
```yaml
---
brief: complex-db-migration
recommended_model: auto/reasoning-pro
model_tier: premium
---
# Complex schema migration
Needs reasoning about foreign keys and data integrity.
```
The `recommended_model: auto/reasoning-pro` overrides the convoy's `model_policy.roles[role-implementer]`.
### Example 3: Testing Provider Connectivity
```bash
# Via MCP tool
testProvider({ alias: "qwen-coder-mlx" })
# Response
{
"success": true,
"latency_ms": 245,
"model": "mlx-community/Qwen2.5-Coder-32B-Instruct-4bit"
}
# Error case
{
"success": false,
"error": "Connection refused",
"detail": "Check base_url and that mlx-openai-server is running on port 8000"
}
```
## References
- **ADR:** `docs/adr-model-routing.md` (full specification)
- **Schema:** `packages/database/src/schema/model_providers.ts`, `role_model_routes.ts`
- **Resolver:** `packages/shared/src/orchestrator/resolve.ts` (or equivalent)
- **OmniRoute docs:** `.convoys/model-routing-registry.md` if exists
- **Local inference:** `docs/adr-model-routing.md#local-mlx-on-apple-silicon-mac`
- **Worker integration:** `docs/worker.md#model-resolution`
## Related Skills
- `backlog-sync` — brief frontmatter with `recommended_model`
- `convoy-lifecycle` — convoy frontmatter with `model_policy`
- `openhands-worker` — uses `ResolvedModel` for inference
- `omniroute-setup` — operational guide for OmniRoute integration

View file

@ -1,343 +0,0 @@
---
name: tdd-red-green
description: Test-Driven Development loop: write a failing test (Red), make it pass with minimal code (Green), then refactor (Refactor). Write tests before implementation, use seams for isolation, and avoid common TDD anti-patterns. Use when implementing features, fixing bugs, or adding tests to existing code.
---
# TDD: Red-Green-Refactor
A practical test-driven development loop adapted for Echodo's codebase. Write the test first, make it pass with minimal code, then refactor. This skill teaches you the TDD cycle, how to write effective tests, and common pitfalls to avoid.
## When to Activate
Use this skill when:
- Implementing a new feature (add tests first)
- Fixing a bug (write a test that reproduces it, then fix)
- Adding coverage to existing code
- Following Echodo's TDD convention in `role-implementer` briefs
## The Core Loop
```
┌─────────────────────────────────────────────────────────────┐
│ 1. Write failing test (Red) │
│ → Test describes expected behavior │
│ → Run test → fails (expected) │
├─────────────────────────────────────────────────────────────┤
│ 2. Write minimal code to pass (Green) │
│ → Only enough to make test pass │
│ → No "preemptive" abstractions │
│ → Run test → passes │
├─────────────────────────────────────────────────────────────┤
│ 3. Refactor (Refactor) │
│ → Clean up without changing behavior │
│ → Keep tests passing │
│ → Run full test suite │
└─────────────────────────────────────────────────────────────┘
```
## Step 1: Write Failing Test (Red)
### What Makes a Good Test?
1. **Describes behavior, not implementation** — "should create user with valid email" not "should call User.create"
2. **Single assertion per test** (or very few) — easier to diagnose failures
3. **Test name explains the scenario** — `describe('when email is invalid', () => { it('should return 400') })`
4. **Arrange-Act-Assert structure** — clear setup, action, and expectation
### Test Location
Follow Echodo conventions:
- Tests next to source: `src/index.ts` → `src/index.test.ts`
- Test file suffix: `.test.ts` (Vitest)
- Use existing test utilities in `packages/shared/test/` if available
### Example Red Test
```typescript
// apps/web/server/routers/auth.test.ts
describe('POST /api/auth/register', () => {
it('should create a user with valid email and password', async () => {
// Arrange
const payload = {
email: 'test@example.com',
password: 'securepassword123'
};
// Act
const response = await fetch('/api/auth/register', {
method: 'POST',
body: JSON.stringify(payload)
});
// Assert
expect(response.status).toBe(201);
const body = await response.json();
expect(body.user.email).toBe('test@example.com');
});
it('should return 400 when email is invalid', async () => {
// Arrange
const payload = {
email: 'not-an-email',
password: 'securepassword123'
};
// Act
const response = await fetch('/api/auth/register', {
method: 'POST',
body: JSON.stringify(payload)
});
// Assert
expect(response.status).toBe(400);
});
});
```
**Run test → fails** (because route doesn't exist yet)
### When Bug-Fixing: Start with a Failing Test
Instead of:
1. Reading the bug
2. Fixing the code
3. Running full test suite
Do this:
1. Write a test that **reproduces the bug**
2. Run test → fails (proves bug exists)
3. Fix code
4. Run test → passes (proves bug fixed)
5. Run full suite
## Step 2: Write Minimal Code (Green)
### Key Principles
1. **Minimal implementation** — only enough to pass the test
2. **No premature abstraction** — don't build "flexible" systems "just in case"
3. **YAGNI** — You Aren't Gonna Need It
4. **Trust the test** — if the test is right, the code should make it pass
### Example Green Implementation
```typescript
// Minimal route to make test pass
// apps/web/app/api/auth/register/route.ts
import { NextResponse } from 'next/server';
import { z } from 'zod';
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(8)
});
export async function POST(request: Request) {
const body = await request.json();
// Basic validation (the test checks this)
const result = registerSchema.safeParse(body);
if (!result.success) {
return NextResponse.json({ error: 'Invalid input' }, { status: 400 });
}
// Minimal DB insert (simplified for example)
const user = await createUser(result.data.email, result.data.password);
return NextResponse.json({ user }, { status: 201 });
}
// Placeholder - in real code, call actual DB
async function createUser(email: string, password: string) {
return { id: '1', email };
}
```
**Run test → passes**
## Step 3: Refactor
### When to Refactor
- Code is duplicated
- Names are unclear
- Function does too much
- Test is fragile or implementation-specific
- You need to add a new test that requires a small change
### While Refactoring
1. **Keep tests passing** — run after every change
2. **Small steps** — refactor one thing at a time
3. **No new functionality** — only clean up existing code
### Example Refactor
Before:
```typescript
// auth.ts - duplicated validation
function validateEmail(email: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
// Different file, same validation
function isValidEmailAddress(email: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
```
After (extract to shared):
```typescript
// lib/validation.ts
export const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function isValidEmail(email: string): boolean {
return emailRegex.test(email);
}
```
## Seams: Testing Without Dependencies
A **seam** is a place where you can alter behavior without changing the code being tested.
### Types of Seams
| Seam | How to Use | Example |
|------|------------|---------|
| **Dependency Injection** | Pass dependencies as params | `function process(db)` → `process(mockDb)` |
| **Interface/Abstract class** | Swap implementations | `Database` abstract → `MockDatabase` |
| **Factory functions** | Return different instances | `createClient()` → `createMockClient()` |
| **Environment variables** | Control behavior | `if (process.env.NODE_ENV === 'test')` |
| **Configuration objects** | Pass config at runtime | `new Service({ retries: 3 })` |
### Example: Seams in Echodo
```typescript
// Without seam (hard to test)
async function createUser(email: string) {
const db = new PostgresDatabase(); // hard-coded
return db.users.insert({ email });
}
// With seam (testable)
async function createUser(email: string, db: Database = defaultDb) {
return db.users.insert({ email });
}
// In test
const mockDb = { users: { insert: vi.fn() } };
await createUser('test@example.com', mockDb);
expect(mockDb.users.insert).toHaveBeenCalledWith({ email: 'test@example.com' });
```
## Anti-Patterns to Avoid
### 1. Test After Implementation
❌ Write code → write tests
✅ Write failing test → write code → pass → refactor
### 2. Implementation-Specific Tests
❌ `expect(service.createUser).toHaveBeenCalledWith(data)`
✅ `expect(response.status).toBe(201)` (behavior, not implementation)
### 3. Brittle Tests
❌ Test checks exact string: `expect(body).toBe('{"id":"1"}')`
✅ Test checks structure: `expect(body.user.id).toBeDefined()`
### 4. Shared State Between Tests
❌ Global variable modified in one test, relied on by another
✅ Each test sets up its own state
### 5. Testing Too Much
❌ One test checks validation, DB insert, email send, response format
✅ Separate tests for each concern
### 6. No Assertions
❌ `await service.process(data)` — no expect
✅ `expect(result).toEqual(expected)`
### 7. Commented-Out Tests
❌ `// skip this test for now`
✅ Delete or fix the test
## Echodo Testing Conventions
- **Framework:** Vitest (see `packages/*/vitest.config.ts`)
- **Location:** Same directory as source, `.test.ts` suffix
- **Mocking:** Use `vi.fn()` from Vitest, mock via dependency injection
- **Database tests:** Use test database or in-memory alternative
- **Run tests:** `pnpm test` or `pnpm --filter @tasks/<package> test`
## Example: Bug Fix Flow
**Bug report:** "Users can register with empty password"
### Before (wrong approach)
1. Read bug report
2. Add validation to route
3. Run full test suite
4. Deploy
### TDD Approach (right way)
1. **Write failing test:**
```typescript
it('should reject password shorter than 8 characters', async () => {
const response = await register({ email: 'test@example.com', password: 'short' });
expect(response.status).toBe(400);
});
```
2. **Run test → fails** (shows bug exists)
3. **Fix code minimally:**
```typescript
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(8)
});
```
4. **Run test → passes** (proves fix works)
5. **Run full suite** (no regressions)
6. **Refactor** if needed
## Integration with Other Skills
- **code-review-two-axis:** Reviewer checks for TDD pattern in implementation
- **backlog-sync:** When testing markdown import/export features
- **convoy-lifecycle:** When testing convoy status transitions
- **model-routing:** When testing model resolution logic
## Running Tests in Echodo
```bash
# Run all tests
pnpm test
# Run specific package
pnpm --filter @tasks/web test
pnpm --filter @tasks/shared test
# Watch mode
pnpm --filter @tasks/web test:watch
# Coverage
pnpm test --coverage
```
## References
- **Original skill:** https://github.com/mattpocock/skills/blob/main/skills/engineering/tdd/SKILL.md
- **Kent Beck:** "Test-Driven Development: By Example"
- **Martin Fowler:** "Refactoring" - Chapter on code smells
- **Vitest docs:** https://vitest.dev/
- **Echodo test config:** `packages/*/vitest.config.ts`

View file

View file

@ -1,123 +0,0 @@
# Convoys (Echodo / tasks repo)
A **convoy** is a multi-PR work-stream coordinated by the agent pipeline. One convoy = one feature, bug fix, or epic slice. Each convoy is a Markdown file in this directory plus an optional sub-directory of implementer briefs.
> **Not Cursor Plan mode.** Pipeline convoys live here (`.convoys/*.md`). Cursor's native Plan feature writes to `.cursor/plans/*.plan.md` — a different artifact. For pipeline work, invoke `role-conductor` (when L2 roles are installed) and write `.convoys/<slug>.md`. See [`.cursor/rules/convoy-planning.mdc`](../.cursor/rules/convoy-planning.mdc).
Product backlog items still live under `plans/` and sync to Echodo via markdown import. Convoys track **how** a slice is executed (roles, briefs, gates) — not the canonical plan tree.
## File layout
```text
.convoys/
├── README.md (this file)
├── .metrics.jsonl (local analytics — gitignored)
├── <slug>.md (convoy file — role-conductor)
└── <slug>/
├── brief-1-<kebab-title>.md (role-architect)
└── ...
```
## Convoy file format
Frontmatter (set by `role-conductor`, appended by other roles):
```yaml
---
name: <kebab-slug>
classification: feature | hotfix | docs-only | infra-only | server-only | config-only
success_metric: <one sentence>
skip:
- ia
status: open | in-progress | merged | shipped | abandoned
created: <YYYY-MM-DD>
model_policy:
roles:
role-conductor: claude-planning
role-implementer: qwen-coder
escalate_to: claude-planning
---
```
Use **registry alias names** in `model_policy` (not Cursor model slugs). See [docs/adr-model-routing.md](../docs/adr-model-routing.md).
Body sections (typical order):
1. `## Why` (Conductor)
2. `## Scope` (Conductor)
3. `## Roles invoked` (Conductor)
4. `## Todos` (Conductor → Architect)
5. `## IA` (IA Architect — skip if `ia` in `skip:`)
6. `## UX` (UX Reviewer)
7. `## Architecture` (Architect)
After Architect, briefs live in `.convoys/<slug>/brief-N-*.md`. Implementers read only their brief.
## Skip flags
| Flag | Skips |
| --- | --- |
| `ia` | IA Architect |
| `ux` | UX Reviewer |
| `arch` | Architect (rare — use only for trivial hotfixes) |
| `test` | Component tests |
| `review` | Reviewer |
| `visual` | Visual diff |
| `a11y` | A11y auditor |
| `design` | Design-system auditor |
| `smoke` | Staging smoke |
| `qa` | Manual QA |
| `docs` | Doc Writer |
| `flag` | Flag rollout |
Never skipped (human gates): `plan-approval`, `pr-merge`, `prod-promote`.
**Echodo server work** often uses `classification: server-only` with `skip: [ia, ux, design, a11y, visual, smoke]`.
## Status lifecycle
- `open` — convoy created; no briefs in flight
- `in-progress` — at least one brief active or PR open
- `merged` — all briefs merged; release pending
- `shipped` — in production
- `abandoned` — closed without shipping
## Kickoff ritual (this repo)
1. Open Cursor in the `tasks` repo on a feature branch.
2. Prompt: *"Start a new convoy: \<idea\>. Success = \<metric\>."*
3. Conductor writes `.convoys/<slug>.md` with `classification` and `skip` flags.
4. **Echodo dogfood:** each implementer session should `claim_task` on the matching `plans/.../Task-*.md`, implement, then `complete_task` with `modelAlias` / `modelTier`.
5. Architect maps briefs 1:1 to tasks when possible.
**Example (Epic 1 — done):** model provider registry convoy with `server-only`, briefs aligned to `Epic-model-provider-registry` tasks.
**Next convoys:** orchestrator (`Epic-apps-orchestrator`), convoy lifecycle MCP (`Epic-convoy-lifecycle-mcp`), connected repos (`Epic-connected-repos`).
## Self-analytics
Each L2 role appends one event via:
```bash
bash scripts/log-convoy-event.sh role=role-implementer convoy=<slug> brief=1 \
duration_s=120 model=qwen-coder model_tier=local outcome=complete
```
`.convoys/.metrics.jsonl` is **gitignored** by default (metadata only, no prompts). Remove that line from `.gitignore` if you want team-visible metrics in git.
Validate a line was written:
```bash
bash scripts/log-convoy-event.sh role=role-conductor convoy=test duration_s=1
tail -1 .convoys/.metrics.jsonl | python3 -m json.tool
```
## Multitask (Cursor 3.2+)
Use `/multitask` for audit fan-out (`role-reviewer` + design + a11y) or parallel implementers when architect marks briefs as disjoint. Set `multitask_group=audit-<convoy>-<pr>` on log lines for wall-clock analytics.
## Related docs
- [Plan automation foundation](../plans/Plan-automation-foundation/Plan-automation-foundation.md)
- [ADR model routing](../docs/adr-model-routing.md)
- [AGENTS.md](../AGENTS.md) — repo commands and multitenancy rules

View file

@ -1,117 +0,0 @@
---
name: ai-agent-studio
classification: feature
success_metric: The existing AI rail destination provides accessible Chat and Agents modes, versioned agent foundations, and a provider-agnostic service that streams validated agent draft proposals.
skip: []
status: in-progress
created: 2026-09-25
model_policy:
roles:
role-conductor: claude-planning
role-architect: claude-planning
role-implementer: qwen-coder
role-reviewer: qwen-coder
escalate_to: claude-planning
---
# AI agent studio and marketplace
## Why
The existing AI rail destination is already the natural home for AI interaction, but it currently provides only one stateless full-page chat. Users should be able to chat with AI or switch modes and use AI itself to create, review, install, and manage reusable agents. This should feel like one coherent AI workspace rather than separate Chat, Settings, and marketplace products.
Canonical backlog: `plans/Plan-workspace-communications/Epic-agent-participation/Epic-agent-participation.md`.
## Scope
- Brief 1: refactor `/<workspaceSlug>/ai` into URL-backed Chat and Agents tabs with one shared composer shell.
- Brief 2: add first-class, versioned agent persistence and shared validation/permission contracts.
- Brief 3: turn Agents-mode prompts into validated clarification and structured draft-patch events; it does not implement the editor or persistence.
- Follow-on backlog tasks add the builder editor/private-draft persistence, marketplace library UI, permission review/sandbox/activation, durable jobs, direct saved-agent chat, channel membership, harness events, and chat delivery.
- Preserve the existing Sparkles `AI` rail entry; do not add a separate Agents rail item.
- Reuse model routing, harness routing, template UI patterns, and workspace authorization without conflating those records with agent identity.
Out of scope: channel participation/jobs, commercial marketplace billing/ratings, arbitrary public publishing, and autonomous channel access. Those follow after the agent registry exists.
## Role activity and kickoff — 2026-10-22
- User approved beginning the integrated chat/agent direction, not merge or release.
- This session performed conductor reconciliation and architect access/registry/dependency review. Existing IA/UX/architecture sections are design guidance, not evidence of historical role completion; independent IA, UX and reviewer completion is not asserted.
- Brief 1 and its canonical task are `in_progress`: an independent implementer owns the AI shell app changes. This planning session changes no app code. Brief 2 is ready; Brief 3 waits for its dependencies.
- `in-progress` is local README bookkeeping for the active brief. No DB registration, task claim, event logging or MCP transition occurred; those tools are unavailable here.
## Full product versus this foundation
These three briefs provide a shell, schema/contracts, and inert builder service—not a working marketplace, install API, contextual chat or agent execution. The [registry API task](../plans/Plan-workspace-communications/Epic-agent-participation/Task-agent-registry-api.md) and [install/lifecycle task](../plans/Plan-workspace-communications/Epic-agent-participation/Task-agent-install-lifecycle.md) now explicitly own the missing server boundaries. Library/editor/review tasks consume them rather than creating duplicate registries or lifecycle endpoints.
AI Chat, Agents creation/library, channel mentions and durable jobs use **one workspace agent registry**. Installation/activation does not grant conversation access. Follow the [plan's context and mention-to-add contracts](../plans/Plan-workspace-communications/Plan-workspace-communications.md): channel addition or expanded access needs authorized explicit confirmation, reviewed version/history/tool scope, idempotency, and revocation checks; workspace role alone cannot grant private-object access.
## Todos
- [x] User approved kickoff of the AI Chat/Agents direction on 2026-10-22; merge/release gates remain pending.
- [ ] Implement the Chat/Agents shell without regressing existing AI chat.
- [ ] Implement versioned agent persistence and shared registry contracts.
- [ ] Implement provider-agnostic conversational clarification and validated structured draft-patch streaming.
- [ ] Reviewer validates tenant isolation, permission safety, provider agnosticism, accessibility, and spec alignment.
- [ ] Human performs merge and release gates.
## IA
- Keep `/<workspaceSlug>/ai` as the canonical route and Sparkles rail destination.
- Use URL-backed top-level tabs: `Chat` and `Agents`; Chat remains the default for old links.
- The shared composer stays centered and visually stable across modes.
- Chat preserves current conversation behavior; saved-agent selection arrives in the direct-agent-chat follow-on, not the shell brief.
- Agents renders scaffolded library regions under/around the composer; the follow-on marketplace task populates Featured/Templates, Installed, Created by your workspace, and Drafts.
- This convoy provides placeholders for future agent cards/detail actions and focuses `Create new` on the builder composer.
- Brief 3 exposes testable builder service events; the follow-on editor wires them into Agents mode and turns them into editable, persisted drafts.
## UX
- Switching tabs changes intent, placeholder, suggestions, submit label, and result content—not the fundamental composer size or interaction language.
- Agents-mode examples include outcome-oriented prompts rather than raw configuration fields.
- Validated builder events expose what the proposed agent could read, requested tools, invocation policy, budget, and requested model role/harness route; the follow-on editor presents the full review experience.
- Draft proposal generation never persists, activates, publishes, grants tools, or joins channels automatically.
- Marketplace lifecycle/provenance visuals are owned by the follow-on library task; this convoy reserves compatible states in the shell.
- Keyboard focus survives tab switches where sensible; tabs, cards, dialogs, draft fields, and permission review are accessible.
## Architecture
### Product boundaries
- An agent definition is a stable service identity and versioned policy bundle.
- A role is a requested capability used by model/harness routing; it is not an agent identity.
- A model provider or OmniRoute profile performs/routes inference; it is not an agent.
- A harness executes a run; it is not an agent.
- An `agent_run` is execution history and references the pinned agent version where applicable.
### Builder flow
1. The Agents tab submits a workspace-authorized builder request through a dedicated typed streaming endpoint, not the normal chat endpoint.
2. The builder resolves a model role through existing routing and returns validated clarification or structured draft-patch events.
3. This convoy stops at the typed event stream; it does not edit or persist drafts.
4. A follow-on editor consumes those events and saves private draft versions owned by the current member.
5. A later review flow handles permission approval, sandbox tests, install, activation, and workspace publication.
### Marketplace model
- Schemas/contracts support global curated templates that are readable but not tenant-owned; catalog seeding and browse APIs are follow-on work.
- Workspace agents/installations, drafts, permissions, and versions are always scoped by `workspace_id`.
- Installation contracts require workspace-owned versioned configuration and prohibit copying secrets.
- First release supports curated templates and workspace sharing, not open public submissions, ratings, or payments.
### Security
- Generated output is untrusted and must pass zod validation plus server-side policy checks.
- Tool grants, channel access, budgets, and activation are never inferred as silent side effects.
- Provider credentials remain referenced through existing provider configuration and are never embedded in an agent definition or prompt transcript.
- Agent handles are unique per workspace; immutable IDs/version IDs drive references and audit history.
## Briefs
| Brief | Backlog task | Depends on |
| --- | --- | --- |
| 1 | `Task-ai-workspace-tabs.md` | none |
| 2 | `Task-agent-registry-marketplace.md` | none; may proceed in parallel with brief 1 |
| 3 | `Task-ai-assisted-agent-builder.md` | briefs 1 and 2 |
Follow-on scheduling: registry API → install/lifecycle → library and editor/review UI; durable jobs → direct-chat and reviewed channel membership; normalized harness → sandbox execution and run delivery. UI-only review scaffolding can precede the harness, but sandbox execution cannot. Contextual channel integration additionally waits for a verified object access policy. These are canonical backlog dependencies, not extra deliverables of the three foundation briefs.

View file

@ -1,50 +0,0 @@
---
brief: 1
convoy: ai-agent-studio
role: role-implementer
title: AI workspace Chat and Agents tabs
status: in_progress
priority: P1
slice_dependencies: []
recommended_model: auto/coding
model_tier: standard
---
# Brief 1 — `Task-ai-workspace-tabs.md`
## Execution note — 2026-10-22
User approved kickoff. The code slice and `/api/chat` role-boundary hardening are implemented; standards/spec reviews are clean, web tests pass (34 tests), and web type-check/lint pass with pre-existing warnings only. Browser verification remains required for native back/forward behavior, Radix keyboard focus, streaming scroll restoration, responsive layout, and screen-reader announcements, so the brief remains in progress. No merge/release or DB transition is claimed. Agents remains an explicit placeholder/adapter stub: the shell must not claim working creation, installation, mention-to-add, saved-agent execution, or private-context access.
## Goal
Refactor the existing workspace AI page into accessible, deep-linkable Chat and Agents modes that share one visual composer shell without regressing current streaming chat.
## Files
- `apps/web/app/(app)/[workspaceSlug]/ai/page.tsx` — preserve the canonical route; split orchestration from presentation.
- `apps/web/components/ai/` — add a workspace frame, URL-backed tab switcher, shared composer shell, Chat mode, and Agents placeholder/library frame.
- Existing `/api/chat` integration — preserve for Chat mode only.
- Adjacent component tests.
## Required design
- Keep the existing `AI` Sparkles item in `apps/web/components/layout/icon-rail.tsx`; do not add an Agents rail item.
- `Chat` is the default for `/<workspaceSlug>/ai`; tab state is URL-backed and browser-navigation safe.
- The composer keeps the same dimensions, shape, attachment/action affordances, and keyboard conventions in both modes.
- Mode-specific behavior is explicit: Chat submits conversation messages; Agents emits typed builder requests through a separate adapter stub until Brief 3.
- Tab changes preserve each mode's independent draft input and relevant scroll state.
- Reuse existing UI primitives and responsive shell patterns.
## Acceptance
- Old AI links still open functional Chat mode.
- Users can switch/deep-link/refresh/back-forward between Chat and Agents.
- Chat streaming, error, stop, and focus behavior remains functional and workspace-scoped.
- Agents shows the shared composer plus library loading/empty/error scaffolding without submitting to `/api/chat`.
- Tabs and composer pass keyboard/screen-reader checks.
- `pnpm lint && pnpm type-check && pnpm test` pass.
## Prompt
Read the current AI page, `/api/chat` route, icon rail, tRPC provider, and UI primitives before editing. Separate shared presentation from mode-specific state; do not introduce the registry schema or persist generated agents in this brief.

View file

@ -1,51 +0,0 @@
---
brief: 2
convoy: ai-agent-studio
role: role-implementer
title: Versioned agent registry schema and contracts
status: ready
priority: P1
slice_dependencies: []
recommended_model: auto/coding
model_tier: standard
---
# Brief 2 — `Task-agent-registry-marketplace.md`
## Goal
Add durable workspace agent definitions and shared validation/permission contracts that later draft, marketplace, and builder APIs can consume.
## Files
Expected scope; confirm names against current exports.
- `packages/database/src/schema/` and a generated migration — template, agent, version, and installation/provenance records.
- `packages/shared/src/` — zod contracts for identity, configuration, policies, versions, permissions, and marketplace filters.
- Adjacent schema/contract/permission-policy tests.
## Required design
- Agents are not `users`, role IDs, provider aliases, harness aliases, or `agent_runs`. This is the single identity/version registry for AI Chat, Agents builder/library, mentions, installations and jobs; never add a second channel-agent registry.
- Separate lifecycle/tool approval roles from context delegation and per-conversation access. Installation/activation grants no channel/private-object access. Follow the parent plan's permission matrix; schemas/contracts must support reviewed, version-bound scoped grants without inventing project ACL tables.
- Workspace agents have immutable IDs, unique workspace handles, versioned configuration, lifecycle status, provenance, and creator/audit metadata.
- Configuration references model role and harness route aliases but contains no provider secrets.
- Schemas support global curated-template provenance and workspace-owned installations without storing provider secrets.
- Reusable policy contracts encode: members manage their own private drafts/use active agents; `admin`/`owner` manage template installation, shared lifecycle, and ordinary allowlisted grants; only `owner` approves mutating sandbox policy or sensitive workspace-wide tools.
- Contracts expose lifecycle, visibility, provenance, permissions, and update state for later APIs and UI.
## Follow-on boundaries
[Registry API](../../plans/Plan-workspace-communications/Epic-agent-participation/Task-agent-registry-api.md) owns catalog/private-draft procedures. [Install lifecycle](../../plans/Plan-workspace-communications/Epic-agent-participation/Task-agent-install-lifecycle.md) owns reviewed lifecycle transitions. These are not hidden deliverables of this schema brief; UI tasks consume them.
## Acceptance
- Tenant ownership, handle uniqueness, and supporting indexes/foreign keys are enforced at the schema/contract layer; workspace-scoped query enforcement is required in the follow-on CRUD APIs.
- Version/updated-at precondition contracts prevent silent last-write-wins behavior in later APIs.
- Contracts cover capabilities, requested/granted tools, context/invocation policy, model role, harness route, budget, provenance, permissions, and version changes without weakening private-draft boundaries.
- No schema or contract treats existing workflow role IDs as agent identities.
- `pnpm db:generate`, `pnpm lint`, `pnpm type-check`, and relevant tests pass.
## Prompt
Read existing model/harness registry schemas and settings UI, template CRUD/editor patterns, workspace authorization helpers, and schema exports. Keep this brief focused on registry/marketplace; do not implement channel participation or builder inference.

View file

@ -1,49 +0,0 @@
---
brief: 3
convoy: ai-agent-studio
role: role-implementer
title: Conversational AI agent builder
status: blocked
priority: P1
slice_dependencies:
- brief-1-ai-workspace-tabs
- brief-2-agent-registry-marketplace
recommended_model: auto/coding
model_tier: standard
---
# Brief 3 — `Task-ai-assisted-agent-builder.md` (service slice)
## Goal
Implement the provider-agnostic builder service and streaming contracts that transform an Agents-mode request into validated clarification or structured draft-patch events. UI editing and private-draft persistence remain a follow-on slice.
## Files
Expected scope; confirm routing conventions before implementation.
- `packages/shared/src/` — versioned builder request/event/draft-patch schemas.
- `packages/ai/src/prompts/` — agent-builder prompts and structured-output instructions.
- `apps/web/app/api/` or a suitable streaming tRPC boundary — workspace-authorized builder stream separate from `/api/chat`.
- Adjacent service tests for validation, workspace authorization, provider abstraction, stream event ordering, and malformed output.
## Required design
- The builder requests an existing model role/capability and uses provider resolution; no direct OmniRoute/provider branch exists in UI or route code.
- Model output is untrusted. Only versioned zod-validated clarification and draft-patch events may affect client state.
- The service emits deterministic base revision IDs and structured patches so a follow-on UI can maintain conflict-safe draft history.
- Generated requested tools, channel access, activation, and workspace publication remain inert, unapproved fields in validated output.
- The endpoint does not persist, publish, install, activate, or execute an agent.
## Acceptance
- Outcome-oriented prompts create valid drafts or targeted clarification questions.
- The service supports initial requests and follow-up refinements through validated clarification/draft-patch events.
- Invalid or policy-violating model output becomes a typed error event and is never persisted.
- Requested tools and access remain marked unapproved and cannot be exercised by this endpoint.
- No plaintext provider secret appears in events, logs, or client payloads.
- `pnpm lint && pnpm type-check && pnpm test` pass; `pnpm build` passes for changed server/AI exports.
## Prompt
Read model routing, `packages/ai` provider adaptation, prompt conventions, registry contracts from Brief 2, and workspace guards. Use the existing AI SDK only as a normalized model API; keep the builder compatible with OmniRoute and other providers through the resolved model contract. Do not build the editor or draft CRUD in this brief.

View file

@ -1,53 +0,0 @@
---
name: model-provider-registry
classification: server-only
success_metric: Operators configure Anthropic + local coder aliases and per-role routing from Settings; agent runs log modelAlias.
skip:
- ia
- ux
- design
- a11y
- visual
- smoke
status: shipped
created: 2026-07-07
model_policy:
roles:
role-conductor: claude-planning
role-architect: claude-planning
role-implementer: qwen-coder
role-reviewer: qwen-coder
escalate_to: claude-planning
---
# Model provider registry (Epic 1)
## Why
Echodo needs a workspace-scoped model registry and `resolveModel()` contract before orchestrator/worker can dispatch unattended jobs. Epic 1 on `feat/automation-foundation` delivered the full slice.
## Scope
- `packages/shared` — schemas + `resolveModel()`
- `model_providers` / `role_model_routes` tables
- `api.ai.models` tRPC + Settings UI
- `agent_runs` model columns + MCP `complete_task` fields
## Roles invoked
Conductor → Architect (briefs = Epic 1 tasks) → Implementer (per task) → Reviewer (PR).
## Outcome
Shipped in PR on `feat/automation-foundation`. Epic 2 convoy scaffolding added after merge for ongoing automation-foundation work.
## Briefs (architect mapping)
| Brief | Task slug |
| --- | --- |
| 1 | `shared-schemas-resolve-model` |
| 2 | `db-model-providers-migration` |
| 3 | `trpc-models-router` |
| 4 | `settings-ai-providers-ui` |
| 5 | `settings-ai-role-routing-ui` |
| 6 | `agent-runs-model-logging` |

View file

@ -1,68 +0,0 @@
---
name: owner-cockpit-v02
classification: server-only
success_metric: Fleet webhooks close PR loop (merged), completion gallery shows summaries+screenshots, ops copilot answers "what needs me?" with links — all from web without dumping plans/
skip:
- ia
- design
- a11y
- visual
- smoke
status: merged
created: 2026-08-25
model_policy:
roles:
role-conductor: claude-planning
role-architect: claude-planning
role-implementer: qwen-coder
role-doc-writer: qwen-coder
role-reviewer: qwen-coder
escalate_to: claude-planning
---
# Owner cockpit v02 — forge webhooks + artifacts + copilot (post-v0.1)
## Why
v0.1 made second brain publishable (MCP create, harness-agnostic, Plans tree, Fleet). Worker closeout closed Epic 7 with doc_review→published + draft PR + operator notes + doc-writer. Remaining owner vision per `plans/Plan-automation-foundation/Plan-automation-foundation.md:222` — "fleet → Plans → Start → Go → PR → gates → completion + copilot" — still misses PR loop close (Epic 8), owner review pack (Epic 11), and ops chat (Epic 12). This convoy finishes the cockpit so a solo can land in Fleet, triage, and ask copilot without grepping.
Decision: `docs/decisions/2026-08-25-second-brain-os-pivot.md`.
## Scope
1. **Forge webhooks** `Epic-forge-webhooks` — `POST /api/webhooks/forge` (GitHub/Forgejo) verifies `X-Hub-Signature`/`X-Forgejo-Signature` via `FORGE_WEBHOOK_SECRET`, finds `echodo-convoy: <uuid>` marker in PR body, transitions `pr_review→merged` (`human-gate-2` automation path) and writes `convoy_events`.
2. **PR checks recovery panel** `Task-pr-checks-recovery-panel.md` — Mission control detail shows PR checks + retry.
3. **Completion artifacts** `Epic-owner-completion-artifacts` — `completion_summary` on `published/merged→released` (markdown summary + optional screenshot placeholder), gallery list, tRPC `listArtifacts`.
4. **Ops copilot** `Epic-ops-copilot` — `ops-copilot-context-tools` (fleet + convoy + backlog queries scoped to workspace) + `ops-copilot-ui` chat panel + `draft-work` from chat (create Task via MCP).
5. Doc-writer: SOP for webhook secret rotation + copilot prompt.
Out of scope: LiteLLM gateway, SQLite single-tenant, preference learning.
## Roles invoked
Conductor → Architect (4 briefs) → Implementer per brief (disjoint: webhooks, artifacts, copilot) → Doc-writer (SOP) → Reviewer.
## Todos
- [ ] Brief 1 forge webhook receiver
- [ ] Brief 2 completion gallery + summaries
- [ ] Brief 3 ops copilot (context tools + UI)
- [ ] Brief 4 doc-writer SOPs
## IA
Skipped (`ia` skip set — server-only, no new top-level entities beyond `completion_artifacts` optional table; reuse `convoy_events metadata.prUrl` + `objects content`).
## UX
Partial — `ux` not skipped for copilot: chat panel reuses `components/ui/*`.
## Architecture
- Webhooks: `apps/web/app/api/webhooks/forge/route.ts` HMAC verify, `reconcile` via `transitionConvoy` `packages/shared/src/convoy-lifecycle/state-machine.ts:90` `pr_review→merged`.
- Artifacts: optional `completion_artifacts` table or reuse `objects content.completionSummary` + `convoy_events metadata.completionScreenshot`.
- Copilot: `apps/web/server/routers/copilot.ts` with `resolveModel(gate-copilot)` + context tools `fleet, convoys.list, backlog.listTree` scoped to `workspace_id`; UI `components/copilot/ops-copilot-panel.tsx`.
## Dependencies
- v0.1 + worker-closeout (harness, doc_review, prUrl)

View file

@ -1,36 +0,0 @@
---
brief: 1
convoy: owner-cockpit-v02
role: role-implementer
title: Forge webhook receiver — GitHub/Forgejo → pr_review→merged
status: ready
priority: P0
slice_dependencies: []
recommended_model: qwen-coder
model_tier: local
---
# Brief 1 — `Task-forge-webhook-receiver.md` (Epic 8)
## Goal
`POST /api/webhooks/forge` verifies signature via `FORGE_WEBHOOK_SECRET`, finds `echodo-convoy: <uuid>` marker in PR body (from `apps/worker/src/git/pr.ts`), transitions `pr_review→merged` via `transitionConvoy` `packages/shared/src/convoy-lifecycle/state-machine.ts:90` as `automation`, writes `convoy_events`.
## Files
- `apps/web/app/api/webhooks/forge/route.ts` NEW — parse `X-Hub-Signature-256` (GitHub) or `X-Forgejo-Signature` (Forgejo) HMAC sha256 `FORGE_WEBHOOK_SECRET`, handle `pull_request: closed & merged=true` + `pr.body` marker regex `/echodo-convoy:\s*([0-9a-f-]{36})/`, lookup convoy by `id`, validate `status===pr_review`, call `transitionConvoy(db, {workspaceId, convoyId, toStatus:merged, actor:automation, reason: pr#N merged})`. Return 200 + `ok` even if no convoy (don't leak). Rate-limit per IP.
- `apps/web/server/lib/forge/adapters.ts` — reuse `ForgeAdapter` if needed for verify.
- `docs/operations/live-e2e-checklist.md` — add webhook step.
## Acceptance
- Forgejo/GitHub payload with `body: "… echodo-convoy: <id> …"` + valid HMAC transitions `pr_review→merged`; invalid signature 401; no marker 200 no-op.
- Existing `pnpm lint && pnpm type-check` pass.
## Prompt
You are `role-implementer` brief 1. Read `packages/shared/src/convoy-lifecycle/state-machine.ts`, `apps/web/server/lib/forge/adapters.ts`, `apps/worker/src/git/pr.ts`, `docs/convoy-lifecycle.md`. Keep verify constant-time, workspace-scoped.
## Depends
None.

View file

@ -1,37 +0,0 @@
---
brief: 2
convoy: owner-cockpit-v02
role: role-implementer
title: Owner completion artifacts — summary + gallery
status: ready
priority: P1
slice_dependencies: []
recommended_model: qwen-coder
model_tier: local
---
# Brief 2 — `Epic-owner-completion-artifacts` (Epic 11)
## Goal
When `merged/published → released` (`human-gate-3`), generate completion summary markdown + optional screenshot placeholder, list in gallery for owner review. No browser automation v1 — screenshot is upload placeholder.
## Files
- `packages/database/src/schema/completion_artifacts.ts` NEW — `id uuid PK, workspace_id fk, convoy_id fk objects.id, summary_md text, screenshot_url text nullable, created_by fk users, created_at`. Or reuse `objects content.completionSummary` — pick minimal table for gallery query.
- `apps/web/server/routers/convoys.ts` — add `complete` mutation (`doc_review→published→released` or `pr_review→merged→released`) that writes artifact + transitions `released` as `human-gate-3`; `listArtifacts` query for gallery.
- `apps/web/app/(app)/[workspaceSlug]/artifacts/page.tsx` NEW — gallery grid, click → detail with summary + PR link + convoy timeline (`convoy_events`).
- `packages/shared/src/worker` — generate summary template: `## Summary\nConvoy ${slug} ${classification} — ${successMetric}\nBriefs: ...\nModel: ... Harness: ...`.
## Acceptance
- Completing `doc_review→published→released` creates artifact row visible in `/artifacts`; screenshot upload placeholder shows.
- `pnpm lint && pnpm type-check` pass.
## Prompt
You are `role-implementer` brief 2. Read `plans/Plan-automation-foundation/Epic-owner-completion-artifacts/Epic-owner-completion-artifacts.md`, `packages/shared/src/convoy-lifecycle/state-machine.ts:122`, `apps/web/server/routers/convoys.ts:237`. Keep artifact generation deterministic, no LLM call v1 (template only).
## Depends
None — disjoint from webhooks.

View file

@ -1,39 +0,0 @@
---
brief: 3
convoy: owner-cockpit-v02
role: role-implementer
title: Ops copilot — context tools + UI + draft work from chat
status: ready
priority: P1
slice_dependencies: []
recommended_model: claude-planning
model_tier: premium
---
# Brief 3 — `Epic-ops-copilot` (Epic 12)
## Goal
`ops copilot` answers "what needs me?" with links, triages, and can draft a `Task` from chat. Uses `gate-copilot` routing but broader context.
## Files
- `apps/web/server/routers/copilot.ts` NEW — `opsCopilot.generateText` tRPC: `resolveModel(gate-copilot)` `packages/shared/src/model-routing/index.ts`, context tools: `fleetStatus()` (reuse `convoys.fleet` logic), `convoyGet(workspaceSlug, convoyId)`, `backlogListTree(workspaceSlug)` (reuse `backlog.listTree`), `backlogSearch` (grep `markdown_backlog_items`). Tools are read-only, workspace-scoped, bounded. System prompt: "You are ops copilot for second brain OS… respond with links [workspace/slug]."
- `apps/web/server/routers/harnesses.ts` — no change, just reuse `resolveModel`.
- `apps/web/components/copilot/ops-copilot-panel.tsx` NEW — chat panel (reuses `components/ui/*`, `ai` SDK `useChat` if available or plain `generateText` loop), shows `fleet → needs you` citations, button `Draft Task` → calls `backlog.createTask` via `create-backlog-item` shape.
- `apps/web/app/(app)/fleet/page.tsx` — add copilot FAB or sidebar embed.
## Acceptance
- "What needs me?" returns gate inbox items with `[fleet]` links to convoys.
- `Draft Task` from copilot creates `Task-*.md` via same path as Plans tree (tool `create-backlog-item` logic).
- Workspace-scoped — no cross-tenant leakage.
- `pnpm lint && pnpm type-check` pass.
## Prompt
You are `role-implementer` brief 3. Read `plans/Plan-automation-foundation/Epic-ops-copilot/Epic-ops-copilot.md`, `docs/adr-model-routing.md`, `apps/web/server/routers/convoys.ts:267` `gateCopilot` as reference, `docs/decisions/2026-08-25-second-brain-os-pivot.md`. Keep tools small (<3 tools), no secrets in prompt.
## Depends
None — can run parallel with 1,2.

View file

@ -1,36 +0,0 @@
---
brief: 4
convoy: owner-cockpit-v02
role: role-doc-writer
title: Doc-writer SOPs — webhook secret rotation + copilot prompt
status: ready
priority: P2
slice_dependencies: [1]
recommended_model: qwen-coder
model_tier: local
---
# Brief 4 — `role-doc-writer` SOPs for owner cockpit
## Goal
Document rotation and prompts so future agents read them as second-brain context, not just code.
## Files
- `docs/operations/webhook-secret-rotation.md` NEW — one page: where `FORGE_WEBHOOK_SECRET` lives (Coolify env), how to rotate (generate `openssl rand -hex 32`, update Forgejo webhook + Coolify `FORGE_WEBHOOK_SECRET`, test via `curl -H 'X-Hub-Signature-256: sha256=...' http://localhost:3000/api/webhooks/forge`).
- `docs/copilot-prompt.md` NEW — system prompt for `gate-copilot` and `ops copilot` (reuse `convoys.ts:gut` prompt but versioned in `docs/` for harness portability).
- `docs/examples/sop-published-flow.md` NEW — one SOP showing `Task-write-sop → doc_review → published → artifact gallery` e2e with screenshots placeholder.
## Acceptance
- Three docs exist, linked from `docs/README` or `docs/operations/live-e2e-checklist.md`.
- No code, only markdown; `pnpm lint` still passes (markdown ignored).
## Prompt
You are `role-doc-writer` brief 4. Read `docs/convoy-lifecycle.md`, `apps/web/app/api/webhooks/forge/route.ts` (once brief 1 lands) to document accurately. Keep docs small, imperative, no narration.
## Depends
Brief 1 — needs webhook route to document accurately.

View file

@ -1,58 +0,0 @@
---
name: polish-dag-tiers-cost
classification: server-only
success_metric: Cost caps + DAG viz + tiers + preflight + checks panel ship; Epic 7 and model-provider follow-ons close
skip:
- ia
- a11y
- visual
- smoke
status: merged
created: 2026-08-25
model_policy:
roles:
role-conductor: claude-planning
role-architect: claude-planning
role-implementer: qwen-coder
role-doc-writer: qwen-coder
role-reviewer: qwen-coder
escalate_to: claude-planning
---
# Polish — DAG + tiers + cost caps + preflight + checks (closing follow-ons)
## Why
Second-brain v0.1 + worker-closeout + owner-cockpit left `draft` follow-ons that block a clean `Plan-automation-foundation` `done`: `Task-cost-budget-ui-and-caps` Epic 1, `Task-repo-setup-preflight` Epic 3, `Task-pr-checks-recovery-panel` Epic 8, `Task-convoy-dag-visualization` + `Task-automation-tiers` Epic 5. This convoy closes them with `role-doc-writer` for SOPs.
Per `plans/Plan-automation-foundation/Plan-automation-foundation.md:332` Polish section and `docs/decisions/2026-08-25-second-brain-os-pivot.md`.
## Scope
1. **Cost budget caps** `Task-cost-budget-ui-and-caps` — month-to-date spend per `model_providers.metadata.tier`, `Budget caps` table `workspace_id` + `cap_usd` + `period: monthly`, dispatch pause when exceeded, tRPC `getSpend` + UI `Settings → AI → Budgets`.
2. **DAG visualization** `Task-convoy-dag-visualization` — `components/mission-control/convoy-dag.tsx` small DAG for `ready/in_progress` brief `sliceDependencies`, `GET /mission-control?dag=1` panel.
3. **Automation tiers** `Task-automation-tiers` — `automation_tier enum: manual|assist|auto` per convoy or workspace, `convoys.create` adds `tier`, Mission control badge.
4. **Repo preflight** `Task-repo-setup-preflight` — `POST /api/connected-repos/preflight` checks `git ls-remote` + `worktree` writability before clone, UI shows result.
5. **PR checks recovery panel** `Task-pr-checks-recovery-panel` — reuses webhook `prUrl`, shows checks status + `Retry` button (calls forge adapter `getChecks` stub).
Out of scope: Epic 11 screenshot capture (needs browser), full LiteLLM.
## Roles invoked
Conductor → Architect (5 briefs) → Implementer per brief (disjoint) → Doc-writer (SOP for caps/tiers) → Reviewer.
## Todos
- [ ] Brief 1 cost caps
- [ ] Brief 2 DAG viz
- [ ] Brief 3 tiers
- [ ] Brief 4 preflight
- [ ] Brief 5 checks panel
## IA
Skipped (`ia` in skip). New table only for budget caps; DAG/tiers reuse `objects.content`.
## UX
Partial — `ux` not skipped for DAG: simple SVG, not full canvas.

View file

@ -1,37 +0,0 @@
---
brief: 1
convoy: polish-dag-tiers-cost
role: role-implementer
title: Cost budget UI + caps — month-to-date spend and dispatch pause
status: ready
priority: P1
slice_dependencies: []
recommended_model: qwen-coder
model_tier: local
---
# Brief 1 — `Task-cost-budget-ui-and-caps` (Epic 1 follow-on)
## Goal
Month-to-date spend per alias/tier visible, caps pause dispatch when exceeded. Autensa benchmark row `cost tracking & budget caps` `plans/Plan-automation-foundation/Plan-automation-foundation.md:236`.
## Files
- `packages/database/src/schema/budget_caps.ts` NEW — `id uuid, workspace_id fk, cap_usd numeric(12,2), period varchar monthly, enabled bool, created_at`. + `spend_ledger` view or `convoy_events` sum query.
- `apps/web/server/routers/budgets.ts` NEW — `getSpend(workspaceId)` sums `convoy_events.estimated_cost_usd` month-to-date grouped by `model_alias`; `getCap`/`upsertCap`/`deleteCap` admin only; `shouldPauseDispatch` helper for orchestrator.
- `apps/orchestrator/src/tick.ts` — call `shouldPauseDispatch` before `enqueueJob`, return `pause` action when cap exceeded.
- `apps/web/app/(app)/[workspaceSlug]/settings/ai/budgets/page.tsx` NEW — table cap + spend chart (simple bar), Test.
## Acceptance
- `/settings/ai/budgets` shows MTD $42/$100 (example), `upsertCap cap_usd:100` → orchestrator `tick` returns `pause reason: budget cap` when sum > cap.
- `pnpm lint && pnpm type-check` pass.
## Prompt
You are `role-implementer` brief 1. Read `plans/Plan-automation-foundation/Epic-model-provider-registry/Task-cost-budget-ui-and-caps.md`, `packages/database/src/schema/convoy_events.ts`, `apps/orchestrator/src/tick.ts`, `docs/adr-model-routing.md`. Keep ledger query simple (sum `estimated_cost_usd` where `occurred_at >= month_start`).
## Depends
None.

View file

@ -1,36 +0,0 @@
---
brief: 2
convoy: polish-dag-tiers-cost
role: role-implementer
title: Convoy DAG visualization for brief dependencies
status: ready
priority: P2
slice_dependencies: []
recommended_model: qwen-coder
model_tier: local
---
# Brief 2 — `Task-convoy-dag-visualization` (Epic 5)
## Goal
Small DAG for `ready/in_progress` brief `sliceDependencies` in Mission control detail — Autensa benchmark row `convoy DAG visualization`.
## Files
- `apps/web/components/mission-control/convoy-dag.tsx` NEW — SVG DAG (nodes = briefNumber, edges = sliceDependencies), uses `convoyBriefs` from `apps/web/server/lib/convoy/convoy-service.ts` `getConvoyBriefs`, highlights `completedBriefIds` green, `eligible` blue.
- `apps/web/app/(app)/[workspaceSlug]/mission-control/page.tsx:12` — embed `ConvoyDag` in detail panel when `briefs.length >1`.
- No DB migration.
## Acceptance
- DAG renders for 3 briefs where `brief 2 depends [1]`, `brief 3 depends [2]` — edges drawn, no overlap.
- `pnpm lint && pnpm type-check` pass.
## Prompt
You are `role-implementer` brief 2. Read `plans/Plan-automation-foundation/Epic-mission-control-gates-ui/Task-convoy-dag-visualization.md`, `packages/shared/src/orchestrator/convoy-driver.ts`, `apps/web/components/mission-control/convoy-detail-panel.tsx`. Keep SVG, no external lib.
## Depends
None.

View file

@ -1,36 +0,0 @@
---
brief: 3
convoy: polish-dag-tiers-cost
role: role-implementer
title: Automation tiers — manual|assist|auto per convoy
status: ready
priority: P2
slice_dependencies: []
recommended_model: qwen-coder
model_tier: local
---
# Brief 3 — `Task-automation-tiers` (Epic 5)
## Goal
`automation_tier enum manual|assist|auto` per convoy or workspace, Mission control badge, orchestrator respects `manual` → pause.
## Files
- `packages/shared/src/convoy-lifecycle/schemas.ts` — add `automationTierSchema` `manual|assist|auto`, add to `convoyObjectContentSchema` `automationTier` optional default `assist`.
- `apps/web/server/routers/convoys.ts` — `create` accepts `automationTier`, `list` returns it, `orchestrator/src/tick.ts` if `tier===manual` return `pause reason: manual tier`.
- `apps/web/components/mission-control/convoy-detail-panel.tsx` — badge `Tier: auto/assist/manual`.
## Acceptance
- `create convoy tier:manual` appears `Tier: manual` badge; orchestrator `tick` on that convoy returns `pause` not `dispatch`.
- `pnpm lint && pnpm type-check` pass.
## Prompt
You are `role-implementer` brief 3. Read `plans/Plan-automation-foundation/Epic-mission-control-gates-ui/Task-automation-tiers.md`, `packages/shared/src/convoy-lifecycle/schemas.ts`, `apps/orchestrator/src/tick.ts`. Keep tier default `assist` for backward compat.
## Depends
None.

View file

@ -1,35 +0,0 @@
---
brief: 4
convoy: polish-dag-tiers-cost
role: role-implementer
title: Repo preflight check before clone
status: ready
priority: P2
slice_dependencies: []
recommended_model: qwen-coder
model_tier: local
---
# Brief 4 — `Task-repo-setup-preflight` (Epic 3)
## Goal
`POST /api/connected-repos/preflight` checks `git ls-remote` + worktree writability before clone, UI shows result — Autensa benchmark row `repo setup preflight`.
## Files
- `apps/web/server/routers/connected-repos.ts` — add `preflight` procedure `input:{url}` → `git ls-remote url` (timeout 5s) + `fs.access(WORKER_WORKTREE_BASE, W_OK)`, returns `{ok, reason}`. Reuse `forge adapters`.
- `apps/web/app/(app)/[workspaceSlug]/settings/repos/page.tsx` — call `preflight` on paste, show green/red before `create`.
## Acceptance
- Invalid URL `https://nope.invalid/repo.git` → `ok:false reason: ls-remote failed`; valid `https://github.com/octocat/Hello-World.git` → `ok:true`.
- `pnpm lint && pnpm type-check` pass.
## Prompt
You are `role-implementer` brief 4. Read `plans/Plan-automation-foundation/Epic-connected-repos/Task-repo-setup-preflight.md`, `apps/web/server/routers/connected-repos.ts`, `apps/worker/src/git/worktree.ts`. Keep timeout small, no creds leak.
## Depends
None.

View file

@ -1,36 +0,0 @@
---
brief: 5
convoy: polish-dag-tiers-cost
role: role-implementer
title: PR checks recovery panel
status: ready
priority: P2
slice_dependencies: []
recommended_model: qwen-coder
model_tier: local
---
# Brief 5 — `Task-pr-checks-recovery-panel` (Epic 8)
## Goal
Mission control detail shows PR checks + Retry — `PR checks recovery` benchmark.
## Files
- `apps/web/server/lib/forge/adapters.ts` — add `getChecks({owner, repo, sha})` stub (GitHub `GET /repos/{owner}/{repo}/commits/{sha}/check-runs` with token, Forgejo `GET /repos/{owner}/{repo}/commits/{sha}/statuses`), returns `{checks: [{name, status, conclusion}], canRetry}`.
- `apps/web/server/routers/convoys.ts` — add `getChecks` query that loads `convoy.prUrl` + resolves sha via `git ls-remote`, calls adapter.
- `apps/web/components/mission-control/convoy-detail-panel.tsx` — panel `Checks` tab when `prUrl` present, shows list + `Retry` button (stub re-run if forge supports).
## Acceptance
- Detail panel `Checks` tab shows checks for PR `sha` (mock if no token → `no token` message, not error).
- `pnpm lint && pnpm type-check` pass.
## Prompt
You are `role-implementer` brief 5. Read `plans/Plan-automation-foundation/Epic-forge-webhooks/Task-pr-checks-recovery-panel.md`, `apps/web/server/lib/forge/adapters.ts`. Keep stub simple, never expose token.
## Depends
None — needs PR `prUrl` from prior convoys but works without.

View file

@ -1,47 +0,0 @@
---
name: repo-discovery-onboarding
classification: feature
success_metric: Operators can authorize GitHub or configure Forgejo/Gitea, discover their repositories, bulk-connect selected repos, import supported repository context, and receive usable agent onboarding instructions.
skip: []
status: open
created: 2026-09-25
model_policy:
roles:
role-conductor: claude-planning
role-architect: claude-planning
role-implementer: qwen-coder
role-reviewer: qwen-coder
escalate_to: claude-planning
---
# Repo discovery and onboarding
## Why
Echodo currently requires workspace admins to paste one remote URL at a time and reference a server-side token environment variable. That is adequate for worker execution but too cumbersome for onboarding a fleet of existing repositories.
The target flow is account/instance connection, repository discovery, explicit selection, context import, and agent setup. `plans/` remains the canonical product backlog; this convoy defines the implementation slices and gates.
## Scope and security decisions
- GitHub discovery uses the current user's linked Auth.js GitHub account and its server-side access token. The browser never receives the token.
- GitHub OAuth requests the minimum scopes needed for repository discovery. Existing accounts must explicitly re-authorize when their stored scope is insufficient.
- Forgejo/Gitea v1 uses an instance URL plus a **token environment-variable reference**. A PAT value is never stored in Echodo or sent back to the browser.
- Every tRPC procedure is workspace-scoped. Mutations require workspace owner/admin; discovery additionally verifies that the linked forge identity belongs to the current user.
- A connected repository remains a workspace resource. Importing repositories does not automatically create new workspaces.
- Repository context import is idempotent and records source repo/path/revision metadata. The canonical remote file is not silently overwritten by Echodo.
## Delivery order
1. **Forge discovery API** — shared schemas, credential resolution, paginated GitHub and Forgejo/Gitea discovery, and `connectedRepos.listAvailable`.
2. **Repo picker and batch connect** — searchable selection UI and transactional/idempotent bulk connection.
3. **Repository context ingestion** — import supported docs and existing convoy manifests with provenance and retryable status.
4. **Agent onboarding** — client-specific, copyable MCP setup based on the actual Echodo deployment model.
Briefs map 1:1 to the matching `plans/Plan-automation-foundation/Epic-repo-discovery-onboarding/Task-*.md` files so the worker can claim and complete backlog work.
## Gates
- Gate 1 must confirm the credential boundary above before Brief 1 is dispatched.
- Gate 2 reviews auth handling, tenant scoping, pagination, and redaction before merge.
- Live GitHub/Forgejo verification uses operator-provided test accounts; automated tests must use mocked HTTP responses and contain no tokens.

View file

@ -1,50 +0,0 @@
---
brief: 1
convoy: repo-discovery-onboarding
role: role-implementer
title: Forge account repository discovery API
status: ready
priority: P1
slice_dependencies: []
recommended_model: qwen-coder
model_tier: local
workspace_id: global
---
# Brief 1 — Forge account repository discovery API
## Goal
A workspace admin can request a normalized, paginated list of repositories visible to either their linked GitHub identity or a configured Forgejo/Gitea instance. Already-connected repositories are identified without exposing credentials to the client.
Backlog task: `plans/Plan-automation-foundation/Epic-repo-discovery-onboarding/Task-forge-discovery-api.md`.
## Security and product contract
- Do not accept or return raw access tokens in tRPC input/output.
- GitHub credentials come from the current user's `accounts` row for `provider='github'`; never use another workspace member's account.
- Update the existing GitHub provider authorization parameters to request repository discovery scopes. Return a typed `reauthorization_required` state when the stored account lacks the required scope instead of treating it as a forge API failure.
- Forgejo/Gitea discovery uses a workspace-admin-supplied instance descriptor and `tokenEnvVar`; resolve the token only on the server with the existing `readToken` pattern.
- Only workspace owner/admin may call discovery. All connected-repo comparisons filter by `workspace_id`.
- Keep the existing paste-URL flow working.
## Files
- `packages/shared/src/connected-repos/schemas.ts` — add zod schemas/types for discovery source, normalized remote repository summary, pagination cursor/page, and typed connection state; export through `packages/shared/src/connected-repos/index.ts`.
- `apps/web/lib/auth.ts` — configure GitHub authorization scopes while preserving the existing sign-in and account-linking flow.
- `apps/web/server/lib/forge/adapters.ts` — add a discovery context separate from repo-specific `ForgeAdapterContext`; implement paginated GitHub `/user/repos` and Forgejo/Gitea `/user/repos` mapping. Preserve `fetchRepoMetadata` and `createPullRequest` behavior.
- `apps/web/server/routers/connected-repos.ts` — add admin-only `listAvailable`; resolve credentials server-side, call the selected adapter, and mark an item connected using the unique tuple `(workspace_id, api_base_url, owner, repo_name)`.
- Add focused tests for response validation, pagination, missing credentials/scopes, private repository mapping, and already-connected matching. If web tests require missing infrastructure, add the smallest Vitest setup consistent with existing package configs.
## Acceptance
- GitHub and Forgejo/Gitea payloads map to one zod-validated result containing stable remote ID, name/full name, owner, HTTPS clone URL, web URL, default branch, privacy flag, description, and updated time.
- Pagination is explicit; the implementation does not silently truncate accounts with more than 100 repositories.
- A credentials or scope problem returns a typed connection state suitable for UI remediation and does not leak a token or provider response body.
- Existing connected repos are marked accurately within the current workspace only.
- Existing manual connect/test/update/disconnect procedures continue to work.
- `pnpm --filter @tasks/shared test`, `pnpm --filter @tasks/web lint`, and `pnpm --filter @tasks/web type-check` pass.
## Prompt for implementer
Implement only Brief 1. Read `AGENTS.md`, the matching backlog task, `apps/web/lib/auth.ts`, `apps/web/server/lib/forge/adapters.ts`, and `apps/web/server/routers/connected-repos.ts` before editing. Use TDD for adapter normalization and credential-state behavior. Do not build the picker UI, batch connection, document ingestion, or agent configuration in this brief. Do not persist plaintext PATs.

View file

@ -1,40 +0,0 @@
---
brief: 2
convoy: repo-discovery-onboarding
role: role-implementer
title: Repository picker and batch connection
status: ready
priority: P1
slice_dependencies: [1]
recommended_model: qwen-coder
model_tier: local
workspace_id: global
---
# Brief 2 — Repository picker and batch connection
## Goal
Workspace admins can search discovered repositories, select multiple entries, and connect them to the current workspace in one idempotent operation while retaining the existing paste-URL workflow.
Backlog task: `plans/Plan-automation-foundation/Epic-repo-discovery-onboarding/Task-repo-picker-modal.md`.
## Files
- `apps/web/server/routers/connected-repos.ts` — add zod-validated, admin-only `batchConnect`. Re-resolve or validate selected remote IDs server-side; do not trust client-supplied owner, URLs, forge type, or token references. Insert with workspace scoping and conflict-safe behavior against `connected_repos_workspace_repo_unique`.
- `apps/web/app/(app)/[workspaceSlug]/settings/repos/page.tsx` — retain manual connection and add a discovery entry point with source/connection-state handling.
- `apps/web/components/settings/repo-picker-dialog.tsx` (new only if extraction improves the existing page) — search, pagination/load-more, multi-select, connected/disabled states, loading/error/empty states, and import progress using existing UI primitives.
## Acceptance
- The picker supports GitHub and Forgejo/Gitea connection states returned by Brief 1 and gives actionable reauthorization/configuration messages.
- Search matches full name and description without discarding selection when paging.
- Already-connected repos cannot be selected.
- `batchConnect` is atomic for valid selections, idempotent for duplicates, tenant-scoped, and returns per-repo connected/already-connected results.
- A stale or forged client selection cannot connect an arbitrary URL.
- Successful import invalidates `connectedRepos.list` and `listAvailable` and leaves the manual paste-URL dialog usable.
- Lint and type-check pass; cover server-side validation and selection-state helpers with tests where feasible.
## Prompt for implementer
Implement only Brief 2 after Brief 1 is complete. Read the matching backlog task and existing repo settings page before editing. Reuse Radix and `components/ui`; do not implement repository document ingestion or agent setup. Never send forge credentials to a client component.

View file

@ -1,51 +0,0 @@
---
brief: 3
convoy: repo-discovery-onboarding
role: role-implementer
title: Idempotent repository context ingestion
status: ready
priority: P1
slice_dependencies: [1, 2]
recommended_model: qwen-coder
model_tier: local
workspace_id: global
---
# Brief 3 — Idempotent repository context ingestion
## Goal
After repositories are connected, an explicit retryable ingestion action imports supported repository documentation and existing convoy manifests with provenance, without making remote API latency part of the batch-connect transaction.
Backlog task: `plans/Plan-automation-foundation/Epic-repo-discovery-onboarding/Task-repo-doc-ingestion.md`.
## Architecture constraints
- Use the canonical object type `document`, not `doc`.
- Do not import an unbounded repository tree. V1 paths are root `README.md`, `AGENTS.md`, `CONTRIBUTING.md`, and `.convoys/*.md`; recursive `docs/**` and `plans/**` are deferred until limits and conflict semantics are designed.
- Import is idempotent by `(workspace, connectedRepoId, sourcePath)`, and records source forge, repo, path, revision/sha, and last import result in metadata.
- Keep remote content read-only. Re-import updates the matching imported object only when the remote revision changes.
- Share convoy parsing/reconciliation logic from a neutral package; do not import `apps/mcp-server` source into `apps/web`.
- A missing optional file is not an error. Authentication, rate-limit, parse, and persistence failures are visible per repo and retryable.
## Files
- Shared connected-repo schemas/services — add bounded tree/list-file and fetch-text-file contracts with size/type validation.
- Forge adapters — implement GitHub and Forgejo/Gitea directory/file reads against the connected repo's default branch.
- Extract reusable convoy markdown parsing from `apps/mcp-server/src/lib/convoy-files.ts` into a package usable by web and MCP, preserving local-file behavior.
- Database schema/migration if needed — add durable repository-source identity and ingestion status. Every new tenant row includes `workspace_id` and an index.
- Web tRPC/service — add admin-only `startImport`/`importStatus` or an equivalent bounded background execution path. Do not perform all remote reads inside the `batchConnect` transaction.
## Acceptance
- Importing a repo creates or updates `document` objects for supported files with source provenance.
- Re-running an unchanged import creates no duplicates.
- Existing `.convoys/*.md` manifests reconcile to workspace-scoped convoy projects. Brief files are imported only when their schema is valid and can be linked safely.
- One bad file does not roll back successful files; the result reports imported, unchanged, skipped, and failed counts.
- File count and byte limits prevent accidental large imports.
- Unit/integration tests cover idempotency, missing files, malformed convoy frontmatter, tenant isolation, and partial failure.
- Generate and commit a new migration for schema changes; lint, type-check, test, and relevant build checks pass.
## Prompt for implementer
Implement only Brief 3 after Briefs 1 and 2. Read `config/CursorSync.md` before changing sync behavior. Preserve the local-first `.convoys/` contract and never mutate remote files. Keep ingestion bounded and retryable; do not silently invent conflict resolution for `plans/**`.

View file

@ -1,48 +0,0 @@
---
brief: 4
convoy: repo-discovery-onboarding
role: role-implementer
title: Agent onboarding and repository bootstrap
status: ready
priority: P2
slice_dependencies: [2, 3]
recommended_model: qwen-coder
model_tier: local
workspace_id: global
---
# Brief 4 — Agent onboarding and repository bootstrap
## Goal
After connection/import, the operator receives accurate, client-specific instructions for connecting an agent to Echodo and can optionally open a bootstrap PR for repository-side convoy guidance.
Backlog task: `plans/Plan-automation-foundation/Epic-repo-discovery-onboarding/Task-agent-mcp-starter.md`.
## Product and security contract
- Do not generate snippets containing `DATABASE_URL` or other infrastructure secrets.
- Do not assume an `ECHODO_WORKSPACE_ID` environment variable is consumed unless the MCP transport/config actually supports it.
- Support the deployment's real MCP transport first. If this remains local stdio, show the command plus non-secret workspace slug used in tool arguments. If remote authenticated MCP is added separately, link to that setup rather than fabricating it here.
- Client formats differ. Render templates from typed server data for explicitly supported clients (OpenCode and Cursor first); label unsupported clients instead of presenting one generic JSON object as universal.
- Any repository bootstrap change is a commit on a branch followed by `createPullRequest`; `createPullRequest` alone cannot create files.
## Files
- Add a typed onboarding-config procedure returning only non-secret deployment and workspace/repo identifiers.
- Add an Agent setup panel to connected-repo settings with client tabs, copy controls, prerequisites, and verification steps.
- Add bootstrap service only if the forge adapter has branch/file/commit primitives; otherwise scope this brief to guidance and create a follow-up task rather than simulating a PR.
- Bootstrap content should reference the canonical `.convoys/README.md` contract and avoid overwriting an existing file without explicit confirmation.
## Acceptance
- OpenCode and Cursor examples are syntactically valid for their actual configuration formats and contain no database URL, PAT, access token, or API-key value.
- Instructions identify the selected workspace and repository and include a smoke test such as `query_manifest_status`.
- Copy controls provide success/error feedback and work over secure browser contexts.
- Bootstrap is idempotent: existing `.convoys/README.md` is detected, and any remote change is shown to the user through a PR URL.
- Authorization is workspace owner/admin for remote writes; read-only members may view non-secret setup guidance if allowed by workspace policy.
- Lint, type-check, and relevant UI/service tests pass.
## Prompt for implementer
Implement only Brief 4 after Briefs 2 and 3. First verify the MCP server's supported transport and environment contract in code. Never place infrastructure credentials in browser-rendered snippets. Do not claim a bootstrap PR is possible unless branch/file commit support is implemented and tested.

View file

@ -1,69 +0,0 @@
---
name: second-brain-os-v01
classification: server-only
success_metric: Web-first second brain OS publishable via docker compose up — any agent (OpenCode/Cursor) can create Plan/Epic/Task via MCP, Plans tree CRUD works without CLI, Fleet shows all workspaces, harness-agnostic worker runs doc + code tasks with BYO LLMs
skip:
- ia
- ux
- design
- a11y
- visual
- smoke
status: merged
created: 2026-08-25
model_policy:
roles:
role-conductor: claude-planning
role-architect: claude-planning
role-implementer: qwen-coder
role-reviewer: qwen-coder
escalate_to: claude-planning
---
# Second Brain OS v0.1 — open self-hosted Obsidian for crews
## Why
Echodo pivots per `docs/decisions/2026-08-25-second-brain-os-pivot.md` from closed code-cockpit to open "Obsidian for agent crews": `docker compose up`, BYO LLMs (Ollama 11434 / MLX 8000 / NIM), any harness via AG-UI/MCP. Current `plans/` is hand-edited markdown + CLI import; Fleet is per-workspace kanban only; worker is code-only with hard worktree + 4-tool allowlist. This convoy makes the homelab dogfoodable + publishable for stars.
See `plans/Plan-automation-foundation/Plan-automation-foundation.md` v0.1 order (2026-08-25 pivot).
## Scope
1. MCP `create-backlog-item` — natural language `Plan/Epic/Task` creation (0.5d)
2. Worker non-code: optional worktree + per-harness allowlist + `doc_review→published` (1d)
3. Harness registry + WorkerAdapter: `harness_providers` + `role_harness_routes`, `AG-UI /api/ag-ui`, `Settings → Harnesses` (2-3d)
4. Plans tree: `apps/web/app/(app)/[workspaceSlug]/plans/page.tsx` + `backlog.createPlan/createEpic/createTask` + `Start→Convoy` (1w) — Epic 10
5. Fleet rollup: `/fleet` + SSE `convoy_events` feed (3-4d) — Epic 9
Out of scope: Epic 8 webhooks, Epic 11 artifacts, Epic 12 copilot (post-v0.1); LiteLLM required, per-user overrides.
## Roles invoked
Conductor → Architect (briefs = 5 above, 1:1 to roadmap) → Implementer per brief (sequential, each needs prior) → Reviewer (PR).
## Todos
- [ ] Brief 1 MCP create
- [ ] Brief 2 worker non-code
- [ ] Brief 3 harness registry — depends on 2
- [ ] Brief 4 Plans tree — depends on 1,3
- [ ] Brief 5 Fleet — depends on 3
## IA
Skipped (`ia` in skip). No new IA — `plans/` hierarchy `docs/Glossary.md:50` + `objects` `packages/database/src/schema/objects.ts:18` unchanged.
## UX
Skipped (`ux` in skip). Reuses existing `Mission control` kanban `apps/web/app/(app)/[workspaceSlug]/mission-control/page.tsx:12` + `Settings` patterns.
## Architecture
See decision log + `docs/adr-model-routing.md`. Key new:
- `packages/database/src/schema/harness_providers.ts` (mirror `model_providers` `docs/adr-model-routing.md:78`)
- `packages/shared/src/worker/executor.ts` `WorkerAdapter`
- `apps/worker/src/handle-job.ts:40` dispatch via adapter
- `apps/web/app/api/ag-ui/[workspace]/route.ts` AG-UI
- `apps/mcp-server/src/tools/create-backlog-item.ts`
- `apps/web/server/routers/backlog.ts:29` add CRUD for `plans/*.md`

View file

@ -1,38 +0,0 @@
---
brief: 1
convoy: second-brain-os-v01
role: role-implementer
title: MCP create-backlog-item tool for natural language Plan/Epic/Task authoring
status: ready
priority: P0
slice_dependencies: []
recommended_model: qwen-coder
model_tier: local
workspace_id: global
---
# Brief 1 — MCP `create-backlog-item` (0.5d)
## Goal
Any agent (OpenCode/Cursor/Codex) via MCP can say "create an Epic for offline mode with 3 tasks" and it writes `plans/Plan-*/Epic-*/Task-*.md` + re-imports to `markdown_backlog_items` `packages/database/src/markdown-backlog/sync.ts:38`.
## Files
- `apps/mcp-server/src/tools/create-backlog-item.ts` NEW — zod schema `kind: plan|epic|task`, `slug`, `title`, `planSlug`, `epicSlug`, `status`, `priority`, `bodyMarkdown`, `agent_prompt`; validates frontmatter per `docs/templates/`, writes file via `exportBacklogItemToMarkdown` inverse, calls `syncMarkdownBacklogScan` or direct DB insert + `contentHash` `packages/database/src/markdown-backlog/parse.ts:1`.
- `apps/mcp-server/src/tools/index.ts` register
- `packages/database/src/markdown-backlog/parse.test.ts` add case
## Acceptance
- `mcp call create-backlog-item {workspace:"maya-workspace", kind:"task", slug:"research-prompt", planSlug:"clarity", epicSlug:"ai-tags", title:"Research prompt", bodyMarkdown:"..."}` creates `plans/Plan-clarity/Epic-ai-tags/Task-research-prompt.md` with YAML frontmatter + body, row appears in `markdown_backlog_items` without manual `pnpm import`.
- Duplicate slug under same epic → error `CONFLICT`.
- `pnpm lint && pnpm type-check && pnpm --filter @tasks/mcp-server test` (if exists) pass; `apps/mcp-server` still builds Docker.
## Prompt for implementer
You are `role-implementer` for `second-brain-os-v01` brief 1. Read `docs/decisions/2026-08-25-second-brain-os-pivot.md`, `packages/database/src/markdown-backlog/parse.ts`, `apps/mcp-server/src/tools/claim-task.ts:1` as reference. Implement exactly this brief, keep changes small, run `pnpm lint && pnpm type-check`.
## Dogfood
`claim_task` on the matching `plans/Plan-automation-foundation/.../Task-*.md` if exists (or ad-hoc) → `complete_task outcome:succeeded tokensTotal:... modelAlias:qwen-coder`.

View file

@ -1,40 +0,0 @@
---
brief: 2
convoy: second-brain-os-v01
role: role-implementer
title: Worker non-code — optional worktree + per-harness allowlist + doc_review→published
status: ready
priority: P0
slice_dependencies: []
recommended_model: qwen-coder
model_tier: local
---
# Brief 2 — Worker non-code (1d)
## Goal
`book/course/research/SOPs` docs don't need git worktree or `pnpm`. Worker must handle doc tasks writing straight to `docs/`/`plans/` no branch, with allowlist per harness.
## Files
- `apps/worker/src/git/worktree.ts:1` — add `needsWorktree: boolean` param; when false skip `ensureWorktree`, use `monorepoRoot` directly. Keep existing path for code briefs `job.briefId` present.
- `apps/worker/src/tools/allowlist.ts:1` — make `allowlist` configurable per harness `harness.config.allowedCommands` (or `minimal: [read,write,grep]` only). Pass `allowedCommands` into `runCommandTool`.
- `packages/shared/src/convoy-lifecycle/state-machine.ts:1` — add `doc_review → published` transition (mirror `pr_review → merged`), allow `human-gate-2` analogue `doc-approver`.
- `apps/worker/src/handle-job.ts:40` — thread `harness.needsWorktree` + `harness.allowlist` through; log `harnessAlias` to `convoy_events`.
- `docs/worker.md:1` document optional worktree.
## Acceptance
- `Task-write-manifesto.md` (doc task) with `harness: doc-writer` creates `docs/manifesto.md` via `write_file` tool without worktree directory under `/tmp/echodo-worker-worktrees`.
- Code task `Task-fix-lint` still creates worktree per prior behavior.
- `transition_convoy_status` `doc_review → published` succeeds; existing `pr_review → merged` still works.
- `pnpm lint && pnpm type-check && pnpm --filter @tasks/worker test` pass.
## Prompt
You are `role-implementer` brief 2. Read `docs/convoy-lifecycle.md`, `packages/shared/src/convoy-lifecycle/state-machine.ts`, `apps/worker/src/handle-job.ts`, `docs/decisions/2026-08-25-second-brain-os-pivot.md`. Implement small, gated.
## Depends
None — can run in parallel with brief 1.

View file

@ -1,44 +0,0 @@
---
brief: 3
convoy: second-brain-os-v01
role: role-implementer
title: Harness registry + WorkerAdapter + AG-UI + Settings → Harnesses
status: ready
priority: P0
slice_dependencies: [1, 2]
recommended_model: qwen-coder
model_tier: local
---
# Brief 3 — Harness registry + WorkerAdapter (2-3d) — THE big one
## Goal
Workspace admins CRUD harness providers (`minimal|crewai|langgraph|remote-ag-ui`) + map `role_id → harness_alias`, worker dispatches via adapter, any harness via `AG-UI /api/ag-ui/[workspace]`, test button proves reachability.
## Files
- `packages/database/src/schema/harness_providers.ts` NEW — mirror `model_providers` `docs/adr-model-routing.md:78`: `id, workspace_id fk, alias unique per workspace, display_name, harness_type enum, endpoint_url nullable, auth_env_var varchar nullable, config jsonb, enabled bool, created_at/updated_at`. + `role_harness_routes` `workspace_id, role_id varchar, harness_provider_id fk, enabled, sort_order`, unique `(workspace_id, role_id)`.
- `packages/database/migrations/0015_*` via `pnpm db:generate`.
- `packages/shared/src/worker/executor.ts` NEW — `WorkerExecutor {id, run(ctx, model):Promise<...>}`, `resolveHarnessForRole()` pure like `resolveModel` `packages/shared/src/model-routing/index.ts`.
- `apps/worker/src/handle-job.ts:40` — replace hard `createMinimalSession` with `registry.get(harnessAlias).run(...)`; keep `MinimalSession` as `minimal` adapter.
- `apps/web/server/routers/harnesses.ts` NEW — `list/create/update/delete/testHarness` (admin only, never return `auth` value, `test` does fetch `health` on endpoint with `Authorization: Bearer ${process.env[AUTH_ENV_VAR]}`).
- `apps/web/app/(app)/[workspaceSlug]/settings/harnesses/page.tsx` NEW — table + add/edit dialog + test.
- `apps/web/app/api/ag-ui/[workspace]/route.ts` NEW — POST ag-ui envelope, validate `workspace_id`, write `convoy_events`/`agent_runs`, enforce tenant fence.
- `packages/database/src/schema/index.ts` exports.
## Acceptance
- Migration applies `pnpm db:generate && pnpm db:migrate` locally (no drift).
- `Settings → Harnesses` CRUD works; `Test` on `remote-ag-ui https://crew.example.com/ag-ui` with `HARNESS_AUTH_TOKEN` env shows latency or error detail, never leaks token to browser.
- `resolveHarnessForRole(workspaceId, "role-implementer")` returns `crewai` when mapped, `minimal` fallback.
- Worker dispatched `role-implementer` via `crewai` adapter still `claim_task`/`complete_task` and writes `harness_alias` to `convoy_events.harness_alias` + `agent_runs`.
- `pnpm lint && pnpm type-check && pnpm test` pass.
## Prompt
You are `role-implementer` brief 3. Read `docs/adr-model-routing.md`, `docs/decisions/2026-08-25-second-brain-os-pivot.md`, `packages/database/src/schema/model_providers.ts`, `packages/shared/src/model-routing/index.ts`. Keep diff minimal, follow existing Drizzle/tRPC patterns in `server/routers/ai-models.ts`.
## Depends
Briefs 1,2 — needs optional worktree done.

View file

@ -1,44 +0,0 @@
---
brief: 4
convoy: second-brain-os-v01
role: role-implementer
title: Plans tree — web CRUD for plans/ without CLI, Start→Convoy
status: ready
priority: P0
slice_dependencies: [1, 3]
recommended_model: qwen-coder
model_tier: local
---
# Brief 4 — Plans tree (1w) — Epic 10
## Goal
Web `Plans` is Obsidian-like tree — read/write `plans/Plan-*/Epic-*/Task-*.md` without `pnpm import:markdown-backlog` CLI; click `Start → Convoy`.
## Files
- `apps/web/server/routers/backlog.ts:29` — add `listTree` (returns hierarchical `Plan → Epic → Task` with `status/priority/repoPath`), `createPlan/createEpic/createTask` (`slug/title/bodyMarkdown/agent_prompt` → writes `.md` via `join(repoRoot, repoPath)` atomically `.tmp→rename`, then direct DB upsert or `syncMarkdownBacklogScan`), `updateTaskBody`, `getBacklogItem` already exists. Enforce slug `^[a-z0-9-]+$`, `workspace_id` fence, `updated_at` bump.
- `apps/web/app/(app)/[workspaceSlug]/plans/page.tsx` NEW — tree component (collapsible `Radix Collapsible` + `ScrollArea`), status chips, search, `New Plan/Epic/Task` dialogs, `Start` button → calls `convoys.create` `apps/web/server/routers/convoys.ts:173` with `planSlug/epicSlug`.
- Reuse `resolveWorkflowPrompt` preview in task detail drawer.
- `docs/Glossary.md:50` hierarchy respected; `config/CursorSync.md:33` export allow-list preserved (web writes go through same export path).
## Acceptance
- `Plans` page shows `Plan-clarity → Epic-ai-tags → Task-local-tag-prompt [ready]` from DB without CLI after DB seed.
- `New Task` dialog `title: Research prompt` `slug: research-prompt` creates `plans/Plan-clarity/Epic-ai-tags/Task-research-prompt.md` on disk + row in `markdown_backlog_items` visible after refresh without manual import.
- `Start` on a `ready` task creates convoy `clarity-research-prompt` at `draft` and links `briefFiles: [repoPath]`.
- `MCP create-backlog-item` brief 1 and web create produce same file format (round-trip parse test).
- `pnpm lint && pnpm type-check` pass.
## Prompt
You are `role-implementer` brief 4. Read `plans/README.md`, `packages/database/src/markdown-backlog/parse.ts`, `apps/web/server/routers/backlog.ts`, `docs/decisions/2026-08-25-second-brain-os-pivot.md`. Follow existing tRPC patterns `ai-models.ts`. Keep UI to `components/ui/*` + Tailwind.
## Depends
Briefs 1,3 — needs MCP tool shape + harness registry for Start.
## Out of scope
Full Notion drag-n-drop, breadcrumb perf — keep tree simple.

View file

@ -1,38 +0,0 @@
---
brief: 5
convoy: second-brain-os-v01
role: role-implementer
title: Fleet rollup /fleet + SSE convoy_events feed
status: ready
priority: P1
slice_dependencies: [3]
recommended_model: qwen-coder
model_tier: local
---
# Brief 5 — Fleet rollup `/fleet` + SSE (3-4d) — Epic 9
## Goal
`echodo.stillwell.cloud/fleet` shows *all* workspaces + `connected_repos` projects needing you — the "land and see everything" you asked for. Live `convoy_events` tail without refresh.
## Files
- `apps/web/server/routers/convoys.ts:119` — add `fleet` procedure (no `workspace` arg, uses `ctx.session.user.id` to gather all `workspaceMembers` workspaces → `listConvoyProjects` across them, return `byWorkspace, byStatus, gateInboxAcross`). Reuse `GATE_INBOX_STATUSES`.
- `apps/web/app/(app)/fleet/page.tsx` NEW — top `Needs you` list, grid `byWorkspace` cards, reuse `ConvoyKanban` `apps/web/app/(app)/[workspaceSlug]/mission-control/page.tsx:12` + `ConvoyDetailPanel` but workspace-agnostic.
- `apps/web/app/api/fleet/events/route.ts` NEW — SSE `text/event-stream` tailing `convoy_events` `packages/database/src/schema/convoy_events.ts:1` filtered to user's workspaces, `occurredAt desc` 30 rows + live publish via `pg_notify` or polling 5s fallback (reuse orchestrator poll).
- `apps/web/components/fleet/fleet-activity-feed.tsx` NEW — auto-append without refresh.
## Acceptance
- `/fleet` lists convoys from `maya-workspace` + `studio-internal` + `bakery-coop` together; `Needs you` shows `× pr_review 2d` count.
- Clicking fleet card opens detail with correct `workspaceSlug` context for `gateAction`.
- SSE feed shows `role_complete` event within 5s of worker `logConvoyEvent` `apps/mcp-server/src/tools/log-convoy-event.ts`.
## Prompt
You are `role-implementer` brief 5. Read `apps/web/server/routers/convoys.ts:173`, `apps/web/server/lib/convoy/convoy-service.ts`, `plans/Plan-automation-foundation/Epic-fleet-dashboard/Epic-fleet-dashboard.md`, `docs/decisions/2026-08-25-second-brain-os-pivot.md`. Keep polling fallback simple, no Redis pubsub v1.
## Depends
Brief 3 — needs harness field in events for display.

View file

@ -1,62 +0,0 @@
---
name: worker-closeout-docwriter
classification: server-only
success_metric: Epic 7 closes — draft PR opens for code + doc_review→published for docs, operator notes work mid-build, doc-writer examples ship, live E2E checklist is runnable without Cursor
skip:
- ia
- ux
- design
- a11y
- visual
- smoke
status: merged
created: 2026-08-25
model_policy:
roles:
role-conductor: claude-planning
role-architect: claude-planning
role-implementer: qwen-coder
role-doc-writer: qwen-coder
role-reviewer: qwen-coder
escalate_to: claude-planning
---
# Worker closeout + doc writers — closing Epic 7 for second-brain v0.1
## Why
Second-brain v0.1 shipped harness-agnostic, Plans tree, Fleet. Epic 7 remains `in_progress` per `plans/Plan-automation-foundation/Epic-apps-worker-v1/Epic-apps-worker-v1.md:23` — tool loop + checkpoints are done, but **no draft PR** via forge adapter blocks Epic 8 dogfood, and **live E2E** `Task-worker-live-e2e-dogfood.md` needs operator proof. Doc writers were asked to be included — this convoy adds `role-doc-writer` for SOPs/book research pipelines (docs that use `doc_review→published` without worktree).
See `docs/decisions/2026-08-25-second-brain-os-pivot.md`.
## Scope
1. **Draft PR via forge** `Task-worker-draft-pr.md` — push branch if needed, `createPullRequest(draft)` on GitHub/Forgejo from worktree, persist URL to convoy, skip for non-implementer roles, handle both `pr_review` and `doc_review` (doc tasks publish without PR but still log).
2. **Operator notes mid-build** `Task-worker-operator-notes.md` — `convoy_operator_messages` table (workspace-scoped, `queued|direct`), tRPC `postOperatorNote`/`listOperatorNotes`, worker poll between tool rounds, Mission control chat thread for `in_progress` convoys.
3. **Doc-writer examples** — `role-doc-writer` routed via harness (uses `doc-writer` harness `needsWorktree:false`), writes book/course research example under `docs/examples/research-pipeline.md` + `docs/templates/doc-sop.md`, updates `docs/worker.md` with doc harness section.
4. **Live E2E dogfood** `Task-worker-live-e2e-dogfood.md` — operator checklist doc (not code), proves `Go → worker` without Cursor; done only when checklist true on real Redis/Coolify.
## Roles invoked
Conductor → Architect (3 briefs map to 3 tasks + doc example) → Implementer per brief (disjoint: draft PR, operator notes, doc-writer) → Doc-writer (parallel) → Reviewer.
## Todos
- [ ] Brief 1 draft PR
- [ ] Brief 2 operator notes
- [ ] Brief 3 doc-writer examples + routing
- [ ] Brief 4 live E2E verification (ops doc, no code)
## IA
Skipped (`ia` in skip). No new entities beyond `convoy_operator_messages`; `harness_providers` already exists from v0.1.
## UX
Skipped (`ux` in skip). Reuses `Mission control detail panel` thread section + `Gate co-pilot` separation.
## Architecture
- State machine: `doc_review→published` already added `packages/shared/src/convoy-lifecycle/state-machine.ts:9` — reuse.
- Worker: `apps/worker/src/handle-job.ts:40` already optional worktree — add PR call after success before teardown, branch naming `echodo/<convoy>-<brief8>`, marker `echodo-convoy: <uuid>` in body for Epic 8 webhook.
- Operator notes: poll `convoy_operator_messages` where `delivered_at IS NULL` each `checkpointEveryNSteps`; inject `Queued note: ...` into system prompt tail or prepend `Direct message: ...`.

View file

@ -1,40 +0,0 @@
---
brief: 1
convoy: worker-closeout-docwriter
role: role-implementer
title: Worker draft PR via forge adapter (code + doc)
status: ready
priority: P0
slice_dependencies: []
recommended_model: qwen-coder
model_tier: local
---
# Brief 1 — Draft PR `Task-worker-draft-pr.md`
## Goal
`apps/worker` after successful `role-implementer` session pushes worktree branch if needed, calls `createPullRequest(draft:true)` on GitHub/Forgejo (from `connected_repos` / `repo_links`), persists `prUrl`/`prNumber` to `convoy_events metadata` + `objects.content.prUrl`, marker `echodo-convoy: <convoyId>` in body for Epic 8 webhook.
## Files
- `apps/worker/src/git/pr.ts` NEW — `openDraftPR({monorepoRoot, worktreePath, branch, convoySlug, briefId, convoyId, workspaceId, forge})` → push `git push origin HEAD` (uses stored credentials / `GITHUB_TOKEN` env pattern) → `forge.createPullRequest({base: main, head: branch, title, body, draft:true})`. Body includes `Convoy: ${slug} Brief: ${briefId} echodo-convoy: ${convoyId}`.
- `apps/worker/src/handle-job.ts:40` — after `session.run` success and before teardown, if `roleId==='role-implementer'` and `worktreePath` present, call `openDraftPR`; on doc harness `needsWorktree:false` skip push but still log `published` artifact.
- `packages/database/src/schema/convoy_events.ts` — already has `metadata jsonb`, store `prUrl`.
- `apps/web/server/lib/convoy/convoy-service.ts` — expose `prUrl` from latest `metadata.prUrl`.
- `apps/web/components/mission-control/convoy-detail-panel.tsx` — show `PR` link when `metadata.prUrl`.
## Acceptance
- Implementer job on test repo `stwl-labs/ubiquitous-invention` (or any connected) produces draft PR URL; non-implementer `role-architect` job skips PR creation.
- PR body contains marker consumable by Epic 8.
- Detail panel shows link when present.
- `pnpm lint && pnpm type-check && pnpm --filter @tasks/worker test` pass.
## Prompt
You are `role-implementer` brief 1. Read `plans/Plan-automation-foundation/Epic-apps-worker-v1/Task-worker-draft-pr.md`, `apps/web/server/lib/forge/adapters.ts`, `apps/worker/src/git/worktree.ts`, `docs/decisions/2026-08-25-second-brain-os-pivot.md`. Keep change small, follow existing `adapters.ts` interface.
## Depends
None.

View file

@ -1,41 +0,0 @@
---
brief: 2
convoy: worker-closeout-docwriter
role: role-implementer
title: Operator notes mid-build — queued + direct messages
status: ready
priority: P1
slice_dependencies: []
recommended_model: qwen-coder
model_tier: local
---
# Brief 2 — Operator notes `Task-worker-operator-notes.md`
## Goal
Let operators send `queued` notes and `direct` messages to an active worker session without waiting for gate — Autensa benchmark row `operator-notes` `plans/Plan-automation-foundation/Plan-automation-foundation.md:229`.
## Files
- `packages/database/src/schema/convoy_operator_messages.ts` NEW — `id uuid PK, workspace_id fk, convoy_id fk objects.id, brief_id fk optional, agent_run_id fk optional, delivery enum queued|direct, body text, author_user_id fk users, delivered_at timestamptz nullable, created_at defaultNow`. Index `workspace_id, convoy_id, delivered_at`.
- `packages/database/migrations/0016_*` — `pnpm db:generate`.
- `apps/web/server/routers/convoys.ts` — add `postOperatorNote` (workspace member; `delivery:queued|direct`, `body 1..2000`, `convoyId, briefId?`) inserts row; `listOperatorNotes` for detail panel thread.
- `apps/worker/src/db/operator-notes.ts` NEW — `pollOperatorNotes(db, workspaceId, convoyId, briefId?)` where `delivered_at IS NULL` ordered `created_at`.
- `apps/worker/src/handle-job.ts:40` — between tool rounds poll queued notes, append `Queued note from ${author}: ${body}` to `toolHistory` summary; before next `generateText` prepend direct messages to system prompt tail. Mark `delivered_at=now()` after injection. Log `harnessAlias`.
- `apps/web/app/(app)/[workspaceSlug]/mission-control/convoy-detail-panel.tsx` — thread tab when `status===in_progress` (textarea + Send → `postOperatorNote`, list `listOperatorNotes`), distinct from gate co-pilot.
## Acceptance
- Operator posts `Queued note: use existing auth middleware` → worker sees it on next tool round (logged in checkpoint summary).
- `Direct message` arrives before next model call when run active (show in worker log `direct message injected`).
- Workspace-scoped, auditable (author + delivered_at visible in detail panel).
- `pnpm lint && pnpm type-check` pass.
## Prompt
You are `role-implementer` brief 2. Read `plans/Plan-automation-foundation/Epic-apps-worker-v1/Task-worker-operator-notes.md`, `packages/database/src/schema/convoy_events.ts`, `apps/web/server/routers/convoys.ts:173`, `docs/worker.md`. Keep polling fallback simple (no Redis pubsub).
## Depends
None — disjoint from brief 1.

View file

@ -1,44 +0,0 @@
---
brief: 3
convoy: worker-closeout-docwriter
role: role-doc-writer
title: Doc-writer examples + routing for second brain (book/course/research SOPs)
status: ready
priority: P1
slice_dependencies: []
recommended_model: qwen-coder
model_tier: local
---
# Brief 3 — `role-doc-writer` for second brain (book / course / SOPs)
## Goal
Prove non-code second brain: `role-doc-writer` routed via harness `doc-writer` (`needsWorktree:false, allowedCommands:[]` `apps/worker/src/harness.ts:1`) writes publishable docs without `pnpm` or worktree, via `doc_review→published` `packages/shared/src/convoy-lifecycle/state-machine.ts:9`.
## Files
- `docs/examples/research-pipeline.md` NEW — one page: "Heavy research pipeline" example (source → claim → outline → publish), calls `doc_review→published` flow; used as doc harness e2e fixture.
- `docs/templates/doc-sop.md` NEW — copy from `docs/templates/Task-*.md` but for SOPs: `kind: doc-sop, slug, title, status: draft→doc_review→published, body`.
- `packages/shared/src/model-routing` / `harness` seeds — add doc role wiring note: `role-doc-writer → qwen-coder-mlx + doc-writer` suggested alias map `docs/adr-model-routing.md:234`.
- `docs/worker.md` — extend doc harness section (already has optional worktree note from v0.1) with `role-doc-writer` example invocation.
- `apps/web/server/routers/harnesses.ts:1` — ensure `harnessType: minimal` with `alias: doc-writer` preset is seedable (if not, document manual creation).
## Acceptance
- `role-doc-writer` job `docs/examples/research-pipeline.md` completes via `write_file` only (no `run_command`, no worktree under `/tmp/echodo-worker-worktrees`) and transitions `in_progress → doc_review` (`role-doc-writer` or automation), then `doc-approver` `doc_review→published` visible in Mission control.
- SOP template renders in `docs/templates/` list.
- `pnpm lint && pnpm type-check` pass.
## Prompt
You are `role-doc-writer` brief 3. Read `docs/decisions/2026-08-25-second-brain-os-pivot.md`, `docs/adr-model-routing.md:234`, `docs/convoy-lifecycle.md`, `packages/shared/src/convoy-lifecycle/schemas.ts:5`. Write docs that a future agent will read as context — keep them small, no narration comments.
## Depends
None — can run parallel with 1,2.
## Links
- `plans/Plan-automation-foundation/Plan-automation-foundation.md` v0.1 pivot
- `apps/worker/src/harness.ts:1` `doc-writer` harness

View file

@ -1,124 +0,0 @@
---
name: workspace-chat-foundation
classification: feature
success_metric: Two workspace members can exchange durable channel messages, reconnect without loss, and open Chat from the left rail with accurate unread state.
skip: []
status: open
created: 2026-09-25
model_policy:
roles:
role-conductor: claude-planning
role-architect: claude-planning
role-implementer: qwen-coder
role-reviewer: qwen-coder
escalate_to: claude-planning
---
# Workspace chat foundation
## Why
Echodo has collaborative documents, convoy operator notes, and AI chat surfaces, but no durable workspace conversation layer. The first slice must establish trustworthy persistence and realtime delivery before notifications or agent participation depend on it.
Canonical backlog: `plans/Plan-workspace-communications/Epic-workspace-chat/Epic-workspace-chat.md`.
## Scope
- Brief 1: tenant-scoped chat schema, channel lifecycle, message/outbox transaction, and workspace-authorized tRPC API.
- Brief 2: outbox publication, authenticated WebSockets, Redis fan-out, and reconnect catch-up.
- Brief 3: workspace Chat routes, channel/message UI, and left-rail unread/mention affordance.
- Postgres is authoritative; Redis and WebSockets accelerate delivery.
- Yjs/Hocuspocus may support presence/typing/drafts, but does not own message history.
Out of scope for this convoy: unified non-chat inbox, agent execution, external delivery, search, and federation.
## Role activity and kickoff — 2026-10-22
- User approved beginning the integrated contextual-chat/AI-agent direction; this does not authorize merge or release.
- This session performed conductor reconciliation and architect schema/access/dependency review. IA, UX, implementation, review and release work are not claimed complete by the existing section headings.
- At kickoff, Brief 1 was the next implementable slice. Superseded by the integration checkpoint below; the independent AI shell implementer remains tracked in the AI convoy.
- Files are local planning artifacts. No convoy DB registration, task claim, event logging, or MCP gate transition was performed; those tools are unavailable here. `open` is the local README status, not a server lifecycle claim.
## Go-live reconciliation — 2026-10-29
- Operator reports Chat deployed on CT 116 and 14/14 checks passed, including two-member messaging, reply, reconnect, unread, edit/delete recovery and revocation. Canonical endpoint: `wss://chat.stillwell.cloud/chat`.
- CT 102 ledger reported at 24 entries through `0023`; Chat schema verified and table ownership corrected to `echodo`. No migrations rerun here.
- Deployment evidence is now present in `docs/Current Progress.md`; local channel projection/plain-JSON redaction fixes preserved with regression coverage. Ticket origin handling is hardened locally to configured auth URL rather than forwarded headers; requires rebuild and post-deploy retest.
- Direct/PVE SSH trust in this harness remains unavailable, but is not a production rollout blocker. Operator production acceptance and local automated checks are separate evidence.
- Shared credentials password removal needs an operator decision. Source-built web rebuild, full checks and final review remain open; no lifecycle transition or release gate completion is inferred from the deployment report.
## Earlier integration checkpoint — 2026-10-29 (superseded by go-live report)
- Local implementation now includes persistence/edit/tombstone transactions, mutation catch-up, signed-ticket subscriptions, Redis outbox publication, and workspace Chat UI with scoped recovery. Cursors are in-memory, not persisted across browser reloads.
- Deployment wiring now extends `collab` with `CHAT_PORT` (default 1235), a shared runtime signing secret, dual-listener liveness checks, standalone publication, and a public TLS proxy example. The web URL is a build argument and Turbo build input.
- Configuration regression tests cover the wiring; actual Docker Compose/image validation still requires a Docker host. See [current progress](../docs/Current%20Progress.md) and the [deployment gate](../docs/deployment-production.md#workspace-chat-integration-gate).
- Production migrations `0022`/`0023` remain unverified. CT 116 SSH was blocked by a missing trusted host key; no remote changes were made. Disposable-Postgres, Redis/WebSocket, browser/revocation, accessibility and final review gates remain open.
- Earlier commit/push completion claims were incorrect: the implementation was still uncommitted at inspection. No merge, release, or server lifecycle transition is claimed by this checkpoint.
## Full product versus this foundation
The approved product includes workspace, project and supported-object chat plus mention-to-add agents. This convoy implements **workspace-context human chat only**, with an explicit fail-closed context policy seam. Object contexts must be rejected until the canonical follow-on [context authorization task](../plans/Plan-workspace-communications/Epic-workspace-chat/Task-contextual-chat-authorization.md) lands; do not treat a same-workspace object lookup as an ACL. Mention-to-add belongs to [agent channel membership](../plans/Plan-workspace-communications/Epic-agent-participation/Task-agent-channel-membership.md), not these three briefs.
All briefs follow the [plan's context and agent permission contracts](../plans/Plan-workspace-communications/Plan-workspace-communications.md). Projects are generic `objects` rows; the inspected schema has no private-object authorization model. This gap is a blocker for contextual rollout, not a reason to silently widen access.
## Todos
- [x] User approved kickoff of the integrated direction on 2026-10-22; merge/release gates remain pending.
- [ ] Implement brief 1 and validate tenant isolation and idempotency.
- [ ] Implement brief 2 and validate outage/reconnect behavior.
- [ ] Implement brief 3 and validate accessibility and multi-device unread state.
- [ ] Reviewer checks standards and the three linked backlog tasks.
- [ ] Human performs merge and release gates.
## IA
- Add `Chat` as a first-class left-rail destination within the current workspace.
- `/[workspaceSlug]/chat` selects or redirects to a channel.
- `/[workspaceSlug]/chat/[channelSlug]` renders channel navigation and conversation.
- Threads open in the existing right-panel pattern where practical, preserving channel context.
- The global notification inbox is a later epic; Chat still exposes its own durable unread and mention counts.
## UX
- Ordinary unread activity uses a dot; mentions/direct messages use a numeric badge.
- Badges include accessible labels and never rely on color alone.
- Optimistic messages show pending, failed, and retry states.
- Reconnect and catch-up are silent when successful and explicit when recovery fails.
- Keyboard navigation covers rail entry, channel switching, message history, composer, and thread opening.
## Architecture
### Data and command path
1. A workspace-authorized tRPC mutation validates the command and channel membership.
2. Brief 1 owns one Postgres transaction that inserts the message and its versioned outbox event.
3. Brief 2 owns an outbox worker that claims and publishes committed events through Redis.
4. Realtime instances fan events out to authenticated local WebSocket subscribers.
5. Reconnecting clients query Postgres after their last durable sequence cursor.
### Boundaries
- `packages/database`: tenant-scoped chat and outbox schema plus migration.
- `packages/shared`: zod contracts and versioned realtime event types when shared across processes.
- `apps/web`: workspace-authorized tRPC router, routes, React Query state, and shell navigation.
- `apps/collab-server` or a narrowly separated realtime entry point: authenticated subscriptions and Redis fan-out.
- Existing Hocuspocus/Yjs infrastructure remains available for ephemeral presence, typing, and collaborative drafts.
### Security and scale
- Every query enforces workspace AND context AND channel access. Foundation accepts workspace context only; public workspace channels require active workspace membership, private/direct also require channel membership, and archived channels are read-only. Channel owners or workspace admins/owners administer only channels they may access; role alone grants no private/direct read or self-join bypass. Object contexts require a separate verified policy.
- Brief 1 defines the versioned outbox contract and owns the atomic message/outbox transaction; Brief 2 owns claim, publication, retry, and consumer delivery.
- Message commands accept client idempotency keys and generate a per-channel monotonic sequence; reconnect cursors are `(channel_id, sequence)` pairs.
- Realtime events carry identifiers and cursors, not authorization truth; subscription and catch-up endpoints authorize independently.
- Redis failure cannot roll back a committed message. Outbox retries and Postgres catch-up restore delivery.
- Use bounded pagination and backpressure; do not broadcast entire message histories.
- This convoy supports one-level threads in the persistence/API contract; full thread-panel polish may remain minimal in Brief 3.
- Read state is the highest message sequence the member has intentionally viewed. In this convoy, mention counts are derived from validated persisted message-mention rows above that cursor; the notification epic later projects those mentions into the unified inbox. Mark-read emits an invalidation event for other devices.
## Briefs
| Brief | Backlog task | Depends on |
| --- | --- | --- |
| 1 | `Task-chat-persistence.md` | none |
| 2 | `Task-chat-realtime-delivery.md` | brief 1 |
| 3 | `Task-chat-ui-and-navigation.md` | briefs 1 and 2 |

View file

@ -1,75 +0,0 @@
---
brief: 1
convoy: workspace-chat-foundation
role: role-implementer
title: Durable chat persistence and workspace API
status: in_progress
priority: P1
slice_dependencies: []
recommended_model: auto/coding
model_tier: standard
---
# Brief 1 — `Task-chat-persistence.md`
## Execution checkpoint — 2026-10-22
- Shared command/context validation and the fail-closed authorization policy are implemented and accepted after standards/spec review; `@tasks/shared` has 65 passing tests.
- The tenant-scoped chat schema and generated migration are implemented and accepted after standards/spec/migration review; database tests pass. A separate generated `0018` repairs pre-existing omitted budget tables, and chat is isolated in generated migration `0019`.
- The concrete Drizzle human send/reply transaction, mention-aware idempotency, atomic sequence allocation, validated `chat.message.created.v1` outbox event, and workspace-authorized `chat.send` tRPC procedure are implemented and review-clean. Acceptance remains blocked on the live PostgreSQL integration gate below; channel lifecycle, history, edit/tombstone, mention counts, and read-state operations are pending.
- **Blocking integration gate:** against disposable PostgreSQL with migrations through `0019`, verify advisory-lock blocking under simultaneous same-key sends; exactly one message, mention set, logical outbox event, and sequence increment; full rollback of the sequence/message/mentions/outbox after an injected post-allocation failure; and Drizzle's runtime `bigint` shape for `UPDATE … RETURNING next_sequence - 1`. Unit fakes verify orchestration only and must not be cited as transaction/concurrency proof.
- IA/UX review completed; private-object authorization remains blocked, and agent approval semantics and AI side-panel convergence require resolution before their dependent integrations ship.
## Goal
Implement the durable, tenant-scoped channel/message foundation and workspace-authorized tRPC API described by `plans/Plan-workspace-communications/Epic-workspace-chat/Task-chat-persistence.md`.
## Files
Expected scope; confirm exact module names against current package exports before editing.
- `packages/database/src/schema/` — add chat channel, membership, message, read-state, and the outbox contract schemas and export them.
- `packages/database/migrations/` — generate a new migration and metadata; never edit an existing migration.
- `packages/shared/src/` — add reusable zod schemas/types for chat commands, author/message states, and the versioned outbox event where cross-process sharing is needed.
- `apps/web/server/routers/` — add a `workspaceProcedure` chat router for channel list/create/archive, membership management, history/send/reply/edit/delete, and mark-read.
- Adjacent `*.test.ts` files — authorization, provisioning, pagination, idempotency, thread, and cross-workspace isolation coverage.
## Required design
- Every tenant-owned table has non-null indexed `workspace_id`.
- Channel types are `public`, `private`, and `direct`; visibility is separate from context. This brief accepts only an explicit workspace context (including a documented workspace default for existing callers); reject object references/unknown context keys rather than dropping them. Add a shared authorization seam with workspace AND context AND channel checks; do not implement a project ACL in this brief.
- Public workspace channels are readable by active workspace members; private/direct channels additionally require active channel membership. Every list/title/preview/history/thread/mention/count/read-state query uses the same access predicate. Archived channels permit authorized reads only.
- Active members create workspace public/private channels and become channel owner. An authorized channel owner or workspace `admin`/`owner` can manage that channel only with channel access; role alone is not a private/direct read bypass or self-join mechanism. All membership targets must themselves be authorized. Direct-channel participant sets are immutable; changes resolve a different exact-participant channel. Object/private-project access remains blocked until an authoritative context policy exists.
- Message authors may edit their own non-deleted messages. They may tombstone their own messages; channel owners and workspace `admin`/`owner` may tombstone messages for moderation. System/agent edits require the owning service path, not an impersonated user mutation.
- Channel lifecycle includes public/private creation, rename/archive, private membership management, and idempotent direct-channel creation for an exact sorted participant set.
- Message authors use a discriminated user/agent/system representation without fake user rows.
- A client-generated idempotency key is unique within the workspace, channel, and author scope.
- Every message receives a transactionally allocated, per-channel monotonic sequence. History and reconnect cursors are `(channel_id, sequence)`; message ID breaks ties only if allocation semantics ever permit one.
- Sending a message or thread reply inserts its versioned outbox event in the same transaction. Brief 1 owns this boundary; Brief 2 consumes the outbox.
- Threads are one level. Channel history returns root messages only plus reply counts/latest-reply metadata; thread history returns direct replies ordered by the same channel sequence; replies cannot themselves receive replies. Every root reference must resolve inside the same workspace and channel.
- Deletes are tombstones where thread/history integrity requires them.
- Read state stores the highest intentionally viewed message sequence per member/channel. `markChannelRead` may only advance it and inserts a read-state invalidation event for other devices.
- Mention parsing creates validated, workspace-scoped persisted message-mention rows in the message transaction. This convoy derives unread mention counts from those rows above the recipient's read cursor; the notifications epic later creates inbox projections from the same source events.
- All reads and mutations authorize through `ctx.workspace` plus the channel-type and role rules above.
## Scope checkpoint and acceptance
Next implementable chat brief: this one. Start with failing policy tests and schema contracts, then migration and workspace-only router/outbox. Existing `objects` includes projects but has no private-object ACL: do not invent project memberships or authorize by `workspace_id` alone. See [context follow-on](../../plans/Plan-workspace-communications/Epic-workspace-chat/Task-contextual-chat-authorization.md).
- Same-workspace unsupported object contexts fail closed; no fallback public channel is created.
- Nonmember workspace admins cannot read/self-add to private/direct channels via administrative endpoints; counts/previews cannot reveal them.
- Access removal is respected by subsequent queries/writes; stale membership cannot override context denial.
- Foundation mention parsing cannot install/add/invoke agents; the agent-channel-membership task owns reviewed addition.
- Duplicate send retries result in one persisted message.
- Cross-workspace and unauthorized private-channel requests return non-disclosing errors.
- Stable cursor pagination works for forward catch-up and backward history.
- Channel creation, rename/archive, private membership changes, immutable exact-participant direct channels, and message edit/moderation enforce the documented ownership and workspace-role rules.
- One-level thread roots/replies, persisted mention rows/counts, and durable per-member read state have working API coverage.
- `markChannelRead` only advances the per-channel sequence and atomically emits its invalidation event.
- Message and thread-reply commits atomically include the corresponding outbox event.
- `pnpm db:generate`, `pnpm lint`, `pnpm type-check`, and relevant tests pass.
## Prompt
Read `AGENTS.md`, `docs/Glossary.md`, database schema exports, `apps/web/server/trpc.ts`, and `apps/web/server/lib/resolve-workspace.ts` before writing. Use TDD for authorization and idempotency. Keep this brief limited to durable persistence/API and atomic outbox writes; do not implement outbox publication, WebSockets, or UI.

View file

@ -1,55 +0,0 @@
---
brief: 2
convoy: workspace-chat-foundation
role: role-implementer
title: Transactional outbox and realtime chat delivery
status: blocked
priority: P1
slice_dependencies:
- brief-1-chat-persistence
recommended_model: auto/coding
model_tier: standard
---
# Brief 2 — `Task-chat-realtime-delivery.md`
## Goal
Deliver committed chat events to authenticated subscribers across instances while preserving Postgres replay as the recovery path.
## Files
Expected scope; refine after brief 1 lands.
- `packages/database/src/schema/` — consume the outbox and per-channel sequence contracts from brief 1; add a generated migration only if claim/retry state genuinely requires a schema extension.
- `packages/shared/src/` — consume or refine the versioned chat event envelopes and `(channel_id, sequence)` cursor schemas from brief 1 without creating a second contract.
- `apps/collab-server/src/index.ts` and focused modules — authenticated WebSocket subscriptions and Redis fan-out.
- `apps/worker/src/` or the established background-job boundary — claim/publish/retry outbox records.
- `apps/web/` realtime client integration — authenticated connection and catch-up trigger only; visual chat UI belongs to brief 3.
- Adjacent tests for authorization, retries, ordering, reconnect, and Redis outage behavior.
## Required design
- Commands remain HTTP/tRPC; do not introduce browser gRPC.
- Brief 1 already commits message/read-state rows and outbox events atomically; this brief must preserve that boundary and must not publish before commit.
- Publishers are at-least-once; consumers and clients handle duplicates by outbox event ID and durable entity ID.
- Redis carries cross-instance fan-out, not canonical history.
- Subscriptions and replay use Brief 1's workspace AND context AND channel authorization, never workspace membership alone. Public is relative to the authorized context; private/direct also require membership. Archived channels allow authorized read/catch-up only. Recheck on delivery and permission changes, close revoked subscriptions and stop fan-out; a Redis topic or cached subscription is not authority. Object contexts remain rejected until the context-policy follow-on.
- Each subscribed channel tracks its own `(channel_id, sequence)` cursor. Reconnect catch-up queries Postgres independently for each authorized channel; no workspace-global ordering is implied.
- Message, thread-reply, tombstone, channel lifecycle, membership, and read-state events use one versioned envelope. `markChannelRead` invalidations update badges on the user's other devices.
- Reuse Hocuspocus/Yjs only for ephemeral presence, typing, or drafts.
## Acceptance
- A committed message remains queryable and is eventually delivered after Redis recovers.
- Two realtime instances fan the same event to their local authorized subscribers.
- Duplicate outbox publishes do not duplicate rendered durable messages or read-state changes.
- Multi-channel reconnect catches each channel up from its own sequence without assuming cross-channel ordering.
- Public, private, direct, and archived subscription rules match Brief 1 and unauthorized attempts disclose no data.
- A read-state event clears or recalculates unread state on another connected device.
- Heartbeat, bounded buffers/backpressure, graceful shutdown, and reconnect behavior are covered.
- `pnpm lint && pnpm type-check && pnpm test && pnpm build` pass.
## Prompt
Read `config/CursorSync.md` for collab-server lifecycle, the current Hocuspocus/Redis configuration, and brief 1's resulting schema/API. Keep durable messages out of Yjs. Document any deploy-impacting port or process changes explicitly.

View file

@ -1,55 +0,0 @@
---
brief: 3
convoy: workspace-chat-foundation
role: role-implementer
title: Chat workspace UI and left-rail navigation
status: blocked
priority: P1
slice_dependencies:
- brief-1-chat-persistence
- brief-2-chat-realtime
recommended_model: auto/coding
model_tier: standard
---
# Brief 3 — `Task-chat-ui-and-navigation.md`
## Goal
Expose workspace chat through the existing application shell with accessible channel navigation, durable history/read state, and realtime updates.
## Files
Expected scope; confirm conventions against nearby workspace routes and UI components.
- `apps/web/app/(app)/[workspaceSlug]/chat/page.tsx` — channel selection/redirect or empty state.
- `apps/web/app/(app)/[workspaceSlug]/chat/[channelSlug]/page.tsx` — channel conversation route.
- `apps/web/components/layout/icon-rail.tsx` — Chat destination and accessible unread/mention badge.
- `apps/web/components/chat/` — channel list, paginated message feed, composer, thread affordance, connection state, and retry UI.
- `apps/web/components/providers/` or focused hooks — realtime event reconciliation with tRPC/React Query caches.
- Adjacent component/unit tests.
## Required design
- Server state is authoritative; realtime events invalidate or reconcile durable query data. Foundation UI supports workspace-context channels only; project/object entry points wait for the context authorization task. Never expose unauthorized titles/previews, mention candidates or counts; purge inaccessible cached conversations on denial/revocation. Mention UI must not promise agent addition/execution before its reviewed membership flow exists.
- Ordinary unread activity uses a dot. In this convoy, the numeric badge is computed from persisted message-mention rows above each channel read cursor plus unread direct-channel activity. The later notifications epic adds unified inbox projections without changing the rail contract.
- Unread means a channel's highest message sequence is greater than the member's last-read sequence. Opening a channel does not mark unseen content read until the client intentionally advances the visible boundary.
- Direct-channel unread activity contributes to the numeric Chat badge; ordinary public/private channel activity contributes only the unread dot unless the user is mentioned.
- Optimistic messages and thread replies have deterministic pending, committed, failed, and retry states keyed by the idempotency key.
- History and thread history use per-channel sequence cursor pagination and preserve scroll position while loading older messages.
- Realtime read-state invalidations reconcile rail/channel badges across the user's devices.
- Keyboard and screen-reader behavior cover navigation, unread indicators, composer, threads, and failures.
- Use existing Radix/Tailwind primitives and the current right-panel pattern for a minimally complete thread list/reply surface.
## Acceptance
- A user can enter Chat from the left rail, switch channels, send/retry messages and thread replies, intentionally advance the read boundary, and reload without losing state.
- Realtime messages and replies appear once and per-channel reconnect catch-up converges to server truth.
- Rail/channel unread, direct-message, and mention indicators update across devices after durable mark-read operations.
- Loading, empty, unauthorized, archived, offline, and failed-send states are handled.
- Responsive layout and keyboard navigation work without inaccessible color-only signals.
- `pnpm lint && pnpm type-check && pnpm test` pass.
## Prompt
Read the workspace layout, icon rail, right panel, UI primitives, tRPC provider, and the APIs/events produced by briefs 1–2. Do not add a separate design system or raw internal fetch calls. Keep notification inbox and agent participation out of this slice.

View file

@ -2,15 +2,8 @@
"_comment": "Echodo's own MCP server. Spawned per Cursor session over stdio. The 'mcp' script in apps/mcp-server/package.json invokes tsx with --env-file=../../.env so DATABASE_URL is read from the repo's .env without leaking it into this committed file. The -s/--silent flag on pnpm suppresses its progress output that would otherwise corrupt the MCP stdio protocol. If you fork the repo, copy .env.example → .env and fill in DATABASE_URL.",
"mcpServers": {
"echodo": {
"command": "/Users/rstillw/.nvm/versions/node/v22.14.0/bin/pnpm",
"args": [
"-s",
"-C",
"/Users/rstillw/Documents/Personal Coding Projects/tasks",
"--filter",
"@tasks/mcp-server",
"mcp"
]
"command": "pnpm",
"args": ["-s", "--filter", "@tasks/mcp-server", "mcp"]
}
}
}

View file

@ -1,35 +0,0 @@
---
description: Pipeline planning uses .convoys/*.md — not Cursor Plan files in .cursor/plans/
alwaysApply: true
---
# Convoy planning (pipeline work)
When scoping, planning, or kicking off **pipeline work** (features, hotfixes, epics tracked by L2 roles), use **convoy files** — not Cursor's native Plan mode.
## Where plans live
| Use case | Location | Who writes it |
| --- | --- | --- |
| **Product backlog** (Plan/Epic/Task) | `plans/` → Echodo DB | Humans + markdown import |
| **Pipeline convoy** (idea → briefs → PR) | `.convoys/<slug>.md` | `role-conductor`, then other L2 roles |
| **Implementer briefs** | `.convoys/<slug>/brief-<N>-<title>.md` | `role-architect` |
| **Cursor Plan mode** (ad-hoc, non-pipeline) | `.cursor/plans/*.plan.md` | Cursor UI — **not** the agent-pipeline |
## Do
- Start pipeline work: *"Run role-conductor: start a new convoy for [idea]. Success = [metric]."*
- Read [`.convoys/README.md`](mdc:.convoys/README.md) for frontmatter, skip flags, and lifecycle.
- Use `model_policy.roles` with **registry alias names** per [docs/adr-model-routing.md](mdc:docs/adr-model-routing.md).
- Log role completion: `bash scripts/log-convoy-event.sh role=... convoy=...` when the script exists.
- Dogfood Echodo: `claim_task` / `complete_task` on the matching `plans/**/Task-*.md` during implementer sessions.
## Do not
- Create `.cursor/plans/` files for convoy or L2-role work.
- Store convoy execution state only in chat when a `.convoys/<slug>.md` file should exist.
- Put secrets or API keys in convoy frontmatter — use `model_policy` alias names only.
## If a `.cursor/plans/` file already exists
That is Cursor's native Plan artifact, not a pipeline convoy. Copy useful content into `.convoys/<slug>.md` and continue from the convoy file.

View file

@ -1,50 +0,0 @@
# Docker build context is the repo root (see docker/docker-compose.coolify.yml).
# Keep in sync with docker/.dockerignore.
# Dependencies & build outputs
**/node_modules
**/.pnpm-store
**/.next
**/dist
**/.turbo
# Git & IDE (worker image copies .cursor; keep context lean via explicit COPY paths in Dockerfile)
.git
.gitignore
.idea
.vscode
*.swp
*.swo
# Env & secrets (compose should inject at runtime)
.env
.env.*
!.env.example
apps/web/.env
# Logs & caches
logs
**/*.log
npm-debug.log*
pnpm-debug.log*
.turbo
.cache
# Tests & misc
coverage
**/__tests__
**/*.test.ts
**/*.test.tsx
**/*.spec.ts
**/*.spec.tsx
# Docs (not needed in image)
**/*.md
LICENSE
# OS
.DS_Store
Thumbs.db
# Docker (avoid nested contexts)
docker/**/*.md

View file

@ -2,9 +2,11 @@
# ECHODO — Environment Variables
# =====================================================================
# Local dev: copy this file to .env and adjust values.
# Production (CT 116 standalone LXC): paste into /opt/submatter/.env on the host.
# See docs/deployment-production.md and proxmox/ct116/README.md.
# See AGENT-DEPLOY.md for shared CT 102 service credentials and Traefik routing.
# Coolify (CT 107): paste these into the resource's "Environment Variables"
# tab. Coolify also accepts a .env file uploaded via the UI.
#
# See AGENT-DEPLOY.md for shared CT 102 service credentials and the
# CT 100 Traefik routing pattern.
# =====================================================================
# ----- Database (CT 102 shared Postgres) -----
@ -18,50 +20,11 @@
# Production (Coolify, CT 102): dedicated `echodo` user/db on shared Postgres
DATABASE_URL="postgresql://echodo:CHANGE_ME@192.168.68.102:5432/echodo"
# UUID of the workspace that owns imported `plans/` rows (slug or UUID from Settings).
# For dogfooding this repo, use the `convoys-tasks` workspace id.
# Required by `pnpm --filter @tasks/database import:markdown-backlog` and
# `watch:markdown-backlog` (not loaded automatically — set in `.env`).
MARKDOWN_BACKLOG_WORKSPACE_ID="00000000-0000-0000-0000-000000000000"
# ----- Redis (CT 102 shared Redis) -----
# Use a dedicated DB index per AGENT-DEPLOY.md (ECHODO uses /11)
# Local dev: redis://localhost:6379
REDIS_URL="redis://:CHANGE_ME@192.168.68.102:6379/11"
# ----- Orchestrator (apps/orchestrator) -----
# ORCHESTRATOR_DRY_RUN=1 logs dispatch/transition without Redis enqueue
# ORCHESTRATOR_POLL_MS=5000
# ORCHESTRATOR_STALL_MINUTES=120
# ORCHESTRATOR_MAX_IMPLEMENTERS=2
# ----- Worker (apps/worker) -----
# WORKER_DRY_RUN=1 skips Redis dequeue loop
# WORKER_BLPOP_TIMEOUT_S=5
# WORKER_MAX_TOOL_STEPS=12
# WORKER_CHECKPOINT_EVERY_N_STEPS=1
# WORKER_REPO_ROOT= # defaults to monorepo root (pnpm-workspace.yaml walk-up)
# WORKER_WORKTREE_BASE=/tmp/echodo-worker-worktrees
#
# Phase B — coding harness (optional)
# Preferred: OpenCode (COMPOSE_PROFILES=opencode)
# WORKER_SESSION_TYPE=minimal # set to `opencode` when opencode profile is running
# OPENCODE_SERVER_URL=http://opencode:4096
# OPENCODE_SERVER_PASSWORD= # openssl rand -hex 24; must match container
# OPENCODE_SERVER_USERNAME=opencode
# OPENCODE_PROVIDER_ID=omniroute
# WORKER_OPENCODE_WORKSPACE=/workspace/repo
# OMNIROUTE_BASE_URL=https://omniroute.stillwell.cloud/v1
# OMNIROUTE_API_KEY=
# OMNIROUTE_MODEL=auto/coding
#
# Legacy: OpenHands agent-server (COMPOSE_PROFILES=agent-server)
# WORKER_SESSION_TYPE=openhands
# AGENT_SERVER_URL=http://agent-server:8001
# WORKER_OPENHANDS_WORKSPACE=/workspace/repo
# SESSION_API_KEY= # must match LOCAL_BACKEND_API_KEY on agent-server
# LOCAL_BACKEND_API_KEY= # generate: openssl rand -hex 32
# ----- Auth (NextAuth v5) -----
# Local: http://localhost:3000 • Prod: https://echodo.stillwell.cloud
NEXTAUTH_URL="http://localhost:3000"
@ -89,11 +52,10 @@ AUTH_AUTHENTIK_ISSUER="https://auth.stillwell.cloud/application/o/echodo/"
# ----- Realtime collab server (Hocuspocus / Yjs) -----
# Server-side reference (used by Next.js server actions, if any)
COLLAB_SERVER_URL="ws://localhost:1234"
# Browser-facing — must be wss:// in production because the page is https.
# Baked into the web image at BUILD time (docker build args) — change requires rebuild.
# Browser-facing — must be wss:// in production because the page is https
NEXT_PUBLIC_COLLAB_SERVER_URL="ws://localhost:1234"
# ----- Umami Analytics (CT 112 — analytics.stillwell.cloud) -----
# ----- Umami Analytics (CT 107 — analytics.stillwell.cloud) -----
# In Umami: Settings → Websites → Add website. Copy the website UUID.
NEXT_PUBLIC_UMAMI_SCRIPT="https://analytics.stillwell.cloud/script.js"
NEXT_PUBLIC_UMAMI_WEBSITE_ID=""
@ -102,66 +64,24 @@ NEXT_PUBLIC_UMAMI_WEBSITE_ID=""
NEXT_PUBLIC_LIBREDESK_URL="https://support.stillwell.cloud"
NEXT_PUBLIC_LIBREDESK_WIDGET_ID=""
# ----- Directus Feedback (archived — was CT 107) -----
# ----- Directus Feedback (CT 107 — directus.stillwell.cloud) -----
# Static token scoped write-only on the `feedback` collection
NEXT_PUBLIC_DIRECTUS_URL="https://directus.stillwell.cloud"
DIRECTUS_FEEDBACK_TOKEN=""
# ----- AI Providers -----
# Registry aliases (Epic 1) read these env var NAMES from the DB — values
# live here on the server/worker host, never in Postgres.
#
# Local Ollama on Mac:
# OPENAI_BASE_URL=http://<mac-lan-ip>:11434/v1
# OPENAI_API_KEY=ollama
# OPENAI_MODEL=qwen2.5-coder:32b
#
# Local MLX (mlx-openai-server on Mac — preferred for parallel implementers):
# MLX_SERVER_URL=http://<mac-lan-ip>:8000/v1
# MLX_API_KEY=local
# (Register as openai_compatible alias qwen-coder-mlx in Settings when Epic 1 ships)
#
# Cloud / free-tier (see docs/adr-model-routing.md):
# Use OPENAI_BASE_URL=http://192.168.68.108:11434/v1 to route through
# local Ollama (CT 108) instead of the OpenAI API.
OPENAI_API_KEY=""
OPENAI_BASE_URL=""
ANTHROPIC_API_KEY=""
GOOGLE_GENERATIVE_AI_API_KEY=""
NVIDIA_API_KEY=""
# ----- OmniRoute (CT 108 — omniroute.stillwell.cloud) -----
# Used by worker + web (Settings → AI provider tests) + Phase B agent-server.
OMNIROUTE_BASE_URL="https://omniroute.stillwell.cloud/v1"
OMNIROUTE_API_KEY=""
OMNIROUTE_MODEL=auto/coding
# ----- Connected repos (Settings → Connected repos) -----
# Token values live here; Settings stores only the env var NAME (e.g. FORGEJO_TOKEN).
# Forgejo self-hosted:
# Remote URL: https://forgejo.stillwell.cloud/rstillwell/Submatter.git
# Token env var name in UI: FORGEJO_TOKEN
FORGEJO_TOKEN=""
# GitHub.com:
# Remote URL: https://github.com/org/repo.git
# Token env var name in UI: GITHUB_TOKEN
GITHUB_TOKEN=""
# ----- Forge webhooks (PR merged → convoy transition) -----
FORGE_WEBHOOK_SECRET=""
# ----- MCP Server -----
MCP_SERVER_PORT=3001
# =====================================================================
# Production Docker Compose build (8 GB hosts)
# =====================================================================
# Serialize image builds to avoid kernel OOM (exit 255) during export.
# Dokploy / standalone: set Custom Build Command to `bash scripts/build-prod.sh`
# Local verify: pnpm docker:build:prod
# Legacy alias: pnpm docker:build:coolify
# =====================================================================
# Local Docker Compose only (docker/docker-compose.yml)
# Not needed for production (uses CT 102 shared Postgres/Redis).
# Not needed for Coolify (Coolify uses CT 102's shared Postgres/Redis).
# =====================================================================
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
@ -171,13 +91,4 @@ POSTGRES_DB=echodo
POSTGRES_PORT=5432
REDIS_PORT=6379
WEB_PORT=3000
# ----- Chat (durable channel messaging) -----
# Shared secret for signing short-lived subscription tickets. Must match on web and collab.
# Generate at least 32 random bytes, for example: openssl rand -hex 32
CHAT_TICKET_SECRET="CHANGE_ME_USE_A_RANDOM_32_BYTE_SECRET"
# Internal collab chat listener port (distinct from Hocuspocus/Yjs on 1234)
CHAT_PORT=1235
# Public WSS endpoint, baked into the web bundle at build time. Rebuild web after changing.
NEXT_PUBLIC_CHAT_SERVER_URL="wss://echodo-chat.stillwell.cloud"
COLLAB_PORT=1234

View file

@ -1,41 +0,0 @@
name: CI
on:
pull_request:
push:
branches: [main]
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
check:
name: Lint, type-check, test
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up pnpm
uses: pnpm/action-setup@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
- name: Type-check
run: pnpm type-check
- name: Test
run: pnpm test

View file

@ -1,40 +0,0 @@
name: Deploy homelab (CT 116)
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: deploy-homelab-main
cancel-in-progress: true
jobs:
deploy:
name: Deploy to CT 116
runs-on: [self-hosted, linux, axiom]
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Sync and deploy over SSH
env:
DEPLOY_HOST: ${{ secrets.HOMELAB_DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.HOMELAB_DEPLOY_USER }}
DEPLOY_PATH: ${{ secrets.HOMELAB_DEPLOY_PATH }}
DEPLOY_SSH_KEY: ${{ secrets.HOMELAB_DEPLOY_SSH_KEY }}
run: |
set -euo pipefail
if [[ -z "${DEPLOY_HOST:-}" || -z "${DEPLOY_USER:-}" || -z "${DEPLOY_SSH_KEY:-}" ]]; then
echo "Skipping deploy: set HOMELAB_DEPLOY_HOST, HOMELAB_DEPLOY_USER, HOMELAB_DEPLOY_SSH_KEY repo secrets."
exit 0
fi
deploy_path="${DEPLOY_PATH:-/opt/submatter}"
mkdir -p ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=accept-new \
"${DEPLOY_USER}@${DEPLOY_HOST}" \
"set -euo pipefail; cd '${deploy_path}' && bash scripts/git-sync-deploy.sh && bash scripts/deploy-prod.sh build"

View file

@ -26,6 +26,8 @@ jobs:
# versions are more reproducible and the lockfile is pinned to 9.x.
- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Set up Node.js
uses: actions/setup-node@v4

View file

@ -1,41 +0,0 @@
name: Deploy homelab (CT 116)
on:
push:
branches: [main]
workflow_dispatch:
# CI already ran on the PR; main deploy is serialized and can take 15–30 min on axiom.
concurrency:
group: deploy-homelab-main
cancel-in-progress: true
jobs:
deploy:
name: Deploy to CT 116
runs-on: [self-hosted, linux, axiom]
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Sync and deploy over SSH
env:
DEPLOY_HOST: ${{ secrets.HOMELAB_DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.HOMELAB_DEPLOY_USER }}
DEPLOY_PATH: ${{ secrets.HOMELAB_DEPLOY_PATH }}
DEPLOY_SSH_KEY: ${{ secrets.HOMELAB_DEPLOY_SSH_KEY }}
run: |
set -euo pipefail
if [[ -z "${DEPLOY_HOST:-}" || -z "${DEPLOY_USER:-}" || -z "${DEPLOY_SSH_KEY:-}" ]]; then
echo "Skipping deploy: set HOMELAB_DEPLOY_HOST, HOMELAB_DEPLOY_USER, HOMELAB_DEPLOY_SSH_KEY repo secrets."
exit 0
fi
deploy_path="${DEPLOY_PATH:-/opt/submatter}"
mkdir -p ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=accept-new \
"${DEPLOY_USER}@${DEPLOY_HOST}" \
"set -euo pipefail; cd '${deploy_path}' && bash scripts/git-sync-deploy.sh && bash scripts/deploy-prod.sh build"

3
.gitignore vendored
View file

@ -55,6 +55,3 @@ docker/**/data/
# misc
*.pem
coverage/
# Convoy pipeline metrics (local-only by default; see .convoys/README.md)
.convoys/.metrics.jsonl

View file

@ -6,8 +6,7 @@
"dev": "tsx watch src/index.ts",
"build": "tsup",
"start": "node dist/index.mjs",
"type-check": "tsc --noEmit",
"test": "vitest run"
"type-check": "tsc --noEmit"
},
"dependencies": {
"@hocuspocus/common": "^2.15.0",
@ -17,15 +16,12 @@
"@tasks/database": "workspace:*",
"drizzle-orm": "^0.38.0",
"ioredis": "^5.4.2",
"ws": "^8.20.0",
"yjs": "^13.6.22",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/ws": "^8.18.2",
"tsup": "^8.3.5",
"tsx": "^4.19.2",
"typescript": "^5.7.0",
"vitest": "^2.1.9"
"typescript": "^5.7.0"
}
}

View file

@ -1,86 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { createHmac } from "node:crypto";
import { authorizeChannel, parseTopic, verifyTicket } from "./chat.js";
const workspaceId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const channelId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const userId = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
const secret = "a".repeat(40);
const claims = {
iss: "echodo-web",
aud: "echodo-chat-v1",
scope: "chat:subscribe",
sub: userId,
workspaceId,
exp: Math.floor(Date.now() / 1000) + 30,
};
function ticket(payload: object, key = secret) {
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
return `${body}.${createHmac("sha256", key).update(body).digest("base64url")}`;
}
describe("chat ticket", () => {
it("rejects unsigned, altered, expired and wrong-scope tickets", () => {
expect(verifyTicket(ticket(claims), secret)).toEqual(claims);
expect(verifyTicket(ticket(claims).replace(/.$/, "x"), secret)).toBeNull();
expect(verifyTicket(ticket({ ...claims, exp: 1 }), secret)).toBeNull();
expect(
verifyTicket(ticket({ ...claims, scope: "yjs" }), secret),
).toBeNull();
expect(
verifyTicket(ticket({ ...claims, aud: "other" }), secret),
).toBeNull();
expect(verifyTicket(ticket(claims, "b".repeat(40)), secret)).toBeNull();
});
});
describe("topic and authorization", () => {
it("accepts only exact workspace and channel UUID topics", () => {
expect(
parseTopic(`chat:v1:workspace:${workspaceId}:channel:${channelId}`),
).toEqual({ workspaceId, channelId });
expect(
parseTopic(`chat:v1:workspace:${workspaceId}:channel:${channelId}:evil`),
).toBeNull();
});
it("requires workspace membership, and channel membership for private/direct (no admin bypass)", async () => {
const lookup = vi
.fn()
.mockResolvedValueOnce({
kind: "public",
workspaceMember: true,
channelMember: false,
})
.mockResolvedValueOnce({
kind: "private",
workspaceMember: true,
channelMember: false,
})
.mockResolvedValueOnce({
kind: "direct",
workspaceMember: true,
channelMember: true,
})
.mockResolvedValueOnce({
kind: "public",
workspaceMember: false,
channelMember: true,
})
.mockRejectedValueOnce(new Error("db down"));
expect(await authorizeChannel(lookup, workspaceId, channelId, userId)).toBe(
true,
);
expect(await authorizeChannel(lookup, workspaceId, channelId, userId)).toBe(
false,
);
expect(await authorizeChannel(lookup, workspaceId, channelId, userId)).toBe(
true,
);
expect(await authorizeChannel(lookup, workspaceId, channelId, userId)).toBe(
false,
);
expect(await authorizeChannel(lookup, workspaceId, channelId, userId)).toBe(
false,
);
});
});

View file

@ -1,400 +0,0 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
import { and, eq } from "drizzle-orm";
import IoRedis from "ioredis";
import { WebSocketServer, type WebSocket } from "ws";
import { z } from "zod";
import { db } from "@tasks/database/client";
import {
chatChannelMembers,
chatChannels,
workspaceMembers,
} from "@tasks/database/schema";
const uuid = z.string().uuid();
const ticketClaims = z
.object({
iss: z.literal("echodo-web"),
aud: z.literal("echodo-chat-v1"),
scope: z.literal("chat:subscribe"),
sub: uuid,
workspaceId: uuid,
exp: z.number().int().positive(),
})
.strict();
export type ChatTicket = z.infer<typeof ticketClaims>;
const topicPattern =
/^chat:v1:workspace:([0-9a-f-]{36}):channel:([0-9a-f-]{36})$/;
const subscribe = z
.object({ type: z.literal("subscribe"), channelId: uuid })
.strict();
const unsubscribe = z
.object({ type: z.literal("unsubscribe"), channelId: uuid })
.strict();
const clientMessage = z.discriminatedUnion("type", [subscribe, unsubscribe]);
const MAX_CONNECTIONS = 2000;
const MAX_CHANNELS = 64;
const MAX_MESSAGE_BYTES = 512;
const MAX_BUFFERED_BYTES = 64 * 1024;
const HEARTBEAT_MS = 15_000;
const AUTH_TIMEOUT_MS = 5_000;
export function verifyTicket(value: string, secret: string): ChatTicket | null {
if (secret.length < 32 || value.length > 2048) return null;
const parts = value.split(".");
if (
parts.length !== 2 ||
!parts[0] ||
!/^[A-Za-z0-9_-]+$/.test(parts[0]) ||
!/^[A-Za-z0-9_-]+$/.test(parts[1])
)
return null;
const expected = createHmac("sha256", secret).update(parts[0]).digest();
const supplied = Buffer.from(parts[1], "base64url");
if (
supplied.length !== expected.length ||
!timingSafeEqual(supplied, expected)
)
return null;
try {
const parsed = ticketClaims.safeParse(
JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8")),
);
if (
!parsed.success ||
parsed.data.exp <= Math.floor(Date.now() / 1000) ||
parsed.data.exp > Math.floor(Date.now() / 1000) + 60
)
return null;
return parsed.data;
} catch {
return null;
}
}
export function parseTopic(
topic: string,
): { workspaceId: string; channelId: string } | null {
const match = topicPattern.exec(topic);
if (
!match ||
!uuid.safeParse(match[1]).success ||
!uuid.safeParse(match[2]).success
)
return null;
return { workspaceId: match[1], channelId: match[2] };
}
type Lookup = (
workspaceId: string,
channelId: string,
userId: string,
) => Promise<{
kind: string;
workspaceMember: boolean;
channelMember: boolean;
} | null>;
export async function authorizeChannel(
lookup: Lookup,
workspaceId: string,
channelId: string,
userId: string,
): Promise<boolean> {
try {
let timeout: ReturnType<typeof setTimeout> | undefined;
const row = await Promise.race([
lookup(workspaceId, channelId, userId),
new Promise<null>((resolve) => {
timeout = setTimeout(() => resolve(null), AUTH_TIMEOUT_MS);
}),
]).finally(() => clearTimeout(timeout));
return (
!!row &&
row.workspaceMember &&
(row.kind === "public" ||
((row.kind === "private" || row.kind === "direct") &&
row.channelMember))
);
} catch {
return false;
}
}
// Always query current membership; never cache authorization across notifications.
const lookupChannel: Lookup = async (workspaceId, channelId, userId) => {
const [row] = await db
.select({
kind: chatChannels.kind,
workspaceMember: workspaceMembers.userId,
channelMember: chatChannelMembers.userId,
})
.from(chatChannels)
.leftJoin(
workspaceMembers,
and(
eq(workspaceMembers.workspaceId, chatChannels.workspaceId),
eq(workspaceMembers.userId, userId),
),
)
.leftJoin(
chatChannelMembers,
and(
eq(chatChannelMembers.workspaceId, chatChannels.workspaceId),
eq(chatChannelMembers.channelId, chatChannels.id),
eq(chatChannelMembers.userId, userId),
),
)
.where(
and(
eq(chatChannels.workspaceId, workspaceId),
eq(chatChannels.id, channelId),
),
)
.limit(1);
return row
? {
kind: row.kind,
workspaceMember: !!row.workspaceMember,
channelMember: !!row.channelMember,
}
: null;
};
type Peer = {
socket: WebSocket;
ticket: ChatTicket;
channels: Set<string>;
busy: boolean;
};
/** Separate port: Hocuspocus owns its existing HTTP/WebSocket upgrade handler. */
export async function startChatServer(): Promise<void> {
const secret = process.env.CHAT_TICKET_SECRET?.trim();
const redisUrl = process.env.REDIS_URL?.trim();
if (!secret || secret.length < 32 || !redisUrl) {
console.warn(
"[chat] disabled: CHAT_TICKET_SECRET (>=32 characters) and REDIS_URL required",
);
return;
}
const port = Number(process.env.CHAT_PORT ?? 1235);
if (!Number.isInteger(port) || port < 1 || port > 65535)
throw new Error("Invalid CHAT_PORT");
const subscriber = new IoRedis(redisUrl, { maxRetriesPerRequest: 1 });
await subscriber.ping(); // Fail closed: never accept subscribers without Redis.
const http = createServer((_req, res) => {
res.writeHead(404).end();
});
const wss = new WebSocketServer({
noServer: true,
maxPayload: MAX_MESSAGE_BYTES,
perMessageDeflate: false,
});
const peers = new Set<Peer>();
const topicPeers = new Map<string, Set<Peer>>();
const send = (peer: Peer, value: object) => {
if (peer.socket.readyState !== 1) return;
if (peer.socket.bufferedAmount > MAX_BUFFERED_BYTES) {
peer.socket.close(1013);
return;
}
peer.socket.send(JSON.stringify(value));
};
const remove = (peer: Peer, topic: string) => {
peer.channels.delete(topic);
const set = topicPeers.get(topic);
set?.delete(peer);
if (set?.size === 0) {
topicPeers.delete(topic);
void subscriber.unsubscribe(topic).catch(() => {});
}
};
const revoke = (peer: Peer, topic: string, channelId: string) => {
remove(peer, topic);
send(peer, { type: "revoked", channelId });
};
const deliver = async (topic: string) => {
const ids = parseTopic(topic);
if (!ids) return;
for (const peer of [...(topicPeers.get(topic) ?? [])]) {
if (!peer.channels.has(topic)) continue;
if (
peer.ticket.exp <= Date.now() / 1000 ||
!(await authorizeChannel(
lookupChannel,
ids.workspaceId,
ids.channelId,
peer.ticket.sub,
))
) {
revoke(peer, topic, ids.channelId);
peer.socket.close(1008);
continue;
}
// Deliberately ignore Redis envelope including message bodies and keys.
if (peer.channels.has(topic))
send(peer, { type: "invalidate", channelId: ids.channelId });
}
};
subscriber.on("message", (topic) => {
void deliver(topic).catch(() => {});
});
subscriber.on("error", () => {
for (const peer of peers) peer.socket.close(1011);
});
subscriber.on("end", () => {
for (const peer of peers) peer.socket.close(1011);
});
http.on("upgrade", (req, socket, head) => {
if (
peers.size >= MAX_CONNECTIONS ||
req.url?.length === undefined ||
req.url.length > 2300
) {
socket.destroy();
return;
}
let token: string | null;
try {
const url = new URL(req.url, "http://localhost");
token = url.pathname === "/chat" ? url.searchParams.get("ticket") : null;
} catch {
token = null;
}
const ticket = token ? verifyTicket(token, secret) : null;
if (!ticket) {
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) =>
wss.emit("connection", ws, ticket),
);
});
wss.on("connection", (socket: WebSocket, ticket: ChatTicket) => {
const peer: Peer = { socket, ticket, channels: new Set(), busy: false };
peers.add(peer);
const expiry = setTimeout(
() => socket.close(1008),
Math.max(0, ticket.exp * 1000 - Date.now()),
);
expiry.unref();
socket.on("close", () => {
clearTimeout(expiry);
peers.delete(peer);
for (const topic of [...peer.channels]) remove(peer, topic);
});
socket.on("message", (bytes) => {
if (peer.busy) {
socket.close(1008);
return;
}
peer.busy = true;
void (async () => {
if (ticket.exp <= Date.now() / 1000) {
socket.close(1008);
return;
}
let data: unknown;
try {
data = JSON.parse(bytes.toString());
} catch {
socket.close(1008);
return;
}
const parsed = clientMessage.safeParse(data);
if (!parsed.success) {
socket.close(1008);
return;
}
const { channelId } = parsed.data;
const topic = `chat:v1:workspace:${ticket.workspaceId}:channel:${channelId}`;
if (parsed.data.type === "unsubscribe") {
remove(peer, topic);
return;
}
if (peer.channels.size >= MAX_CHANNELS && !peer.channels.has(topic)) {
socket.close(1008);
return;
}
if (
!(await authorizeChannel(
lookupChannel,
ticket.workspaceId,
channelId,
ticket.sub,
))
) {
socket.close(1008);
return;
}
if (peer.channels.has(topic)) return;
if (!topicPeers.has(topic)) {
try {
await subscriber.subscribe(topic);
} catch {
socket.close(1011);
return;
}
if (!topicPeers.has(topic)) topicPeers.set(topic, new Set());
}
// Recheck after the Redis subscribe await: access may have been revoked meanwhile.
if (
!(await authorizeChannel(
lookupChannel,
ticket.workspaceId,
channelId,
ticket.sub,
))
) {
socket.close(1008);
return;
}
if (socket.readyState !== 1) return;
topicPeers.get(topic)?.add(peer);
peer.channels.add(topic);
send(peer, { type: "subscribed", channelId });
})()
.catch(() => socket.close(1011))
.finally(() => {
peer.busy = false;
});
});
});
setInterval(() => {
for (const peer of peers) {
if (peer.busy) continue;
peer.busy = true;
void (async () => {
if (peer.ticket.exp <= Date.now() / 1000) {
peer.socket.close(1008);
return;
}
for (const topic of [...peer.channels]) {
const ids = parseTopic(topic);
if (
!ids ||
!(await authorizeChannel(
lookupChannel,
ids.workspaceId,
ids.channelId,
peer.ticket.sub,
))
) {
revoke(peer, topic, ids?.channelId ?? "");
peer.socket.close(1008);
return;
}
}
})()
.catch(() => peer.socket.close(1011))
.finally(() => {
peer.busy = false;
});
}
}, HEARTBEAT_MS).unref();
await new Promise<void>((resolve, reject) => {
http.once("error", reject);
http.listen(port, "0.0.0.0", resolve);
});
console.log(`[chat] listening on port ${port}`);
}

View file

@ -8,7 +8,6 @@ import { objects } from "@tasks/database/schema";
import { eq } from "drizzle-orm";
import IoRedis from "ioredis";
import { z } from "zod";
import { startChatServer } from "./chat.js";
import {
base64ToYUpdate,
extractObjectId,
@ -187,8 +186,6 @@ function colorFromName(name: string): string {
async function main() {
const port = Number(process.env.PORT) || 1234;
await startChatServer();
const extensions: Extension[] = [buildDatabaseExtension()];
const redis = await tryCreateRedisExtension();
if (redis) {

View file

@ -14,7 +14,6 @@
"@modelcontextprotocol/sdk": "^1.4.0",
"@tasks/database": "workspace:*",
"@tasks/shared": "workspace:*",
"yaml": "^2.8.3",
"zod": "^3.24.0"
},
"devDependencies": {

View file

@ -15,10 +15,8 @@ const d: any = require(drizzlePath);
export const and = d.and;
export const asc = d.asc;
export const desc = d.desc;
export const eq = d.eq;
export const ilike = d.ilike;
export const inArray = d.inArray;
export const isNull = d.isNull;
export const or = d.or;
export const sql = d.sql;

View file

@ -10,7 +10,7 @@ const mcp = new McpServer(
},
{
instructions:
"Tools and resources for Echodo: objects, workspaces, backlog claim/complete, and convoy lifecycle (create_convoy, transition_convoy_status, reconcile_from_files).",
"Tools and resources for the Tasks project management app: objects, workspaces, views, and templates.",
},
);

View file

@ -1,147 +0,0 @@
// @ts-nocheck — Node built-ins; MCP tsconfig may omit @types/node in some paths.
import { existsSync, readdirSync, readFileSync, appendFileSync, writeFileSync } from "node:fs";
import { basename, join } from "node:path";
import { parse as parseYaml } from "yaml";
import type { ConvoyClassification, ConvoyFileStatus } from "@tasks/shared/convoy-lifecycle";
const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
export type ParsedConvoyFile = {
filePath: string;
slug: string;
classification: ConvoyClassification | null;
successMetric: string | null;
skipFlags: string[];
fileStatus: ConvoyFileStatus | null;
bodyMarkdown: string;
modelPolicy: Record<string, unknown> | null;
};
export type PendingMcpSyncEntry = {
ts: string;
tool: string;
args: Record<string, unknown>;
error: string;
};
function readString(fm: Record<string, unknown>, key: string): string | null {
const value = fm[key];
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function readStringArray(fm: Record<string, unknown>, key: string): string[] {
const value = fm[key];
if (Array.isArray(value)) {
return value.filter((item): item is string => typeof item === "string");
}
return [];
}
export function parseConvoyMarkdownFile(
filePath: string,
raw: string,
): ParsedConvoyFile | null {
const fmMatch = raw.match(FRONTMATTER);
let frontmatter: Record<string, unknown> = {};
let bodyMarkdown = raw;
if (fmMatch) {
try {
const parsed = parseYaml(fmMatch[1]);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
frontmatter = parsed as Record<string, unknown>;
}
} catch {
return null;
}
bodyMarkdown = raw.slice(fmMatch[0].length);
}
const slug =
readString(frontmatter, "name") ??
basename(filePath).replace(/\.md$/i, "");
const classification = readString(frontmatter, "classification");
const fileStatus = readString(frontmatter, "status");
const modelPolicyRaw = frontmatter.model_policy;
const modelPolicy =
modelPolicyRaw && typeof modelPolicyRaw === "object" && !Array.isArray(modelPolicyRaw)
? (modelPolicyRaw as Record<string, unknown>)
: null;
return {
filePath,
slug,
classification: classification as ConvoyClassification | null,
successMetric: readString(frontmatter, "success_metric"),
skipFlags: readStringArray(frontmatter, "skip"),
fileStatus: fileStatus as ConvoyFileStatus | null,
bodyMarkdown,
modelPolicy,
};
}
export function listConvoyMarkdownFiles(repoRoot: string): ParsedConvoyFile[] {
const convoysDir = join(repoRoot, ".convoys");
if (!existsSync(convoysDir)) {
return [];
}
const results: ParsedConvoyFile[] = [];
for (const entry of readdirSync(convoysDir, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith(".md") || entry.name === "README.md") {
continue;
}
const filePath = join(convoysDir, entry.name);
const raw = readFileSync(filePath, "utf8");
const parsed = parseConvoyMarkdownFile(filePath, raw);
if (parsed) {
results.push(parsed);
}
}
return results;
}
export function pendingSyncPath(repoRoot: string): string {
return join(repoRoot, ".convoys", ".pending-mcp-sync.jsonl");
}
export function appendPendingSync(
repoRoot: string,
entry: Omit<PendingMcpSyncEntry, "ts"> & { ts?: string },
): void {
const path = pendingSyncPath(repoRoot);
const line = JSON.stringify({
ts: entry.ts ?? new Date().toISOString(),
tool: entry.tool,
args: entry.args,
error: entry.error,
});
appendFileSync(path, `${line}\n`, "utf8");
}
export function readPendingSync(repoRoot: string): PendingMcpSyncEntry[] {
const path = pendingSyncPath(repoRoot);
if (!existsSync(path)) {
return [];
}
const raw = readFileSync(path, "utf8");
const entries: PendingMcpSyncEntry[] = [];
for (const line of raw.split("\n")) {
if (!line.trim()) continue;
try {
const parsed = JSON.parse(line) as PendingMcpSyncEntry;
entries.push(parsed);
} catch {
// skip corrupt lines
}
}
return entries;
}
export function clearPendingSync(repoRoot: string): void {
const path = pendingSyncPath(repoRoot);
if (existsSync(path)) {
writeFileSync(path, "", "utf8");
}
}

View file

@ -1,94 +0,0 @@
import { and, eq, sql } from "../drizzle.js";
import { db } from "../db.js";
import { convoyEvents, objects } from "../schema.js";
import {
convoyObjectContentSchema,
type ConvoyObjectContent,
type ConvoyStatus,
} from "@tasks/shared/convoy-lifecycle";
export async function findConvoyBySlug(workspaceId: string, slug: string) {
const [row] = await db
.select()
.from(objects)
.where(
and(
eq(objects.workspaceId, workspaceId),
eq(objects.type, "project"),
sql`${objects.content} ->> 'convoySlug' = ${slug}`,
),
)
.limit(1);
return row ?? null;
}
export async function loadConvoyProject(convoyId: string, workspaceId: string) {
const [row] = await db
.select()
.from(objects)
.where(
and(
eq(objects.id, convoyId),
eq(objects.workspaceId, workspaceId),
eq(objects.type, "project"),
),
)
.limit(1);
return row ?? null;
}
export function parseConvoyContent(content: unknown): ConvoyObjectContent | null {
const parsed = convoyObjectContentSchema.safeParse(content);
return parsed.success ? parsed.data : null;
}
export async function insertConvoyEvent(input: {
workspaceId: string;
convoyId: string;
briefId?: string | null;
role: string;
actor?: string | null;
eventType: string;
classification?: string | null;
skipFlags?: string[] | null;
durationS?: number | null;
outcome?: string | null;
repo?: string | null;
multitaskGroup?: string | null;
modelAlias?: string | null;
providerType?: string | null;
modelId?: string | null;
modelTier?: string | null;
estimatedCostUsd?: string | null;
metadata?: Record<string, unknown> | null;
}) {
const [row] = await db
.insert(convoyEvents)
.values({
workspaceId: input.workspaceId,
convoyId: input.convoyId,
briefId: input.briefId ?? null,
role: input.role,
actor: input.actor ?? null,
eventType: input.eventType,
classification: input.classification ?? null,
skipFlags: input.skipFlags ?? null,
durationS: input.durationS ?? null,
outcome: input.outcome ?? null,
repo: input.repo ?? null,
multitaskGroup: input.multitaskGroup ?? null,
modelAlias: input.modelAlias ?? null,
providerType: input.providerType ?? null,
modelId: input.modelId ?? null,
modelTier: input.modelTier ?? null,
estimatedCostUsd: input.estimatedCostUsd ?? null,
metadata: input.metadata ?? null,
})
.returning();
return row;
}
export function currentConvoyStatus(row: { status: string | null }): ConvoyStatus {
const status = row.status ?? "draft";
return status as ConvoyStatus;
}

View file

@ -1,123 +0,0 @@
import { z } from "zod";
import { and, eq, isNull } from "../drizzle.js";
import { db } from "../db.js";
import { markdownBacklogItems } from "../schema.js";
export const backlogKindSchema = z.enum(["plan", "epic", "task"]);
export const backlogItemLookupFieldsSchema = z.object({
repoPath: z
.string()
.min(1)
.optional()
.describe(
"Repo-relative path under plans/, e.g. plans/Plan-automation-foundation/Epic-apps-worker-v1/Task-worker-minimal-tool-loop.md",
),
slug: z.string().min(1).optional(),
planSlug: z.string().min(1).optional(),
epicSlug: z.string().min(1).optional(),
kind: backlogKindSchema.optional(),
});
export const backlogItemLookupSchema = backlogItemLookupFieldsSchema.superRefine(
(input, ctx) => {
const hasRepoPath = Boolean(input.repoPath?.trim());
const hasSlugKeys = Boolean(input.slug?.trim() && input.planSlug?.trim());
if (!hasRepoPath && !hasSlugKeys) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"Provide repoPath, or slug + planSlug (add epicSlug for tasks/epics).",
});
}
},
);
export type BacklogItemLookup = z.infer<typeof backlogItemLookupFieldsSchema>;
export type ResolvedBacklogItem = {
id: string;
workspaceId: string;
kind: string;
slug: string;
planSlug: string;
epicSlug: string | null;
repoPath: string;
title: string;
bodyMarkdown: string;
status: string | null;
priority: string | null;
};
export const BACKLOG_ITEM_NOT_FOUND_MESSAGE =
"Backlog item not found. Run `pnpm --filter @tasks/database import:markdown-backlog` (with MARKDOWN_BACKLOG_WORKSPACE_ID set) if the plans/ file exists but has not been imported yet.";
export function normalizeRepoPath(repoPath: string): string {
return repoPath.trim().replace(/^\.\/+/, "");
}
function buildLookupConditions(
workspaceId: string,
lookup: BacklogItemLookup,
) {
const conditions = [
eq(markdownBacklogItems.workspaceId, workspaceId),
isNull(markdownBacklogItems.archivedAt),
];
if (lookup.repoPath?.trim()) {
conditions.push(
eq(markdownBacklogItems.repoPath, normalizeRepoPath(lookup.repoPath)),
);
return conditions;
}
conditions.push(eq(markdownBacklogItems.slug, lookup.slug!.trim()));
conditions.push(eq(markdownBacklogItems.planSlug, lookup.planSlug!.trim()));
if (lookup.epicSlug?.trim()) {
conditions.push(eq(markdownBacklogItems.epicSlug, lookup.epicSlug.trim()));
}
if (lookup.kind) {
conditions.push(eq(markdownBacklogItems.kind, lookup.kind));
}
return conditions;
}
const backlogItemSelect = {
id: markdownBacklogItems.id,
workspaceId: markdownBacklogItems.workspaceId,
kind: markdownBacklogItems.kind,
slug: markdownBacklogItems.slug,
planSlug: markdownBacklogItems.planSlug,
epicSlug: markdownBacklogItems.epicSlug,
repoPath: markdownBacklogItems.repoPath,
title: markdownBacklogItems.title,
bodyMarkdown: markdownBacklogItems.bodyMarkdown,
status: markdownBacklogItems.status,
priority: markdownBacklogItems.priority,
};
export async function resolveBacklogItemById(
backlogItemId: string,
): Promise<ResolvedBacklogItem | null> {
const [row] = await db
.select(backlogItemSelect)
.from(markdownBacklogItems)
.where(eq(markdownBacklogItems.id, backlogItemId))
.limit(1);
return row ?? null;
}
export async function resolveBacklogItemsByLookup(
workspaceId: string,
lookup: BacklogItemLookup,
): Promise<ResolvedBacklogItem[]> {
return db
.select(backlogItemSelect)
.from(markdownBacklogItems)
.where(and(...buildLookupConditions(workspaceId, lookup)))
.limit(5);
}

View file

@ -8,12 +8,6 @@ import {
auditLog,
markdownBacklogItems,
} from "../schema.js";
import {
BACKLOG_ITEM_NOT_FOUND_MESSAGE,
backlogItemLookupFieldsSchema,
resolveBacklogItemById,
resolveBacklogItemsByLookup,
} from "../lib/resolve-backlog-item.js";
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
import { resolveWorkflowPrompt } from "../../../../packages/database/src/markdown-backlog/resolve-prompt.ts";
import { exportBacklogItemToMarkdown } from "../../../../packages/database/src/markdown-backlog/export.ts";
@ -46,62 +40,45 @@ import { toolCatch, toolErr, toolOk } from "./tool-result.js";
* `agent_runs` separately.
*/
const claimTaskInputSchema = z
.object({
workspace: z
.string()
.min(1)
.describe("Workspace UUID or slug (e.g. 'acme' or '550e8400-...')"),
backlogItemId: z
.string()
.uuid()
.optional()
.describe(
"UUID of the markdown_backlog_items row being claimed. Optional when repoPath or slug keys are provided.",
),
actorUserId: z
.string()
.uuid()
.nullable()
.optional()
.describe(
"Optional UUID of the human user the agent is acting on behalf of. Null = anonymous dev session.",
),
notes: z
.string()
.max(2_000)
.optional()
.describe("Short opener text attached to the run (e.g. session intent)."),
client: z
.string()
.min(1)
.max(50)
.optional()
.describe(
"Caller identifier — e.g. 'cursor-ide', 'cursor-cloud', 'codex', 'echodo-orchestrator'. Persisted in agent_runs.metadata so the runs UI can slice by client.",
),
model: z
.string()
.min(1)
.max(100)
.optional()
.describe(
"Model handle driving the session — e.g. 'claude-4.6-sonnet', 'gpt-5-codex'. Free-form because new models ship faster than we can rev a zod enum.",
),
})
.merge(backlogItemLookupFieldsSchema)
.superRefine((input, ctx) => {
const hasId = Boolean(input.backlogItemId);
const hasRepoPath = Boolean(input.repoPath?.trim());
const hasSlugKeys = Boolean(input.slug?.trim() && input.planSlug?.trim());
if (!hasId && !hasRepoPath && !hasSlugKeys) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"Provide backlogItemId, repoPath, or slug + planSlug (add epicSlug for tasks/epics).",
});
}
});
const claimTaskInputSchema = z.object({
workspace: z
.string()
.min(1)
.describe("Workspace UUID or slug (e.g. 'acme' or '550e8400-...')"),
backlogItemId: z
.string()
.uuid()
.describe("UUID of the markdown_backlog_items row being claimed."),
actorUserId: z
.string()
.uuid()
.nullable()
.optional()
.describe(
"Optional UUID of the human user the agent is acting on behalf of. Null = anonymous dev session.",
),
notes: z
.string()
.max(2_000)
.optional()
.describe("Short opener text attached to the run (e.g. session intent)."),
client: z
.string()
.min(1)
.max(50)
.optional()
.describe(
"Caller identifier — e.g. 'cursor-ide', 'cursor-cloud', 'codex', 'echodo-orchestrator'. Persisted in agent_runs.metadata so the runs UI can slice by client.",
),
model: z
.string()
.min(1)
.max(100)
.optional()
.describe(
"Model handle driving the session — e.g. 'claude-4.6-sonnet', 'gpt-5-codex'. Free-form because new models ship faster than we can rev a zod enum.",
),
});
/**
* Build the metadata object stored on agent_runs at claim time. Returns
@ -123,7 +100,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
"claim_task",
{
description:
"Open an agent_runs row against a backlog item, transition it to in_progress (if eligible), and return the effective workflow prompt for the session. Identify the item by backlogItemId, repoPath, or slug + planSlug.",
"Open an agent_runs row against a backlog item, transition it to in_progress (if eligible), and return the effective workflow prompt for the session.",
inputSchema: claimTaskInputSchema,
},
async (args) => {
@ -131,42 +108,27 @@ export function registerClaimTaskTool(mcp: McpServer): void {
const input = claimTaskInputSchema.parse(args);
const ws = await resolveWorkspaceHandle(input.workspace);
let item;
let backlogItemId: string;
const [item] = await db
.select({
id: markdownBacklogItems.id,
workspaceId: markdownBacklogItems.workspaceId,
title: markdownBacklogItems.title,
bodyMarkdown: markdownBacklogItems.bodyMarkdown,
status: markdownBacklogItems.status,
})
.from(markdownBacklogItems)
.where(eq(markdownBacklogItems.id, input.backlogItemId))
.limit(1);
if (input.backlogItemId) {
const found = await resolveBacklogItemById(input.backlogItemId);
if (!found) {
return toolErr(`Backlog item ${input.backlogItemId} not found`);
}
item = found;
backlogItemId = found.id;
} else {
const matches = await resolveBacklogItemsByLookup(ws.id, {
repoPath: input.repoPath,
slug: input.slug,
planSlug: input.planSlug,
epicSlug: input.epicSlug,
kind: input.kind,
});
if (matches.length === 0) {
return toolErr(BACKLOG_ITEM_NOT_FOUND_MESSAGE);
}
if (matches.length > 1) {
return toolErr(
`Ambiguous backlog lookup (${matches.length} matches). Add epicSlug/kind or use repoPath.`,
);
}
item = matches[0]!;
backlogItemId = item.id;
if (!item) {
return toolErr(`Backlog item ${input.backlogItemId} not found`);
}
if (item.workspaceId !== ws.id) {
// Tenancy fence: the backlog item exists but in a different
// workspace than the resolved handle. Refuse rather than 404 so
// the caller can't fish for cross-tenant existence.
return toolErr(
`Backlog item ${backlogItemId} does not belong to workspace ${ws.slug}`,
`Backlog item ${input.backlogItemId} does not belong to workspace ${ws.slug}`,
);
}
@ -188,7 +150,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
.from(agentRuns)
.where(
and(
eq(agentRuns.backlogItemId, backlogItemId),
eq(agentRuns.backlogItemId, input.backlogItemId),
isNull(agentRuns.finishedAt),
),
)
@ -228,7 +190,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
const resolved = await resolveWorkflowPrompt(db, {
workspaceId: ws.id,
backlogItemId,
backlogItemId: input.backlogItemId,
});
return toolOk({
@ -241,7 +203,6 @@ export function registerClaimTaskTool(mcp: McpServer): void {
title: item.title,
bodyMarkdown: item.bodyMarkdown,
status: item.status,
repoPath: item.repoPath,
},
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
});
@ -258,7 +219,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
.insert(agentRuns)
.values({
workspaceId: ws.id,
backlogItemId,
backlogItemId: input.backlogItemId,
actorUserId,
notes: input.notes ?? null,
metadata: claimMeta,
@ -269,7 +230,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
await tx
.update(markdownBacklogItems)
.set({ status: "in_progress", updatedAt: new Date() })
.where(eq(markdownBacklogItems.id, backlogItemId));
.where(eq(markdownBacklogItems.id, input.backlogItemId));
}
await tx.insert(auditLog).values({
@ -279,7 +240,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
targetType: "agent_run",
targetId: created.id,
metadata: {
backlog_item_id: backlogItemId,
backlog_item_id: input.backlogItemId,
prior_status: priorStatus,
transitioned: shouldTransition,
...(claimMeta ?? {}),
@ -292,7 +253,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
const resolved = await resolveWorkflowPrompt(db, {
workspaceId: ws.id,
backlogItemId,
backlogItemId: input.backlogItemId,
});
// Only export when the row's status actually changed. On a
@ -308,7 +269,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
try {
const exportResult = await exportBacklogItemToMarkdown(db, {
workspaceId: ws.id,
backlogItemId,
backlogItemId: input.backlogItemId,
repoRootAbs: repoRoot,
});
if (exportResult.ok) {
@ -341,7 +302,6 @@ export function registerClaimTaskTool(mcp: McpServer): void {
title: item.title,
bodyMarkdown: item.bodyMarkdown,
status: shouldTransition ? "in_progress" : priorStatus,
repoPath: item.repoPath,
},
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
export: exportSummary,

View file

@ -1,6 +1,5 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { modelTierSchema, providerTypeSchema } from "@tasks/shared/model-routing";
import { db } from "../db.js";
import { eq } from "../drizzle.js";
import { agentRuns, auditLog, markdownBacklogItems } from "../schema.js";
@ -45,15 +44,6 @@ const completeTaskInputSchema = z.object({
.describe(
"Late-bound model handle. Same merge semantics as `client` — pass this when the model that closed the session differs from the one that opened it.",
),
modelAlias: z
.string()
.min(1)
.max(120)
.optional()
.describe("Registry alias used for this session (e.g. qwen-coder)."),
modelTier: modelTierSchema.optional(),
providerType: providerTypeSchema.optional(),
modelId: z.string().min(1).max(200).optional(),
});
type CompleteTaskInput = z.infer<typeof completeTaskInputSchema>;
@ -125,10 +115,6 @@ export function registerCompleteTaskTool(mcp: McpServer): void {
const incomingMeta: Record<string, unknown> = {};
if (input.client) incomingMeta.client = input.client;
if (input.model) incomingMeta.model = input.model;
if (input.modelAlias) incomingMeta.modelAlias = input.modelAlias;
if (input.modelTier) incomingMeta.modelTier = input.modelTier;
if (input.providerType) incomingMeta.providerType = input.providerType;
if (input.modelId) incomingMeta.modelId = input.modelId;
const hasIncomingMeta = Object.keys(incomingMeta).length > 0;
const mergedMeta = hasIncomingMeta
? { ...(run.metadata ?? {}), ...incomingMeta }
@ -145,14 +131,6 @@ export function registerCompleteTaskTool(mcp: McpServer): void {
tokensTotal,
notes: input.notes ?? null,
error: input.error ?? null,
...(input.modelAlias !== undefined
? { modelAlias: input.modelAlias }
: {}),
...(input.modelTier !== undefined ? { modelTier: input.modelTier } : {}),
...(input.providerType !== undefined
? { providerType: input.providerType }
: {}),
...(input.modelId !== undefined ? { modelId: input.modelId } : {}),
...(hasIncomingMeta ? { metadata: mergedMeta } : {}),
})
.where(eq(agentRuns.id, input.runId));

View file

@ -1,362 +0,0 @@
import { mkdir, writeFile, rename, access } from "node:fs/promises";
import { dirname, join } from "node:path";
import { randomUUID } from "node:crypto";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { parseDocument, Scalar } from "yaml";
import { eq, and } from "../drizzle.js";
import { db } from "../db.js";
import { markdownBacklogItems } from "../schema.js";
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
import { resolveRepoRoot } from "../lib/repo-root.js";
import { parentRepoPath } from "../../../../packages/database/src/markdown-backlog/paths.ts";
import { hashFileContents } from "../../../../packages/database/src/markdown-backlog/parse.ts";
import { toolCatch, toolErr, toolOk } from "./tool-result.js";
const slugRe = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const kindSchema = z.enum(["plan", "epic", "task"]);
const statusSchema = z.enum(["draft", "ready", "in_progress", "blocked", "done", "cancelled"]);
const prioritySchema = z.enum(["P0", "P1", "P2", "P3"]);
const createBacklogItemInputSchema = z
.object({
workspace: z.string().min(1).describe("Workspace UUID or slug (e.g. 'acme' or '550e8400-...')"),
kind: kindSchema,
slug: z
.string()
.min(1)
.max(200)
.regex(slugRe, "slug must be lowercase-hyphenated (a-z, 0-9, hyphen)"),
title: z.string().min(1).max(500),
planSlug: z
.string()
.min(1)
.max(200)
.regex(slugRe, "planSlug must be lowercase-hyphenated")
.optional(),
epicSlug: z
.string()
.min(1)
.max(200)
.regex(slugRe, "epicSlug must be lowercase-hyphenated")
.optional(),
status: statusSchema.optional(),
priority: prioritySchema.optional(),
bodyMarkdown: z.string().optional(),
agent_prompt: z.string().nullable().optional().describe("Optional per-item agent prompt (multiline supported)"),
})
.superRefine((input, ctx) => {
if (input.kind === "plan") {
if (input.epicSlug) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "epicSlug must not be provided for kind=plan",
path: ["epicSlug"],
});
}
if (input.planSlug && input.planSlug !== input.slug) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "planSlug for kind=plan must equal slug or be omitted",
path: ["planSlug"],
});
}
}
if (input.kind === "epic") {
if (!input.planSlug?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "planSlug is required for kind=epic",
path: ["planSlug"],
});
}
}
if (input.kind === "task") {
if (!input.planSlug?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "planSlug is required for kind=task",
path: ["planSlug"],
});
}
if (!input.epicSlug?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "epicSlug is required for kind=task",
path: ["epicSlug"],
});
}
}
});
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
function defaultStatusForKind(kind: z.infer<typeof kindSchema>): string {
if (kind === "task") return "ready";
return "draft";
}
function defaultBodyForKind(kind: string, title: string): string {
if (kind === "plan") {
return `# ${title}\n\nShort summary of the initiative and why it exists.\n`;
}
if (kind === "epic") {
return `# ${title}\n\nOne paragraph: what milestone this epic delivers.\n`;
}
return `# ${title}\n\nOne or two sentences.\n`;
}
function repoPathFor(input: {
kind: z.infer<typeof kindSchema>;
slug: string;
planSlug?: string;
epicSlug?: string;
}): string {
if (input.kind === "plan") {
return `plans/Plan-${input.slug}/Plan-${input.slug}.md`;
}
if (input.kind === "epic") {
return `plans/Plan-${input.planSlug}/Epic-${input.slug}/Epic-${input.slug}.md`;
}
return `plans/Plan-${input.planSlug}/Epic-${input.epicSlug}/Task-${input.slug}.md`;
}
function buildFrontmatterYaml(input: {
kind: string;
slug: string;
title: string;
planSlug: string | null;
epicSlug: string | null;
status: string;
priority: string;
agentPrompt: string | null;
}): string {
const doc = parseDocument("");
// Ensure we start from empty map
if (!doc.contents) {
// parseDocument("") yields empty; ensure contents is mapping
}
doc.set("kind", input.kind);
doc.set("slug", input.slug);
doc.set("title", input.title);
if (input.planSlug) doc.set("plan_slug", input.planSlug);
if (input.epicSlug) doc.set("epic_slug", input.epicSlug);
doc.set("status", input.status);
doc.set("priority", input.priority);
doc.set("updated_at", todayIso());
if (input.agentPrompt !== null && input.agentPrompt.trim() !== "") {
const isMultiline = input.agentPrompt.includes("\n");
const node = doc.createNode(input.agentPrompt);
if (node instanceof Scalar) {
node.type = isMultiline ? Scalar.BLOCK_LITERAL : Scalar.PLAIN;
}
doc.set("agent_prompt", node);
}
const yamlText = String(doc).replace(/\n$/, "");
return yamlText;
}
async function fileExists(absPath: string): Promise<boolean> {
try {
await access(absPath);
return true;
} catch {
return false;
}
}
async function atomicWrite(absPath: string, contents: string): Promise<void> {
const dir = dirname(absPath);
await mkdir(dir, { recursive: true });
const tmp = join(dir, `.${randomUUID()}.tmp`);
await writeFile(tmp, contents, "utf8");
await rename(tmp, absPath);
}
export function registerCreateBacklogItemTool(mcp: McpServer): void {
mcp.registerTool(
"create-backlog-item",
{
description:
"Create a new Plan/Epic/Task markdown file under plans/ and upsert markdown_backlog_items. Validates frontmatter per docs/templates/. Duplicate slug under same epic → CONFLICT.",
inputSchema: createBacklogItemInputSchema,
},
async (args) => {
try {
const input = createBacklogItemInputSchema.parse(args);
const ws = await resolveWorkspaceHandle(input.workspace);
const effectivePlanSlug =
input.kind === "plan" ? input.slug : (input.planSlug?.trim() ?? null);
const effectiveEpicSlug =
input.kind === "task"
? (input.epicSlug?.trim() ?? null)
: input.kind === "epic"
? null
: null;
const status = input.status ?? defaultStatusForKind(input.kind);
const priority = input.priority ?? "P2";
const agentPrompt =
input.agent_prompt !== undefined && input.agent_prompt !== null
? input.agent_prompt
: null;
const bodyMarkdown = input.bodyMarkdown ?? defaultBodyForKind(input.kind, input.title);
const repoPath = repoPathFor({
kind: input.kind,
slug: input.slug,
planSlug: effectivePlanSlug ?? undefined,
epicSlug: effectiveEpicSlug ?? undefined,
});
const repoRoot = resolveRepoRoot();
if (!repoRoot) {
return toolErr("MARKDOWN_BACKLOG_REPO_ROOT is disabled; cannot write plans/ file");
}
const absPath = join(repoRoot, ...repoPath.split("/"));
// Duplicate check: file exists
if (await fileExists(absPath)) {
return toolErr(`CONFLICT: backlog item already exists at ${repoPath}`);
}
// Duplicate check: DB row exists for same workspace+repoPath
const [existingRow] = await db
.select({ id: markdownBacklogItems.id })
.from(markdownBacklogItems)
.where(and(eq(markdownBacklogItems.workspaceId, ws.id), eq(markdownBacklogItems.repoPath, repoPath)))
.limit(1);
if (existingRow) {
return toolErr(`CONFLICT: backlog item already exists at ${repoPath}`);
}
// Build frontmatter + raw markdown (exportBacklogItemToMarkdown inverse)
// Plan files per template do not carry plan_slug/epic_slug
const yamlPlanSlug = input.kind === "plan" ? null : effectivePlanSlug;
const yamlEpicSlug = input.kind === "task" ? effectiveEpicSlug : null;
const yamlText = buildFrontmatterYaml({
kind: input.kind,
slug: input.slug,
title: input.title,
planSlug: yamlPlanSlug,
epicSlug: yamlEpicSlug,
status,
priority,
agentPrompt,
});
const body = bodyMarkdown.startsWith("\n") ? bodyMarkdown : `\n${bodyMarkdown}`;
// Ensure body ends with newline
const normalizedBody = body.endsWith("\n") ? body : `${body}\n`;
const raw = `---\n${yamlText}\n---${normalizedBody}`;
const contentHash = hashFileContents(raw);
// Frontmatter stored in DB (mirrors sync's parsed frontmatter)
const frontmatter: Record<string, unknown> = {
kind: input.kind,
slug: input.slug,
title: input.title,
status,
priority,
updated_at: todayIso(),
};
if (yamlPlanSlug) frontmatter.plan_slug = yamlPlanSlug;
if (yamlEpicSlug) frontmatter.epic_slug = yamlEpicSlug;
if (agentPrompt !== null && agentPrompt.trim() !== "") {
frontmatter.agent_prompt = agentPrompt;
}
// Atomic write .tmp → rename
await atomicWrite(absPath, raw);
// Upsert markdown_backlog_items with contentHash
// Compute parentId by looking up parent repo path if any
const parentPath = parentRepoPath(repoPath);
let parentId: string | null = null;
if (parentPath) {
const [parentRow] = await db
.select({ id: markdownBacklogItems.id })
.from(markdownBacklogItems)
.where(and(eq(markdownBacklogItems.workspaceId, ws.id), eq(markdownBacklogItems.repoPath, parentPath)))
.limit(1);
parentId = parentRow?.id ?? null;
}
let insertedId: string | null = null;
try {
const [inserted] = await db
.insert(markdownBacklogItems)
.values({
workspaceId: ws.id,
kind: input.kind,
slug: input.slug,
planSlug: effectivePlanSlug ?? input.slug,
epicSlug: effectiveEpicSlug,
parentId,
repoPath,
title: input.title,
status,
priority,
owner: null,
frontmatter,
bodyMarkdown: normalizedBody.replace(/^\n/, ""),
contentHash,
workflowPrompt: agentPrompt?.trim() ? agentPrompt.trim() : null,
})
.returning({ id: markdownBacklogItems.id });
insertedId = inserted?.id ?? null;
} catch (e) {
// Unique violation → CONFLICT (race with concurrent writer)
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("duplicate") || msg.includes("unique") || msg.includes("CONFLICT")) {
return toolErr(`CONFLICT: backlog item already exists at ${repoPath}`);
}
throw e;
}
// If we had a parent but it was inserted concurrently after our lookup,
// refresh parentId (best-effort)
if (parentPath && !parentId) {
const [parentRow] = await db
.select({ id: markdownBacklogItems.id })
.from(markdownBacklogItems)
.where(and(eq(markdownBacklogItems.workspaceId, ws.id), eq(markdownBacklogItems.repoPath, parentPath)))
.limit(1);
if (parentRow) {
await db
.update(markdownBacklogItems)
.set({ parentId: parentRow.id })
.where(eq(markdownBacklogItems.id, insertedId!));
parentId = parentRow.id;
}
}
return toolOk({
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
repoPath,
absPath,
contentHash,
backlogItem: {
id: insertedId,
kind: input.kind,
slug: input.slug,
planSlug: effectivePlanSlug,
epicSlug: effectiveEpicSlug,
title: input.title,
status,
priority,
parentId,
},
});
} catch (e) {
return toolCatch(e);
}
},
);
}

View file

@ -1,76 +0,0 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { briefObjectContentSchema } from "@tasks/shared/convoy-lifecycle";
import { db } from "../db.js";
import { objects } from "../schema.js";
import { loadConvoyProject } from "../lib/convoy-objects.js";
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
import { toolCatch, toolErr, toolOk } from "./tool-result.js";
const createBriefInputSchema = z.object({
workspace: z.string().min(1),
convoyId: z.string().uuid(),
briefNumber: z.number().int().positive(),
title: z.string().min(1).max(500),
files: z.array(z.string()).default([]),
sliceDependencies: z.array(z.number().int().positive()).default([]),
acceptance: z.string().optional(),
bodyMarkdown: z.string().optional(),
});
export function registerCreateBriefTool(mcp: McpServer): void {
mcp.registerTool(
"create_brief",
{
description:
"Create an implementer brief as a task object child of a convoy project.",
inputSchema: createBriefInputSchema,
},
async (args) => {
try {
const input = createBriefInputSchema.parse(args);
const ws = await resolveWorkspaceHandle(input.workspace);
const convoy = await loadConvoyProject(input.convoyId, ws.id);
if (!convoy) {
return toolErr(`Convoy ${input.convoyId} not found in workspace ${ws.slug}`);
}
const content = briefObjectContentSchema.parse({
briefNumber: input.briefNumber,
files: input.files,
sliceDependencies: input.sliceDependencies,
acceptance: input.acceptance,
});
const [created] = await db
.insert(objects)
.values({
type: "task",
title: input.title,
parentId: convoy.id,
workspaceId: ws.id,
description: input.bodyMarkdown ?? input.acceptance ?? null,
status: "ready",
content,
sortOrder: input.briefNumber,
})
.returning();
if (!created) {
return toolErr("Failed to create brief");
}
return toolOk({
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
convoyId: convoy.id,
brief: created,
});
} catch (e) {
return toolCatch(e);
}
},
);
}

View file

@ -1,85 +0,0 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import {
convoyClassificationSchema,
convoyObjectContentSchema,
} from "@tasks/shared/convoy-lifecycle";
import { db } from "../db.js";
import { objects } from "../schema.js";
import { findConvoyBySlug } from "../lib/convoy-objects.js";
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
import { toolCatch, toolErr, toolOk } from "./tool-result.js";
const createConvoyInputSchema = z.object({
workspace: z.string().min(1),
slug: z
.string()
.min(1)
.max(120)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "slug must be kebab-case"),
classification: convoyClassificationSchema,
skipFlags: z.array(z.string()).default([]),
successMetric: z.string().min(1).max(2_000),
ideaMarkdown: z.string().min(1),
modelPolicy: z
.object({
roles: z.record(z.string()).optional(),
escalate_to: z.string().optional(),
})
.optional(),
});
export function registerCreateConvoyTool(mcp: McpServer): void {
mcp.registerTool(
"create_convoy",
{
description:
"Create a convoy project object (type=project, status=draft) in the workspace convoys-* tenant.",
inputSchema: createConvoyInputSchema,
},
async (args) => {
try {
const input = createConvoyInputSchema.parse(args);
const ws = await resolveWorkspaceHandle(input.workspace);
const existing = await findConvoyBySlug(ws.id, input.slug);
if (existing) {
return toolErr(`Convoy slug '${input.slug}' already exists in workspace ${ws.slug}`);
}
const content = convoyObjectContentSchema.parse({
convoySlug: input.slug,
classification: input.classification,
skipFlags: input.skipFlags,
successMetric: input.successMetric,
modelPolicy: input.modelPolicy,
});
const [created] = await db
.insert(objects)
.values({
type: "project",
title: input.slug,
workspaceId: ws.id,
description: input.ideaMarkdown,
status: "draft",
content,
})
.returning();
if (!created) {
return toolErr("Failed to create convoy");
}
return toolOk({
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
convoy: created,
});
} catch (e) {
return toolCatch(e);
}
},
);
}

View file

@ -1,70 +0,0 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import {
BACKLOG_ITEM_NOT_FOUND_MESSAGE,
backlogItemLookupFieldsSchema,
resolveBacklogItemsByLookup,
} from "../lib/resolve-backlog-item.js";
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
import { toolCatch, toolErr, toolOk } from "./tool-result.js";
const findBacklogItemInputSchema = z
.object({
workspace: z
.string()
.min(1)
.describe("Workspace UUID or slug (e.g. 'convoys-tasks')."),
})
.merge(backlogItemLookupFieldsSchema)
.superRefine((input, ctx) => {
const hasRepoPath = Boolean(input.repoPath?.trim());
const hasSlugKeys = Boolean(input.slug?.trim() && input.planSlug?.trim());
if (!hasRepoPath && !hasSlugKeys) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"Provide repoPath, or slug + planSlug (add epicSlug for tasks/epics).",
});
}
});
export function registerFindBacklogItemTool(mcp: McpServer): void {
mcp.registerTool(
"find_backlog_item",
{
description:
"Resolve a markdown backlog item to its backlogItemId by repo path or slug keys. Optional when claim_task is called with repoPath directly.",
inputSchema: findBacklogItemInputSchema,
},
async (args) => {
try {
const input = findBacklogItemInputSchema.parse(args);
const ws = await resolveWorkspaceHandle(input.workspace);
const rows = await resolveBacklogItemsByLookup(ws.id, input);
if (rows.length === 0) {
return toolErr(BACKLOG_ITEM_NOT_FOUND_MESSAGE);
}
if (rows.length > 1) {
return toolOk({
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
ambiguous: true,
matches: rows,
count: rows.length,
});
}
const item = rows[0]!;
return toolOk({
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
backlogItem: item,
backlogItemId: item.id,
});
} catch (e) {
return toolCatch(e);
}
},
);
}

View file

@ -6,14 +6,6 @@ import { registerSearchObjectsTool } from "./search-objects.js";
import { registerUpdateObjectTool } from "./update-object.js";
import { registerClaimTaskTool } from "./claim-task.js";
import { registerCompleteTaskTool } from "./complete-task.js";
import { registerCreateBacklogItemTool } from "./create-backlog-item.js";
import { registerFindBacklogItemTool } from "./find-backlog-item.js";
import { registerCreateConvoyTool } from "./create-convoy.js";
import { registerCreateBriefTool } from "./create-brief.js";
import { registerTransitionConvoyStatusTool } from "./transition-convoy-status.js";
import { registerLogConvoyEventTool } from "./log-convoy-event.js";
import { registerQueryManifestStatusTool } from "./query-manifest-status.js";
import { registerReconcileFromFilesTool } from "./reconcile-from-files.js";
export function registerTools(mcp: McpServer): void {
registerCreateObjectTool(mcp);
@ -23,14 +15,6 @@ export function registerTools(mcp: McpServer): void {
registerManageObjectTool(mcp);
registerClaimTaskTool(mcp);
registerCompleteTaskTool(mcp);
registerCreateBacklogItemTool(mcp);
registerFindBacklogItemTool(mcp);
registerCreateConvoyTool(mcp);
registerCreateBriefTool(mcp);
registerTransitionConvoyStatusTool(mcp);
registerLogConvoyEventTool(mcp);
registerQueryManifestStatusTool(mcp);
registerReconcileFromFilesTool(mcp);
}
export { registerCreateObjectTool } from "./create-object.js";
@ -40,11 +24,3 @@ export { registerListObjectsTool } from "./list-objects.js";
export { registerManageObjectTool } from "./manage-object.js";
export { registerClaimTaskTool } from "./claim-task.js";
export { registerCompleteTaskTool } from "./complete-task.js";
export { registerCreateBacklogItemTool } from "./create-backlog-item.js";
export { registerFindBacklogItemTool } from "./find-backlog-item.js";
export { registerCreateConvoyTool } from "./create-convoy.js";
export { registerCreateBriefTool } from "./create-brief.js";
export { registerTransitionConvoyStatusTool } from "./transition-convoy-status.js";
export { registerLogConvoyEventTool } from "./log-convoy-event.js";
export { registerQueryManifestStatusTool } from "./query-manifest-status.js";
export { registerReconcileFromFilesTool } from "./reconcile-from-files.js";

View file

@ -1,82 +0,0 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { convoyEventTypeSchema } from "@tasks/shared/convoy-lifecycle";
import {
insertConvoyEvent,
loadConvoyProject,
} from "../lib/convoy-objects.js";
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
import { toolCatch, toolErr, toolOk } from "./tool-result.js";
const logConvoyEventInputSchema = z.object({
workspace: z.string().min(1),
convoyId: z.string().uuid(),
role: z.string().min(1).max(64),
eventType: convoyEventTypeSchema.default("role_complete"),
briefId: z.string().uuid().optional(),
actor: z.string().min(1).max(64).optional(),
classification: z.string().optional(),
skipFlags: z.array(z.string()).optional(),
durationS: z.number().int().nonnegative().optional(),
outcome: z.string().optional(),
repo: z.string().optional(),
multitaskGroup: z.string().optional(),
modelAlias: z.string().optional(),
providerType: z.string().optional(),
modelId: z.string().optional(),
modelTier: z.string().optional(),
estimatedCostUsd: z.number().nonnegative().optional(),
metadata: z.record(z.unknown()).optional(),
});
export function registerLogConvoyEventTool(mcp: McpServer): void {
mcp.registerTool(
"log_convoy_event",
{
description:
"Append a convoy_events row (role metrics, status notes) for analytics and mission control.",
inputSchema: logConvoyEventInputSchema,
},
async (args) => {
try {
const input = logConvoyEventInputSchema.parse(args);
const ws = await resolveWorkspaceHandle(input.workspace);
const convoy = await loadConvoyProject(input.convoyId, ws.id);
if (!convoy) {
return toolErr(`Convoy ${input.convoyId} not found in workspace ${ws.slug}`);
}
const event = await insertConvoyEvent({
workspaceId: ws.id,
convoyId: convoy.id,
briefId: input.briefId ?? null,
role: input.role,
actor: input.actor ?? input.role,
eventType: input.eventType,
classification: input.classification ?? null,
skipFlags: input.skipFlags ?? null,
durationS: input.durationS ?? null,
outcome: input.outcome ?? null,
repo: input.repo ?? null,
multitaskGroup: input.multitaskGroup ?? null,
modelAlias: input.modelAlias ?? null,
providerType: input.providerType ?? null,
modelId: input.modelId ?? null,
modelTier: input.modelTier ?? null,
estimatedCostUsd:
input.estimatedCostUsd !== undefined
? String(input.estimatedCostUsd)
: null,
metadata: input.metadata ?? null,
});
return toolOk({ event });
} catch (e) {
return toolCatch(e);
}
},
);
}

View file

@ -1,74 +0,0 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { and, desc, eq } from "../drizzle.js";
import { db } from "../db.js";
import { convoyEvents, objects } from "../schema.js";
import {
currentConvoyStatus,
loadConvoyProject,
parseConvoyContent,
} from "../lib/convoy-objects.js";
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
import { toolCatch, toolErr, toolOk } from "./tool-result.js";
const queryManifestInputSchema = z.object({
workspace: z.string().min(1),
convoyId: z.string().uuid(),
eventLimit: z.number().int().min(1).max(100).default(20),
});
export function registerQueryManifestStatusTool(mcp: McpServer): void {
mcp.registerTool(
"query_manifest_status",
{
description:
"Return convoy project status, metadata, child brief count, and recent convoy_events.",
inputSchema: queryManifestInputSchema,
},
async (args) => {
try {
const input = queryManifestInputSchema.parse(args);
const ws = await resolveWorkspaceHandle(input.workspace);
const convoy = await loadConvoyProject(input.convoyId, ws.id);
if (!convoy) {
return toolErr(`Convoy ${input.convoyId} not found in workspace ${ws.slug}`);
}
const briefs = await db
.select({ id: objects.id, title: objects.title, status: objects.status })
.from(objects)
.where(
and(eq(objects.parentId, convoy.id), eq(objects.workspaceId, ws.id)),
);
const events = await db
.select()
.from(convoyEvents)
.where(
and(
eq(convoyEvents.convoyId, convoy.id),
eq(convoyEvents.workspaceId, ws.id),
),
)
.orderBy(desc(convoyEvents.occurredAt))
.limit(input.eventLimit);
return toolOk({
convoy: {
id: convoy.id,
title: convoy.title,
status: currentConvoyStatus(convoy),
content: parseConvoyContent(convoy.content),
updatedAt: convoy.updatedAt,
},
briefs,
recentEvents: events,
});
} catch (e) {
return toolCatch(e);
}
},
);
}

View file

@ -1,155 +0,0 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { basename } from "node:path";
import {
convoyClassificationSchema,
convoyObjectContentSchema,
mapFileStatusToServer,
} from "@tasks/shared/convoy-lifecycle";
import { eq } from "../drizzle.js";
import { db } from "../db.js";
import { objects } from "../schema.js";
import {
clearPendingSync,
listConvoyMarkdownFiles,
readPendingSync,
} from "../lib/convoy-files.js";
import {
findConvoyBySlug,
insertConvoyEvent,
} from "../lib/convoy-objects.js";
import { resolveRepoRoot } from "../lib/repo-root.js";
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
import { toolCatch, toolErr, toolOk } from "./tool-result.js";
const reconcileInputSchema = z.object({
workspace: z.string().min(1),
repoPath: z.string().optional(),
});
export function registerReconcileFromFilesTool(mcp: McpServer): void {
mcp.registerTool(
"reconcile_from_files",
{
description:
"Sync `.convoys/*.md` files into Echodo project objects and report pending MCP outbox entries.",
inputSchema: reconcileInputSchema,
},
async (args) => {
try {
const input = reconcileInputSchema.parse(args);
const ws = await resolveWorkspaceHandle(input.workspace);
const repoRoot = input.repoPath?.trim() || resolveRepoRoot();
if (!repoRoot) {
return toolErr(
"Repo root not found. Set MARKDOWN_BACKLOG_REPO_ROOT or pass repoPath.",
);
}
const files = listConvoyMarkdownFiles(repoRoot);
const pending = readPendingSync(repoRoot);
const synced: Array<{ slug: string; convoyId: string; created: boolean }> = [];
const skipped: Array<{ slug: string; reason: string }> = [];
for (const file of files) {
if (!file.classification || !file.successMetric) {
skipped.push({
slug: file.slug,
reason: "missing classification or success_metric in frontmatter",
});
continue;
}
const classification = convoyClassificationSchema.safeParse(file.classification);
if (!classification.success) {
skipped.push({ slug: file.slug, reason: "invalid classification" });
continue;
}
const content = convoyObjectContentSchema.parse({
convoySlug: file.slug,
classification: classification.data,
skipFlags: file.skipFlags,
successMetric: file.successMetric,
modelPolicy: file.modelPolicy ?? undefined,
});
let convoy = await findConvoyBySlug(ws.id, file.slug);
let created = false;
if (!convoy) {
const [inserted] = await db
.insert(objects)
.values({
type: "project",
title: file.slug,
workspaceId: ws.id,
description: file.bodyMarkdown.trim() || null,
status: mapFileStatusToServer(file.fileStatus ?? "open") ?? "draft",
content,
})
.returning();
if (!inserted) {
skipped.push({ slug: file.slug, reason: "insert failed" });
continue;
}
convoy = inserted;
created = true;
} else {
const mappedStatus = file.fileStatus
? mapFileStatusToServer(file.fileStatus)
: null;
const [updated] = await db
.update(objects)
.set({
description: file.bodyMarkdown.trim() || convoy.description,
content,
...(mappedStatus ? { status: mappedStatus } : {}),
updatedAt: new Date(),
})
.where(eq(objects.id, convoy.id))
.returning();
convoy = updated ?? convoy;
}
await insertConvoyEvent({
workspaceId: ws.id,
convoyId: convoy.id,
role: "reconcile_from_files",
actor: "automation",
eventType: "reconcile",
repo: basename(repoRoot),
metadata: {
filePath: file.filePath,
fileStatus: file.fileStatus,
created,
},
});
synced.push({ slug: file.slug, convoyId: convoy.id, created });
}
if (pending.length > 0) {
clearPendingSync(repoRoot);
}
return toolOk({
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
repoRoot,
synced,
skipped,
clearedPendingEntries: pending.length,
pendingReplayNote:
pending.length > 0
? "Pending outbox entries were cleared after reporting; replay tooling is deferred."
: null,
pending,
});
} catch (e) {
return toolCatch(e);
}
},
);
}

View file

@ -1,102 +0,0 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import {
ConvoyTransitionError,
convoyStatusSchema,
validateConvoyTransition,
} from "@tasks/shared/convoy-lifecycle";
import { eq } from "../drizzle.js";
import { db } from "../db.js";
import { objects } from "../schema.js";
import {
currentConvoyStatus,
insertConvoyEvent,
loadConvoyProject,
} from "../lib/convoy-objects.js";
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
import { toolCatch, toolErr, toolOk } from "./tool-result.js";
const transitionInputSchema = z.object({
workspace: z.string().min(1),
convoyId: z.string().uuid(),
toStatus: convoyStatusSchema,
actor: z.string().min(1).max(64),
reason: z.string().min(1).max(2_000).optional(),
});
export function registerTransitionConvoyStatusTool(mcp: McpServer): void {
mcp.registerTool(
"transition_convoy_status",
{
description:
"Transition a convoy project through the server-enforced lifecycle state machine.",
inputSchema: transitionInputSchema,
},
async (args) => {
try {
const input = transitionInputSchema.parse(args);
const ws = await resolveWorkspaceHandle(input.workspace);
const convoy = await loadConvoyProject(input.convoyId, ws.id);
if (!convoy) {
return toolErr(`Convoy ${input.convoyId} not found in workspace ${ws.slug}`);
}
const fromStatus = currentConvoyStatus(convoy);
try {
validateConvoyTransition({
from: fromStatus,
to: input.toStatus,
actor: input.actor,
reason: input.reason,
});
} catch (error) {
if (error instanceof ConvoyTransitionError) {
return toolErr(
JSON.stringify({
code: error.code,
message: error.message,
from: fromStatus,
to: input.toStatus,
actor: input.actor,
}),
);
}
throw error;
}
const [updated] = await db
.update(objects)
.set({
status: input.toStatus,
updatedAt: new Date(),
})
.where(eq(objects.id, convoy.id))
.returning();
await insertConvoyEvent({
workspaceId: ws.id,
convoyId: convoy.id,
role: input.actor,
actor: input.actor,
eventType: "status_change",
metadata: {
from: fromStatus,
to: input.toStatus,
reason: input.reason ?? null,
},
});
return toolOk({
convoy: updated,
transition: { from: fromStatus, to: input.toStatus, actor: input.actor },
});
} catch (e) {
return toolCatch(e);
}
},
);
}

View file

@ -1,8 +1,5 @@
import { defineConfig } from "tsup";
/** `yaml` uses dynamic require internally and breaks when inlined into ESM. */
const bundledDatabaseExternals = ["yaml"] as const;
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
@ -12,7 +9,6 @@ export default defineConfig({
// at import time — Node 20 cannot load .ts. Third-party deps stay external
// and are installed via the regular node_modules layer in the runtime image.
noExternal: [/^@tasks\//],
external: [...bundledDatabaseExternals],
target: "node20",
platform: "node",
clean: true,

View file

@ -1,28 +0,0 @@
{
"name": "@tasks/orchestrator",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "node dist/index.mjs",
"build": "tsup",
"type-check": "tsc --noEmit",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest"
},
"dependencies": {
"@tasks/database": "workspace:*",
"@tasks/shared": "workspace:*",
"drizzle-orm": "^0.38.0",
"ioredis": "^5.4.2",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/node": "^22.10.0",
"tsup": "^8.3.5",
"tsx": "^4.19.2",
"typescript": "^5.7.0",
"vitest": "^2.1.9"
}
}

View file

@ -1,220 +0,0 @@
import { and, desc, eq, inArray, isNull, lt, sql } from "drizzle-orm";
import type { db as dbInstance } from "@tasks/database";
import { agentRuns, convoyEvents, objects, workerCheckpoints } from "@tasks/database/schema";
import {
briefObjectContentSchema,
convoyObjectContentSchema,
validateConvoyTransition,
type ConvoyStatus,
} from "@tasks/shared/convoy-lifecycle";
import type { BriefSummary } from "@tasks/shared/orchestrator";
type Db = typeof dbInstance;
import type { AutomationTier } from "@tasks/shared/convoy-lifecycle";
export type LoadedConvoy = {
id: string;
workspaceId: string;
title: string;
status: ConvoyStatus;
skipFlags: string[];
briefs: BriefSummary[];
completedRoles: Set<string>;
completedBriefIds: Set<string>;
automationTier?: AutomationTier;
};
function parseStatus(raw: string | null): ConvoyStatus {
const status = raw ?? "draft";
return status as ConvoyStatus;
}
export async function listActiveConvoys(db: Db): Promise<LoadedConvoy[]> {
const rows = await db
.select()
.from(objects)
.where(
and(
eq(objects.type, "project"),
sql`${objects.content} ->> 'convoySlug' IS NOT NULL`,
inArray(objects.status, ["draft", "ready", "in_progress"]),
),
);
const loaded: LoadedConvoy[] = [];
for (const row of rows) {
const content = convoyObjectContentSchema.safeParse(row.content);
if (!content.success) continue;
const briefRows = await db
.select()
.from(objects)
.where(and(eq(objects.parentId, row.id), eq(objects.workspaceId, row.workspaceId)));
const events = await db
.select({ role: convoyEvents.role, eventType: convoyEvents.eventType, briefId: convoyEvents.briefId })
.from(convoyEvents)
.where(eq(convoyEvents.convoyId, row.id))
.orderBy(desc(convoyEvents.occurredAt))
.limit(200);
const completedRoles = new Set<string>();
const completedBriefIds = new Set<string>();
for (const event of events) {
if (event.eventType === "role_complete") {
completedRoles.add(event.role);
if (event.briefId) completedBriefIds.add(event.briefId);
}
}
const briefs: BriefSummary[] = briefRows.map((brief) => {
const parsed = briefObjectContentSchema.safeParse(brief.content);
return {
id: brief.id,
briefNumber: parsed.success ? parsed.data.briefNumber : brief.sortOrder,
status: brief.status,
sliceDependencies: parsed.success ? parsed.data.sliceDependencies : [],
};
});
loaded.push({
id: row.id,
workspaceId: row.workspaceId,
title: row.title,
status: parseStatus(row.status),
skipFlags: content.data.skipFlags,
briefs,
completedRoles,
completedBriefIds,
automationTier: content.data.automationTier ?? "assist",
});
}
return loaded;
}
export async function transitionConvoyInDb(
db: Db,
input: {
workspaceId: string;
convoyId: string;
toStatus: ConvoyStatus;
actor: string;
reason?: string;
},
) {
const [convoy] = await db
.select()
.from(objects)
.where(
and(
eq(objects.id, input.convoyId),
eq(objects.workspaceId, input.workspaceId),
eq(objects.type, "project"),
),
)
.limit(1);
if (!convoy) {
throw new Error(`Convoy ${input.convoyId} not found`);
}
const fromStatus = parseStatus(convoy.status);
validateConvoyTransition({
from: fromStatus,
to: input.toStatus,
actor: input.actor,
reason: input.reason,
});
await db
.update(objects)
.set({ status: input.toStatus, updatedAt: new Date() })
.where(eq(objects.id, convoy.id));
await db.insert(convoyEvents).values({
workspaceId: input.workspaceId,
convoyId: convoy.id,
role: input.actor,
actor: input.actor,
eventType: "status_change",
metadata: {
from: fromStatus,
to: input.toStatus,
reason: input.reason ?? null,
source: "orchestrator",
},
});
}
export type StalledRunDispatch = {
workspaceId: string;
agentRunId: string;
backlogItemId: string;
convoyId: string;
roleId: string;
briefId?: string;
};
/**
* Open agent runs past the stall threshold that have a workspace-scoped
* checkpoint (enough context to re-dispatch with resume_from_checkpoint).
*/
export async function detectStalledRunsForResume(
db: Db,
stallMinutes: number,
): Promise<StalledRunDispatch[]> {
const cutoff = new Date(Date.now() - stallMinutes * 60_000);
const openRuns = await db
.select({
id: agentRuns.id,
workspaceId: agentRuns.workspaceId,
backlogItemId: agentRuns.backlogItemId,
startedAt: agentRuns.startedAt,
})
.from(agentRuns)
.where(and(isNull(agentRuns.finishedAt), lt(agentRuns.startedAt, cutoff)))
.limit(50);
if (openRuns.length === 0) return [];
const dispatches: StalledRunDispatch[] = [];
for (const run of openRuns) {
const [checkpoint] = await db
.select({
convoyId: workerCheckpoints.convoyId,
briefId: workerCheckpoints.briefId,
roleId: workerCheckpoints.roleId,
})
.from(workerCheckpoints)
.where(
and(
eq(workerCheckpoints.workspaceId, run.workspaceId),
eq(workerCheckpoints.agentRunId, run.id),
),
)
.orderBy(desc(workerCheckpoints.checkpointSeq))
.limit(1);
if (!checkpoint) continue;
dispatches.push({
workspaceId: run.workspaceId,
agentRunId: run.id,
backlogItemId: run.backlogItemId,
convoyId: checkpoint.convoyId,
roleId: checkpoint.roleId,
briefId: checkpoint.briefId ?? undefined,
});
}
return dispatches;
}
/** @deprecated Prefer detectStalledRunsForResume — kept for log-only callers. */
export async function detectStalledConvoys(db: Db, stallMinutes: number): Promise<string[]> {
const stalled = await detectStalledRunsForResume(db, stallMinutes);
return stalled.map((run) => run.backlogItemId);
}

View file

@ -1,42 +0,0 @@
import { z } from "zod";
const envSchema = z.object({
DATABASE_URL: z.string().min(1),
REDIS_URL: z.string().min(1).optional(),
ORCHESTRATOR_DRY_RUN: z
.enum(["0", "1", "true", "false", ""])
.optional()
.transform((value) => value === "1" || value === "true"),
ORCHESTRATOR_POLL_MS: z
.string()
.optional()
.transform((value) => {
const parsed = Number(value ?? "5000");
return Number.isFinite(parsed) && parsed > 0 ? parsed : 5_000;
}),
ORCHESTRATOR_STALL_MINUTES: z
.string()
.optional()
.transform((value) => {
const parsed = Number(value ?? "120");
return Number.isFinite(parsed) && parsed > 0 ? parsed : 120;
}),
ORCHESTRATOR_MAX_IMPLEMENTERS: z
.string()
.optional()
.transform((value) => {
const parsed = Number(value ?? "2");
return Number.isFinite(parsed) && parsed > 0 ? parsed : 2;
}),
});
export type OrchestratorEnv = z.infer<typeof envSchema>;
export function loadEnv(): OrchestratorEnv {
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error("[orchestrator] Invalid environment:", parsed.error.flatten().fieldErrors);
process.exit(1);
}
return parsed.data;
}

View file

@ -1,71 +0,0 @@
import { db } from "@tasks/database";
import IoRedis from "ioredis";
import { detectStalledRunsForResume } from "./db/convoy.js";
import { loadEnv } from "./env.js";
import { enqueueJob } from "./queue/redis-queue.js";
import { runOrchestratorTick } from "./tick.js";
async function main(): Promise<void> {
const env = loadEnv();
const redis = env.REDIS_URL ? new IoRedis(env.REDIS_URL) : null;
if (env.ORCHESTRATOR_DRY_RUN) {
console.info("[orchestrator] DRY-RUN mode — jobs will be logged, not enqueued");
} else if (!redis) {
console.warn("[orchestrator] REDIS_URL not set — running in DRY-RUN mode");
}
console.info(`[orchestrator] polling every ${env.ORCHESTRATOR_POLL_MS}ms`);
const loop = async () => {
try {
const results = await runOrchestratorTick(db, redis, {
...env,
ORCHESTRATOR_DRY_RUN: env.ORCHESTRATOR_DRY_RUN || !redis,
});
const stalled = await detectStalledRunsForResume(db, env.ORCHESTRATOR_STALL_MINUTES);
if (stalled.length > 0) {
console.warn(
`[orchestrator] stall detector: ${stalled.length} open agent_runs older than ${env.ORCHESTRATOR_STALL_MINUTES}m`,
);
for (const run of stalled) {
if (env.ORCHESTRATOR_DRY_RUN || !redis) {
console.info(
`[orchestrator] DRY-RUN resume_from_checkpoint convoy=${run.convoyId} role=${run.roleId} run=${run.agentRunId}`,
);
continue;
}
const job = await enqueueJob(redis, {
type: "role_dispatch",
workspaceId: run.workspaceId,
convoyId: run.convoyId,
roleId: run.roleId,
briefId: run.briefId,
resumeFromCheckpoint: true,
});
console.info(
`[orchestrator] re-dispatched job=${job.id} resume_from_checkpoint run=${run.agentRunId}`,
);
}
}
if (results.length === 0) {
console.info("[orchestrator] no active convoys");
}
} catch (error) {
console.error("[orchestrator] tick failed:", error);
}
};
await loop();
setInterval(loop, env.ORCHESTRATOR_POLL_MS);
}
main().catch((error) => {
console.error("[orchestrator] fatal:", error);
process.exit(1);
});

View file

@ -1,31 +0,0 @@
import { randomUUID } from "node:crypto";
import type IoRedis from "ioredis";
import {
ORCHESTRATOR_QUEUE_KEY,
orchestratorJobSchema,
type OrchestratorJob,
} from "@tasks/shared/orchestrator";
export async function enqueueJob(redis: IoRedis, job: Omit<OrchestratorJob, "id" | "enqueuedAt">) {
const payload = orchestratorJobSchema.parse({
...job,
id: randomUUID(),
enqueuedAt: new Date().toISOString(),
});
await redis.rpush(ORCHESTRATOR_QUEUE_KEY, JSON.stringify(payload));
return payload;
}
export async function dequeueJob(redis: IoRedis, timeoutSeconds = 5): Promise<OrchestratorJob | null> {
const result = await redis.blpop(ORCHESTRATOR_QUEUE_KEY, timeoutSeconds);
if (!result) return null;
const [, raw] = result;
try {
return orchestratorJobSchema.parse(JSON.parse(raw));
} catch {
console.error("[orchestrator] Dropping invalid job payload:", raw);
return null;
}
}

View file

@ -1,124 +0,0 @@
import type IoRedis from "ioredis";
import type { db as dbInstance } from "@tasks/database";
import {
planOrchestratorAction,
type OrchestratorAction,
type OrchestratorJob,
} from "@tasks/shared/orchestrator";
import type { AutomationTier } from "@tasks/shared/convoy-lifecycle";
import { listActiveConvoys, transitionConvoyInDb, type LoadedConvoy } from "./db/convoy.js";
import { enqueueJob } from "./queue/redis-queue.js";
import type { OrchestratorEnv } from "./env.js";
type Db = typeof dbInstance;
export type TickResult = {
convoyId: string;
action: OrchestratorAction;
job?: OrchestratorJob;
};
async function applyAction(
db: Db,
redis: IoRedis | null,
env: OrchestratorEnv,
convoy: LoadedConvoy,
action: OrchestratorAction,
): Promise<TickResult> {
const base = { convoyId: convoy.id, action };
if (action.type === "noop" || action.type === "pause") {
console.info(
`[orchestrator] convoy=${convoy.title} status=${convoy.status} → ${action.type}: ${
action.type === "pause" ? action.reason : action.reason
}`,
);
return base;
}
if (action.type === "transition") {
if (env.ORCHESTRATOR_DRY_RUN) {
console.info(
`[orchestrator] DRY-RUN transition convoy=${convoy.title} ${convoy.status} → ${action.toStatus} (actor=${action.actor})`,
);
return base;
}
await transitionConvoyInDb(db, {
workspaceId: convoy.workspaceId,
convoyId: convoy.id,
toStatus: action.toStatus,
actor: action.actor,
});
console.info(
`[orchestrator] transition convoy=${convoy.title} → ${action.toStatus} (actor=${action.actor})`,
);
return base;
}
if (action.type === "dispatch") {
if (
action.roleId === "role-implementer" &&
convoy.status === "ready" &&
!env.ORCHESTRATOR_DRY_RUN
) {
await transitionConvoyInDb(db, {
workspaceId: convoy.workspaceId,
convoyId: convoy.id,
toStatus: "in_progress",
actor: "role-implementer",
});
convoy.status = "in_progress";
}
if (env.ORCHESTRATOR_DRY_RUN || !redis) {
console.info(
`[orchestrator] DRY-RUN dispatch convoy=${convoy.title} role=${action.roleId}${
action.briefId ? ` brief=${action.briefId}` : ""
}`,
);
return base;
}
const job = await enqueueJob(redis, {
type: "role_dispatch",
workspaceId: convoy.workspaceId,
convoyId: convoy.id,
roleId: action.roleId,
briefId: action.briefId,
});
console.info(
`[orchestrator] enqueued job=${job.id} convoy=${convoy.title} role=${action.roleId}`,
);
return { ...base, job };
}
return base;
}
export async function runOrchestratorTick(
db: Db,
redis: IoRedis | null,
env: OrchestratorEnv,
): Promise<TickResult[]> {
const convoys = await listActiveConvoys(db);
const results: TickResult[] = [];
for (const convoy of convoys) {
const action = planOrchestratorAction({
convoyId: convoy.id,
status: convoy.status,
skipFlags: convoy.skipFlags,
briefs: convoy.briefs,
completedRoles: convoy.completedRoles,
completedBriefIds: convoy.completedBriefIds,
automationTier: convoy.automationTier,
});
results.push(await applyAction(db, redis, env, convoy, action));
}
return results;
}

View file

@ -1,10 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"noEmit": true,
"incremental": false
},
"include": ["src/**/*"]
}

View file

@ -1,14 +0,0 @@
import { defineConfig } from "tsup";
const bundledDatabaseExternals = ["yaml"] as const;
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
dts: true,
noExternal: [/^@tasks\//],
external: [...bundledDatabaseExternals],
target: "node20",
platform: "node",
clean: true,
});

View file

@ -1,7 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
},
});

View file

@ -1,23 +1,212 @@
import { Suspense } from "react";
"use client";
import { AIWorkspace } from "@/components/ai/ai-workspace";
import { useEffect, useRef } from "react";
import { useParams } from "next/navigation";
import { useChat } from "@ai-sdk/react";
import { AlertTriangle, Bot, Loader2, Send, Sparkles, User } from "lucide-react";
export default async function AIPage({
params,
}: {
params: Promise<{ workspaceSlug: string }>;
}) {
const { workspaceSlug } = await params;
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export default function AIPage() {
const params = useParams();
const workspaceSlug =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : "";
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const {
messages,
input,
handleInputChange,
handleSubmit,
status,
error,
setInput,
} = useChat({
api: "/api/chat",
body: { workspace: workspaceSlug },
});
const isBusy = status === "submitted" || status === "streaming";
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages, status]);
return (
<Suspense
fallback={
<p role="status" className="p-6">
Loading AI workspace…
</p>
}
>
<AIWorkspace key={workspaceSlug} workspaceSlug={workspaceSlug} />
</Suspense>
<div className="flex h-full min-h-0 flex-col">
<div className="flex shrink-0 items-center gap-3 border-b px-6 py-4">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
<Sparkles className="size-5 text-primary" />
</div>
<div>
<h1 className="text-lg font-semibold">AI Assistant</h1>
<p className="text-xs text-muted-foreground">
Ask me anything about your workspace
</p>
</div>
</div>
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
{messages.length === 0 ? (
<div className="flex h-full min-h-[12rem] flex-col items-center justify-center gap-4 text-muted-foreground">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10">
<Bot className="size-8 text-primary" />
</div>
<div className="text-center">
<p className="text-base font-medium">How can I help you today?</p>
<p className="mt-1 text-sm">
Ask me to break down a task, draft an outline, or summarize what
you have open.
</p>
</div>
<div className="mt-2 flex flex-wrap justify-center gap-2">
{[
"Break this project into subtasks",
"Summarize my open tasks",
"Draft a sprint plan for this week",
].map((suggestion) => (
<button
key={suggestion}
type="button"
onClick={() => setInput(suggestion)}
className="rounded-full border px-3 py-1.5 text-xs transition-colors hover:bg-muted"
>
{suggestion}
</button>
))}
</div>
</div>
) : (
<div className="mx-auto max-w-3xl space-y-4 px-4 py-6">
{messages.map((msg) => (
<div
key={msg.id}
className={cn(
"flex gap-3",
msg.role === "user" && "flex-row-reverse",
)}
>
<div
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
msg.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted",
)}
>
{msg.role === "user" ? (
<User className="size-4" />
) : (
<Bot className="size-4" />
)}
</div>
<div
className={cn(
"max-w-[80%] rounded-xl px-4 py-2.5 text-sm whitespace-pre-wrap",
msg.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted",
)}
>
{msg.content}
</div>
</div>
))}
{status === "submitted" && (
<div className="flex gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted">
<Bot className="size-4" />
</div>
<div className="rounded-xl bg-muted px-4 py-2.5">
<Loader2 className="size-4 animate-spin" />
</div>
</div>
)}
</div>
)}
{error ? <ChatErrorBanner error={error} /> : null}
</div>
<div className="shrink-0 border-t bg-background p-4">
<form
onSubmit={handleSubmit}
className="mx-auto flex max-w-3xl gap-2"
>
<textarea
ref={inputRef}
value={input}
onChange={handleInputChange}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (input.trim() && !isBusy) {
// Cast: handleSubmit accepts FormEvent | KeyboardEvent; the
// useChat helper unwraps either.
handleSubmit(
e as unknown as React.FormEvent<HTMLFormElement>,
);
}
}
}}
placeholder="Ask anything..."
className="flex-1 resize-none rounded-lg border bg-background px-4 py-2.5 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
rows={1}
/>
<Button
type="submit"
size="icon"
disabled={!input.trim() || isBusy || !workspaceSlug}
>
<Send className="size-4" />
</Button>
</form>
</div>
</div>
);
}
function ChatErrorBanner({ error }: { error: Error }) {
const message = parseErrorMessage(error);
const isUnavailable = /AI is unavailable/i.test(message);
return (
<div
className={cn(
"mx-auto mb-4 mt-2 flex max-w-3xl items-start gap-3 rounded-lg border px-4 py-3 text-sm",
isUnavailable
? "border-amber-500/40 bg-amber-500/10 text-amber-900 dark:text-amber-200"
: "border-destructive/40 bg-destructive/10 text-destructive",
)}
role="alert"
>
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<div className="min-w-0">
<p className="font-medium">
{isUnavailable ? "AI is unavailable" : "Something went wrong"}
</p>
<p className="mt-1 break-words text-xs opacity-90">{message}</p>
</div>
</div>
);
}
/**
* `useChat` wraps fetch errors in a generic Error; the route handler returns
* a JSON body with `{ error: string }`. Try to surface that to the user; fall
* back to whatever message the SDK gave us.
*/
function parseErrorMessage(error: Error): string {
const raw = error.message || "";
try {
const parsed = JSON.parse(raw) as { error?: string };
if (parsed?.error) return parsed.error;
} catch {
// not JSON; fall through
}
return raw || "Unknown error";
}

View file

@ -1,204 +0,0 @@
"use client";
import * as React from "react";
import { useParams } from "next/navigation";
import { ExternalLink, ImageOff, Loader2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { api } from "@/lib/trpc";
export default function ArtifactsPage() {
const params = useParams();
const workspaceSlug =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined;
const [selectedId, setSelectedId] = React.useState<string | null>(null);
const artifactsQuery = api.convoys.listArtifacts.useQuery(
{ workspace: workspaceSlug! },
{ enabled: Boolean(workspaceSlug) },
);
const artifacts = artifactsQuery.data ?? [];
const selected = artifacts.find((a) => a.id === selectedId) ?? null;
if (!workspaceSlug) return null;
return (
<div className="flex h-full min-h-0 flex-col">
<header className="shrink-0 border-b border-border px-6 py-4">
<h1 className="text-lg font-semibold">Completion artifacts</h1>
<p className="text-xs text-muted-foreground">
Gallery of released convoys — summary + PR link + timeline
</p>
</header>
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[1fr_380px]">
<section className="min-h-0 overflow-y-auto p-6">
{artifactsQuery.isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading artifacts…
</div>
) : artifacts.length === 0 ? (
<div className="rounded-md border border-dashed border-border p-8 text-center">
<p className="text-sm font-medium">No completion artifacts yet</p>
<p className="mt-1 text-xs text-muted-foreground">
Complete a convoy from merged or published via human-gate-3 to generate one.
</p>
<div className="mt-4 inline-flex items-center gap-2 rounded-md border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
<ImageOff className="size-4" />
Screenshot upload placeholder — shows when no image is attached
</div>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{artifacts.map((artifact) => {
const isSelected = artifact.id === selectedId;
return (
<button
key={artifact.id}
type="button"
onClick={() => setSelectedId(artifact.id)}
className={`text-left ${isSelected ? "ring-2 ring-primary" : ""} rounded-lg`}
>
<Card className={`h-full ${isSelected ? "border-primary" : ""}`}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between gap-2">
<CardTitle className="truncate text-sm">{artifact.convoySlug}</CardTitle>
<Badge variant="secondary" className="shrink-0 text-[10px]">
{artifact.convoyStatus}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{new Date(artifact.createdAt).toLocaleString()}
</p>
</CardHeader>
<CardContent className="space-y-3">
{artifact.screenshotUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={artifact.screenshotUrl}
alt="screenshot"
className="h-32 w-full rounded-md object-cover"
/>
) : (
<div className="flex h-32 w-full items-center justify-center rounded-md border border-dashed border-border bg-muted/20 p-3 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<ImageOff className="size-4" />
Screenshot upload placeholder
</span>
</div>
)}
<pre className="line-clamp-3 whitespace-pre-wrap break-words rounded bg-muted/30 p-2 text-xs">
{artifact.summaryMd.slice(0, 180)}
{artifact.summaryMd.length > 180 ? "…" : ""}
</pre>
{artifact.pr.prUrl && (
<a
href={artifact.pr.prUrl}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 text-xs text-primary underline"
>
PR <ExternalLink className="size-3" />
</a>
)}
</CardContent>
</Card>
</button>
);
})}
</div>
)}
</section>
<aside className="min-h-0 overflow-y-auto border-l border-border bg-background p-4">
{!selected ? (
<p className="py-8 text-center text-sm text-muted-foreground">
Select an artifact to see detail summary + PR link + convoy timeline.
</p>
) : (
<div className="space-y-4">
<div>
<h2 className="text-sm font-semibold">{selected.convoySlug}</h2>
<p className="text-xs text-muted-foreground">{selected.convoyTitle}</p>
<p className="text-xs text-muted-foreground">{selected.convoyStatus}</p>
</div>
<div className="rounded-md border border-border bg-muted/30 p-3">
<p className="mb-1 text-xs font-medium uppercase text-muted-foreground">Summary</p>
<pre className="whitespace-pre-wrap break-words text-sm">
{selected.summaryMd}
</pre>
</div>
<div className="rounded-md border border-border p-3">
<p className="mb-1 text-xs font-medium uppercase text-muted-foreground">PR link</p>
{selected.pr.prUrl ? (
<a
href={selected.pr.prUrl}
target="_blank"
rel="noreferrer"
className="break-all text-sm text-primary underline"
>
{selected.pr.prUrl}
{selected.pr.prNumber ? ` (#${selected.pr.prNumber})` : ""}
</a>
) : (
<p className="text-xs text-muted-foreground">No PR linked</p>
)}
</div>
{selected.screenshotUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={selected.screenshotUrl}
alt="screenshot"
className="w-full rounded-md border border-border object-cover"
/>
) : (
<div className="flex h-32 w-full items-center justify-center rounded-md border border-dashed border-border bg-muted/20 p-3 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<ImageOff className="size-4" />
Screenshot upload placeholder — no screenshot yet
</span>
</div>
)}
<div>
<p className="mb-2 text-xs font-medium uppercase text-muted-foreground">
Convoy timeline ({selected.timeline.length})
</p>
{selected.timeline.length === 0 ? (
<p className="text-xs text-muted-foreground">No events</p>
) : (
<ul className="space-y-1">
{selected.timeline.map((ev) => (
<li
key={ev.id}
className="rounded border border-border bg-muted/10 px-2 py-1.5 text-xs"
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{ev.eventType}</span>
<span className="text-muted-foreground">
{new Date(ev.occurredAt).toLocaleString()}
</span>
</div>
<p className="text-muted-foreground">
{ev.role}
{ev.actor ? ` · ${ev.actor}` : ""}
</p>
</li>
))}
</ul>
)}
</div>
</div>
)}
</aside>
</div>
</div>
);
}

View file

@ -1,10 +0,0 @@
import { ChatWorkspace } from "@/components/chat/chat-workspace";
export default async function ChannelPage({
params,
}: {
params: Promise<{ workspaceSlug: string; channelId: string }>;
}) {
const { workspaceSlug, channelId } = await params;
return <ChatWorkspace workspace={workspaceSlug} channelId={channelId} />;
}

View file

@ -1,10 +0,0 @@
import { ChatWorkspace } from "@/components/chat/chat-workspace";
export default async function ChatPage({
params,
}: {
params: Promise<{ workspaceSlug: string }>;
}) {
const { workspaceSlug } = await params;
return <ChatWorkspace workspace={workspaceSlug} />;
}

View file

@ -1,5 +0,0 @@
import { redirect } from "next/navigation";
export default function WorkspaceFleetRedirect() {
redirect("/fleet");
}

View file

@ -1,72 +0,0 @@
"use client";
import * as React from "react";
import { useParams } from "next/navigation";
import { Plus, Radar } from "lucide-react";
import { ConvoyDetailPanel } from "@/components/mission-control/convoy-detail-panel";
import { ConvoyKanban, GateInbox } from "@/components/mission-control/convoy-kanban";
import { CreateConvoyDialog } from "@/components/mission-control/create-convoy-dialog";
import { Button } from "@/components/ui/button";
export default function MissionControlPage() {
const params = useParams();
const workspaceSlug = params?.workspaceSlug as string | undefined;
const [selectedId, setSelectedId] = React.useState<string | null>(null);
const [createOpen, setCreateOpen] = React.useState(false);
if (!workspaceSlug) {
return null;
}
return (
<div className="flex h-full min-h-0 flex-col">
<header className="flex shrink-0 items-center justify-between border-b border-border px-6 py-4">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
<Radar className="size-5 text-primary" />
</div>
<div>
<h1 className="text-lg font-semibold">Mission control</h1>
<p className="text-xs text-muted-foreground">
Convoy kanban, human gates, and gate co-pilot
</p>
</div>
</div>
<Button type="button" onClick={() => setCreateOpen(true)}>
<Plus className="mr-2 size-4" />
New convoy
</Button>
</header>
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[220px_1fr_320px]">
<aside className="min-h-0 overflow-y-auto border-r border-border bg-muted/10">
<GateInbox
workspaceSlug={workspaceSlug}
selectedId={selectedId}
onSelect={setSelectedId}
/>
</aside>
<main className="flex min-h-0 flex-col overflow-hidden">
<ConvoyKanban
workspaceSlug={workspaceSlug}
selectedId={selectedId}
onSelect={setSelectedId}
/>
</main>
<aside className="min-h-0 border-l border-border bg-background">
<ConvoyDetailPanel workspaceSlug={workspaceSlug} convoyId={selectedId} />
</aside>
</div>
<CreateConvoyDialog
workspaceSlug={workspaceSlug}
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={setSelectedId}
/>
</div>
);
}

View file

@ -0,0 +1,243 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { ChevronRight, FileText } from "lucide-react";
import type { inferRouterOutputs } from "@trpc/server";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { WorkflowPromptSection } from "@/components/backlog/workflow-prompt-section";
import { api } from "@/lib/trpc";
import { cn } from "@/lib/utils";
import type { AppRouter } from "@/server/root";
/**
* Task detail page rendered from URL slugs that mirror the `plans/`
* directory layout. There's no plans-tree browser yet, so this is the
* smallest surface that lets an operator land directly on a single task
* (e.g. from a `/settings/runs` row, or by typing the URL) and edit its
* workflow prompt.
*
* Path: /<workspaceSlug>/plans/<planSlug>/<epicSlug>/<taskSlug>
*
* 404 here means "no such task in this workspace at that path." We surface
* the error inline rather than calling `notFound()` so the path components
* stay visible in the breadcrumb — easier to copy-edit a typo.
*/
const STATUS_BADGE: Record<string, { label: string; className: string }> = {
draft: {
label: "Draft",
className:
"border-transparent bg-muted text-muted-foreground hover:bg-muted/80",
},
ready: {
label: "Ready",
className:
"border-transparent bg-sky-500/15 text-sky-700 dark:text-sky-300",
},
in_progress: {
label: "In progress",
className:
"border-transparent bg-blue-500/15 text-blue-700 dark:text-blue-300",
},
blocked: {
label: "Blocked",
className:
"border-transparent bg-amber-500/15 text-amber-700 dark:text-amber-300",
},
done: {
label: "Done",
className:
"border-transparent bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
},
cancelled: {
label: "Cancelled",
className:
"border-transparent bg-red-500/15 text-red-700 dark:text-red-300",
},
};
function StatusBadge({ status }: { status: string | null | undefined }) {
const entry = status && STATUS_BADGE[status];
if (!entry) {
return (
<Badge variant="outline" className="font-normal">
{status ?? "unknown"}
</Badge>
);
}
return (
<Badge className={cn(entry.className, "font-medium")}>{entry.label}</Badge>
);
}
export default function TaskDetailPage() {
const params = useParams();
const workspaceSlug = params?.workspaceSlug as string | undefined;
const planSlug = params?.planSlug as string | undefined;
const epicSlug = params?.epicSlug as string | undefined;
const taskSlug = params?.taskSlug as string | undefined;
const ready = Boolean(workspaceSlug && planSlug && epicSlug && taskSlug);
const detailQuery = api.backlog.getTaskByPath.useQuery(
{
workspace: workspaceSlug ?? "",
planSlug: planSlug ?? "",
epicSlug: epicSlug ?? "",
taskSlug: taskSlug ?? "",
},
{ enabled: ready },
);
if (!ready) {
return (
<div className="mx-auto max-w-3xl px-8 py-10 text-sm text-muted-foreground">
Missing path segments.
</div>
);
}
return (
<div className="mx-auto max-w-3xl px-8 py-10">
<Breadcrumb
workspaceSlug={workspaceSlug!}
planSlug={planSlug!}
epicSlug={epicSlug!}
taskSlug={taskSlug!}
planTitle={detailQuery.data?.plan?.title ?? null}
epicTitle={detailQuery.data?.epic?.title ?? null}
taskTitle={detailQuery.data?.task.title ?? null}
/>
{detailQuery.isLoading ? (
<div className="mt-6 space-y-4">
<Skeleton className="h-8 w-2/3" />
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-40 w-full" />
</div>
) : detailQuery.error ? (
<div className="mt-6 rounded-lg border border-destructive/40 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{detailQuery.error.message}
</div>
) : detailQuery.data ? (
<TaskBody workspaceSlug={workspaceSlug!} data={detailQuery.data} />
) : null}
</div>
);
}
function Breadcrumb({
workspaceSlug,
planSlug,
epicSlug,
taskSlug,
planTitle,
epicTitle,
taskTitle,
}: {
workspaceSlug: string;
planSlug: string;
epicSlug: string;
taskSlug: string;
planTitle: string | null;
epicTitle: string | null;
taskTitle: string | null;
}) {
// There isn't a plans browser route yet, so the only meaningful link
// is back up to the workspace root. Plan/epic crumbs render as text
// until those landing pages exist.
return (
<nav
aria-label="Breadcrumb"
className="flex flex-wrap items-center gap-1 text-xs text-muted-foreground"
>
<Link
href={`/${workspaceSlug}`}
className="hover:text-foreground hover:underline"
>
{workspaceSlug}
</Link>
<ChevronRight className="size-3" />
<span className="text-muted-foreground/80">plans</span>
<ChevronRight className="size-3" />
<span title={planSlug}>{planTitle ?? planSlug}</span>
<ChevronRight className="size-3" />
<span title={epicSlug}>{epicTitle ?? epicSlug}</span>
<ChevronRight className="size-3" />
<span className="font-medium text-foreground" title={taskSlug}>
{taskTitle ?? taskSlug}
</span>
</nav>
);
}
type DetailData = inferRouterOutputs<AppRouter>["backlog"]["getTaskByPath"];
function TaskBody({
workspaceSlug,
data,
}: {
workspaceSlug: string;
data: DetailData;
}) {
const canEdit = data.callerRole === "owner" || data.callerRole === "admin";
return (
<>
<header className="mt-6 flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<FileText className="size-5 text-primary" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-xl font-semibold leading-tight">
{data.task.title || "Untitled task"}
</h1>
<StatusBadge status={data.task.status} />
{data.task.priority ? (
<Badge variant="outline" className="font-normal">
{data.task.priority}
</Badge>
) : null}
</div>
<p className="mt-1 text-xs text-muted-foreground">
<span className="font-mono">{data.task.repoPath}</span>
</p>
</div>
</header>
<div className="mt-6 space-y-6">
<WorkflowPromptSection
workspace={workspaceSlug}
backlogItemId={data.task.id}
canEdit={canEdit}
sourceContext={{
taskTitle: data.task.title,
epicTitle: data.epic?.title ?? null,
planTitle: data.plan?.title ?? null,
}}
/>
<section className="rounded-lg border bg-card p-4">
<h2 className="text-sm font-semibold">Body</h2>
<p className="mb-3 text-xs text-muted-foreground">
Markdown imported from <span className="font-mono">{data.task.repoPath}</span>. Edit the file in your repo and re-run the importer to update.
</p>
{data.task.bodyMarkdown.trim() ? (
<pre className="whitespace-pre-wrap rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs leading-relaxed">
{data.task.bodyMarkdown}
</pre>
) : (
<div className="rounded-md border border-dashed bg-muted/20 px-3 py-6 text-center text-xs text-muted-foreground">
No body content.
</div>
)}
</section>
</div>
</>
);
}

View file

@ -1,655 +0,0 @@
"use client";
import * as React from "react";
import { useParams, useRouter } from "next/navigation";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
import { ChevronDown, ChevronRight, FileText, Folder, FolderKanban, Loader2, Plus, Search, Rocket, X } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet";
import { api } from "@/lib/trpc";
import { cn } from "@/lib/utils";
const STATUS_VARIANT: Record<string, string> = {
draft: "bg-muted text-muted-foreground",
ready: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
in_progress: "bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/20",
blocked: "bg-destructive/10 text-destructive border-destructive/20",
done: "bg-sky-500/15 text-sky-600 dark:text-sky-400 border-sky-500/20",
cancelled: "bg-muted text-muted-foreground",
};
function StatusChip({ status }: { status: string | null }) {
const s = status ?? "unknown";
const cls = STATUS_VARIANT[s] ?? STATUS_VARIANT.draft;
return (
<span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium", cls)}>{s}</span>
);
}
function slugify(value: string): string {
return value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.replace(/--+/g, "-");
}
type DialogState =
| { kind: "plan" }
| { kind: "epic"; planSlug: string }
| { kind: "task"; planSlug: string; epicSlug: string }
| null;
export default function PlansPage() {
const params = useParams();
const router = useRouter();
const workspaceSlug = params?.workspaceSlug as string | undefined;
const [search, setSearch] = React.useState("");
const [expandedPlans, setExpandedPlans] = React.useState<Set<string>>(new Set());
const [expandedEpics, setExpandedEpics] = React.useState<Set<string>>(new Set());
const [selectedId, setSelectedId] = React.useState<string | null>(null);
const [dialog, setDialog] = React.useState<DialogState>(null);
const [formSlug, setFormSlug] = React.useState("");
const [formTitle, setFormTitle] = React.useState("");
const [formBody, setFormBody] = React.useState("");
const [formAgentPrompt, setFormAgentPrompt] = React.useState("");
const [formError, setFormError] = React.useState<string | null>(null);
const treeQuery = api.backlog.listTree.useQuery({ workspace: workspaceSlug! }, { enabled: Boolean(workspaceSlug) });
const detailQuery = api.backlog.getBacklogItem.useQuery(
{ workspace: workspaceSlug!, backlogItemId: selectedId! },
{ enabled: Boolean(workspaceSlug && selectedId) },
);
const promptQuery = api.backlog.getWorkflowPrompt.useQuery(
{ workspace: workspaceSlug!, backlogItemId: selectedId! },
{ enabled: Boolean(workspaceSlug && selectedId) },
);
const utils = api.useUtils();
const createPlan = api.backlog.createPlan.useMutation({
onSuccess: () => {
void utils.backlog.listTree.invalidate();
closeDialog();
},
onError: (e) => setFormError(e.message),
});
const createEpic = api.backlog.createEpic.useMutation({
onSuccess: () => {
void utils.backlog.listTree.invalidate();
closeDialog();
},
onError: (e) => setFormError(e.message),
});
const createTask = api.backlog.createTask.useMutation({
onSuccess: () => {
void utils.backlog.listTree.invalidate();
closeDialog();
},
onError: (e) => setFormError(e.message),
});
const startConvoy = api.backlog.startConvoy.useMutation({
onSuccess: () => {
void utils.convoys.kanban.invalidate();
if (workspaceSlug) router.push(`/${workspaceSlug}/mission-control`);
},
onError: (e) => setFormError(e.message),
});
// Direct convoy.create path required by brief 4 — Start → Convoy via convoys.create with planSlug/epicSlug
const createConvoyDirect = api.convoys.create.useMutation({
onSuccess: () => {
void utils.convoys.kanban.invalidate();
if (workspaceSlug) router.push(`/${workspaceSlug}/mission-control`);
},
onError: (e) => setFormError(e.message),
});
function closeDialog() {
setDialog(null);
setFormSlug("");
setFormTitle("");
setFormBody("");
setFormAgentPrompt("");
setFormError(null);
}
function openPlanDialog() {
setFormError(null);
setFormSlug("");
setFormTitle("");
setFormBody("");
setFormAgentPrompt("");
setDialog({ kind: "plan" });
}
function openEpicDialog(planSlug: string) {
setFormError(null);
setFormSlug("");
setFormTitle("");
setFormBody("");
setFormAgentPrompt("");
setDialog({ kind: "epic", planSlug });
}
function openTaskDialog(planSlug: string, epicSlug: string) {
setFormError(null);
setFormSlug("");
setFormTitle("");
setFormBody("");
setFormAgentPrompt("");
setDialog({ kind: "task", planSlug, epicSlug });
}
const tree = React.useMemo(() => treeQuery.data?.plans ?? [], [treeQuery.data?.plans]);
const filtered = React.useMemo(() => {
if (!search.trim()) return tree;
const q = search.trim().toLowerCase();
return tree
.map((plan) => {
const planMatches = plan.title.toLowerCase().includes(q) || plan.slug.toLowerCase().includes(q);
const epics = plan.epics
.map((epic) => {
const epicMatches = epic.title.toLowerCase().includes(q) || epic.slug.toLowerCase().includes(q);
const tasks = epic.tasks.filter(
(t) =>
t.title.toLowerCase().includes(q) ||
t.slug.toLowerCase().includes(q) ||
(t.status ?? "").toLowerCase().includes(q),
);
if (epicMatches || tasks.length > 0 || planMatches) {
return { ...epic, tasks: epicMatches && tasks.length === 0 ? epic.tasks : tasks };
}
return null;
})
.filter(Boolean) as typeof plan.epics;
if (planMatches) return { ...plan, epics: epics.length ? epics : plan.epics };
if (epics.length) return { ...plan, epics };
return null;
})
.filter(Boolean) as typeof tree;
}, [tree, search]);
React.useEffect(() => {
if (tree.length && expandedPlans.size === 0) {
setExpandedPlans(new Set(tree.slice(0, 2).map((p) => p.slug)));
}
}, [tree, expandedPlans.size]);
if (!workspaceSlug) return null;
const isMutating = createPlan.isPending || createEpic.isPending || createTask.isPending;
return (
<div className="flex h-full min-h-0 flex-col">
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-border px-6 py-4">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
<FolderKanban className="size-5 text-primary" />
</div>
<div>
<h1 className="text-lg font-semibold">Plans</h1>
<p className="text-xs text-muted-foreground">Plan → Epic → Task tree from plans/ markdown</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input placeholder="Search plans, epics, tasks…" value={search} onChange={(e) => setSearch(e.target.value)} className="w-64 pl-8" />
</div>
<Button type="button" size="sm" onClick={openPlanDialog}>
<Plus className="size-4" />
New Plan
</Button>
</div>
</header>
<div className="flex min-h-0 flex-1">
<div className="flex min-h-0 flex-1 flex-col border-r border-border">
{treeQuery.isLoading ? (
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading plans…
</div>
) : filtered.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center">
<FolderKanban className="size-8 text-muted-foreground" />
<p className="text-sm font-medium">No plans yet</p>
<p className="max-w-sm text-xs text-muted-foreground">
{search.trim()
? "No results for your search."
: "Create your first plan or run pnpm --filter @tasks/database watch:markdown-backlog to import plans/ after seeding."}
</p>
{!search.trim() && (
<Button size="sm" onClick={openPlanDialog}>
<Plus className="mr-1 size-4" />
New Plan
</Button>
)}
</div>
) : (
<ScrollArea className="h-full">
<div className="space-y-1 p-3">
{filtered.map((plan) => {
const planOpen = expandedPlans.has(plan.slug);
return (
<CollapsiblePrimitive.Root
key={plan.id}
open={planOpen}
onOpenChange={(open) =>
setExpandedPlans((prev) => {
const next = new Set(prev);
if (open) next.add(plan.slug);
else next.delete(plan.slug);
return next;
})
}
>
<div className="group flex items-center gap-1 rounded-md px-1 py-1 hover:bg-muted/50">
<CollapsiblePrimitive.Trigger asChild>
<button type="button" className="flex items-center gap-1 rounded p-1 hover:bg-muted" aria-label={planOpen ? "Collapse" : "Expand"}>
{planOpen ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
</button>
</CollapsiblePrimitive.Trigger>
<Folder className="size-4 shrink-0 text-primary" />
<button
type="button"
onClick={() => setSelectedId(plan.id.startsWith("placeholder-") ? null : plan.id)}
className={cn(
"min-w-0 flex-1 truncate text-left text-sm font-medium hover:underline",
selectedId === plan.id && "text-primary",
)}
>
{plan.title || plan.slug}
</button>
<StatusChip status={plan.status} />
<Badge variant="outline" className="text-[10px]">{plan.slug}</Badge>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => openEpicDialog(plan.slug)}>
<Plus className="size-3" />
Epic
</Button>
</div>
<CollapsiblePrimitive.Content className="ml-5 border-l border-border pl-3">
<div className="space-y-1 py-1">
{plan.epics.length === 0 ? (
<p className="px-2 py-1 text-xs text-muted-foreground">No epics — add one.</p>
) : (
plan.epics.map((epic) => {
const epicKey = `${plan.slug}/${epic.slug}`;
const epicOpen = expandedEpics.has(epicKey);
return (
<CollapsiblePrimitive.Root
key={epic.id}
open={epicOpen}
onOpenChange={(open) =>
setExpandedEpics((prev) => {
const next = new Set(prev);
if (open) next.add(epicKey);
else next.delete(epicKey);
return next;
})
}
>
<div className="flex items-center gap-1 rounded-md px-1 py-1 hover:bg-muted/50">
<CollapsiblePrimitive.Trigger asChild>
<button type="button" className="flex items-center rounded p-1 hover:bg-muted">
{epicOpen ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
</button>
</CollapsiblePrimitive.Trigger>
<FolderKanban className="size-3.5 shrink-0 text-muted-foreground" />
<button
type="button"
onClick={() => setSelectedId(epic.id.startsWith("placeholder-") ? null : epic.id)}
className={cn(
"min-w-0 flex-1 truncate text-left text-sm hover:underline",
selectedId === epic.id && "text-primary",
)}
>
{epic.title || epic.slug}
</button>
<StatusChip status={epic.status} />
<Badge variant="outline" className="hidden text-[10px] sm:inline-flex">{epic.slug}</Badge>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => openTaskDialog(plan.slug, epic.slug)}>
<Plus className="size-3" />
Task
</Button>
</div>
<CollapsiblePrimitive.Content className="ml-5 border-l border-border pl-3">
<ul className="space-y-0.5 py-1">
{epic.tasks.length === 0 ? (
<li className="px-2 py-1 text-xs text-muted-foreground">No tasks.</li>
) : (
epic.tasks.map((task) => (
<li
key={task.id}
className={cn(
"flex items-center gap-1.5 rounded-md px-2 py-1.5 hover:bg-muted",
selectedId === task.id && "bg-muted ring-1 ring-primary/20",
)}
>
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
<button
type="button"
onClick={() => setSelectedId(task.id)}
className="min-w-0 flex-1 truncate text-left text-sm"
>
<span className="font-medium">{task.title || task.slug}</span>
<span className="ml-1 text-xs text-muted-foreground">{task.slug}</span>
</button>
<StatusChip status={task.status} />
{task.priority && (
<Badge variant="outline" className="hidden text-[10px] sm:inline-flex">
{task.priority}
</Badge>
)}
<span className="hidden max-w-[140px] truncate text-[10px] text-muted-foreground sm:inline">{task.repoPath}</span>
<Button
type="button"
size="sm"
variant={task.status === "ready" ? "default" : "outline"}
className="h-7 shrink-0 px-2 text-xs"
disabled={startConvoy.isPending || createConvoyDirect.isPending}
onClick={() => {
setSelectedId(task.id);
// Primary: backlog.startConvoy (idempotent, creates briefFiles: [repoPath]); fallback/additional: convoys.create with planSlug/epicSlug per brief
const convoySlug = `${task.planSlug}-${task.slug}`.toLowerCase();
if (task.status === "ready") {
startConvoy.mutate({ workspace: workspaceSlug, backlogItemId: task.id });
} else {
createConvoyDirect.mutate({
workspace: workspaceSlug,
slug: convoySlug,
classification: "feature",
successMetric: task.title || task.slug,
ideaMarkdown: task.bodyMarkdown || `# ${task.title}`,
planSlug: task.planSlug,
epicSlug: task.epicSlug ?? undefined,
backlogItemId: task.id,
repoPath: task.repoPath,
briefFiles: [task.repoPath],
});
}
}}
>
<Rocket className="mr-1 size-3" />
Start
</Button>
</li>
))
)}
</ul>
</CollapsiblePrimitive.Content>
</CollapsiblePrimitive.Root>
);
})
)}
</div>
</CollapsiblePrimitive.Content>
</CollapsiblePrimitive.Root>
);
})}
</div>
</ScrollArea>
)}
</div>
<Sheet open={Boolean(selectedId)} onOpenChange={(open) => !open && setSelectedId(null)}>
<SheetContent className="flex w-[480px] max-w-[90vw] flex-col sm:max-w-[480px]">
<SheetHeader>
<SheetTitle className="pr-6">{detailQuery.data?.title ?? "Details"}</SheetTitle>
<SheetDescription className="break-all text-xs">
{detailQuery.data?.repoPath ?? ""}
{detailQuery.data ? ` · ${detailQuery.data.kind}` : ""}
</SheetDescription>
</SheetHeader>
<ScrollArea className="flex-1">
<div className="space-y-4 p-1 pr-4">
{detailQuery.isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading…
</div>
) : detailQuery.data ? (
<>
<div className="flex flex-wrap gap-2">
<StatusChip status={detailQuery.data.status} />
{detailQuery.data.priority && <Badge variant="outline">{detailQuery.data.priority}</Badge>}
<Badge variant="secondary">{detailQuery.data.kind}</Badge>
</div>
<div className="rounded-md border border-border bg-muted/20 p-3">
<p className="text-xs font-medium text-muted-foreground">Repo path</p>
<p className="mt-1 break-all font-mono text-xs">{detailQuery.data.repoPath}</p>
<p className="mt-2 text-xs font-medium text-muted-foreground">Slugs</p>
<p className="mt-1 text-xs">
Plan: <code className="rounded bg-muted px-1 py-0.5">{detailQuery.data.planSlug}</code>
{detailQuery.data.epicSlug ? (
<>
{" "}
Epic: <code className="rounded bg-muted px-1 py-0.5">{detailQuery.data.epicSlug}</code>
</>
) : null}{" "}
Slug: <code className="rounded bg-muted px-1 py-0.5">{detailQuery.data.slug}</code>
</p>
</div>
<div>
<h3 className="text-sm font-semibold">Body</h3>
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-md border border-border bg-muted/20 p-3 text-xs leading-relaxed">
{detailQuery.data.bodyMarkdown || "(empty)"}
</pre>
</div>
<div>
<h3 className="text-sm font-semibold">Effective prompt</h3>
<p className="mt-1 text-xs text-muted-foreground">Source: {promptQuery.data?.source ?? "—"}</p>
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap break-words rounded-md border border-border bg-muted/20 p-3 text-xs leading-relaxed">
{promptQuery.data?.effectivePrompt ?? promptQuery.data?.ownOverride ?? "(no prompt)"}
</pre>
{promptQuery.data?.ownOverride && <p className="mt-1 text-[10px] text-muted-foreground">Own override present</p>}
</div>
{detailQuery.data.kind === "task" && (
<div className="flex gap-2">
<Button
type="button"
size="sm"
disabled={startConvoy.isPending || createConvoyDirect.isPending}
onClick={() => {
const d = detailQuery.data!;
const convoySlug = `${d.planSlug}-${d.slug}`.toLowerCase();
// Use convoys.create with planSlug/epicSlug as required by brief, via createConvoyDirect
createConvoyDirect.mutate({
workspace: workspaceSlug,
slug: convoySlug,
classification: "feature",
successMetric: d.title || d.slug,
ideaMarkdown: d.bodyMarkdown || `# ${d.title}`,
planSlug: d.planSlug,
epicSlug: d.epicSlug ?? undefined,
backlogItemId: d.id,
repoPath: d.repoPath,
briefFiles: [d.repoPath],
});
}}
>
{(startConvoy.isPending || createConvoyDirect.isPending) ? <Loader2 className="size-4 animate-spin" /> : <Rocket className="size-4" />}
Start → Convoy
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => setSelectedId(null)}>
Close
</Button>
</div>
)}
{(startConvoy.error || createConvoyDirect.error) && (
<p className="text-xs text-destructive">{(startConvoy.error ?? createConvoyDirect.error)?.message}</p>
)}
{(startConvoy.isSuccess || createConvoyDirect.isSuccess) && (
<p className="text-xs text-emerald-600">
Convoy {(startConvoy.data?.convoySlug ?? createConvoyDirect.data?.convoyId) ?? ""} created — check Mission Control.
</p>
)}
</>
) : (
<p className="text-sm text-muted-foreground">Select an item to see details.</p>
)}
</div>
</ScrollArea>
</SheetContent>
</Sheet>
</div>
<DialogPrimitive.Root open={Boolean(dialog)} onOpenChange={(open) => !open && closeDialog()}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/50" />
<DialogPrimitive.Content className="fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-background p-6 shadow-lg">
<div className="mb-4 flex items-center justify-between">
<DialogPrimitive.Title className="text-lg font-semibold">
{dialog?.kind === "plan" ? "New Plan" : dialog?.kind === "epic" ? "New Epic" : dialog?.kind === "task" ? "New Task" : "New"}
</DialogPrimitive.Title>
<DialogPrimitive.Close asChild>
<button type="button" className="rounded p-1 hover:bg-muted" aria-label="Close">
<X className="size-4" />
</button>
</DialogPrimitive.Close>
</div>
<div className="space-y-3">
{dialog?.kind === "epic" && (
<div>
<label className="mb-1 block text-sm font-medium">Plan</label>
<div className="rounded-md border border-input bg-muted px-3 py-2 text-sm">{dialog.planSlug}</div>
</div>
)}
{dialog?.kind === "task" && (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-sm font-medium">Plan</label>
<div className="rounded-md border border-input bg-muted px-3 py-2 text-sm">{dialog.planSlug}</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium">Epic</label>
<div className="rounded-md border border-input bg-muted px-3 py-2 text-sm">{dialog.epicSlug}</div>
</div>
</div>
)}
<div>
<label className="mb-1 block text-sm font-medium" htmlFor="plans-slug">
Slug <span className="font-normal text-muted-foreground">(a-z 0-9 -)</span>
</label>
<Input
id="plans-slug"
placeholder="my-slug"
value={formSlug}
onChange={(e) => {
const v = e.target.value.toLowerCase();
setFormSlug(v);
if (!formTitle.trim() || slugify(formTitle) === formSlug) {
// keep title in sync helper? no auto
}
}}
/>
<p className="mt-1 text-[11px] text-muted-foreground">Must match ^[a-z0-9-]+$</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium" htmlFor="plans-title">
Title
</label>
<Input
id="plans-title"
placeholder="Human readable title"
value={formTitle}
onChange={(e) => setFormTitle(e.target.value)}
onBlur={() => {
if (!formSlug.trim() && formTitle.trim()) setFormSlug(slugify(formTitle));
}}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium" htmlFor="plans-body">
Body markdown (optional)
</label>
<textarea
id="plans-body"
className="min-h-[90px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder="# Summary…"
value={formBody}
onChange={(e) => setFormBody(e.target.value)}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium" htmlFor="plans-prompt">
Agent prompt (optional)
</label>
<textarea
id="plans-prompt"
className="min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder="Custom prompt for agents…"
value={formAgentPrompt}
onChange={(e) => setFormAgentPrompt(e.target.value)}
/>
</div>
{formError && <p className="text-xs text-destructive">{formError}</p>}
<div className="flex justify-end gap-2 pt-2">
<DialogPrimitive.Close asChild>
<Button type="button" variant="outline" onClick={closeDialog}>
Cancel
</Button>
</DialogPrimitive.Close>
<Button
type="button"
disabled={isMutating || !formSlug.trim() || !formTitle.trim()}
onClick={() => {
setFormError(null);
const slug = formSlug.trim().toLowerCase();
if (!SLUG_RE.test(slug)) {
setFormError("slug must match ^[a-z0-9-]+$");
return;
}
if (dialog?.kind === "plan") {
createPlan.mutate({
workspace: workspaceSlug,
slug,
title: formTitle.trim(),
bodyMarkdown: formBody.trim() || undefined,
agentPrompt: formAgentPrompt.trim() || null,
});
} else if (dialog?.kind === "epic") {
createEpic.mutate({
workspace: workspaceSlug,
planSlug: dialog.planSlug,
slug,
title: formTitle.trim(),
bodyMarkdown: formBody.trim() || undefined,
agentPrompt: formAgentPrompt.trim() || null,
});
} else if (dialog?.kind === "task") {
createTask.mutate({
workspace: workspaceSlug,
planSlug: dialog.planSlug,
epicSlug: dialog.epicSlug,
slug,
title: formTitle.trim(),
bodyMarkdown: formBody.trim() || undefined,
agentPrompt: formAgentPrompt.trim() || null,
});
}
}}
>
{isMutating ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
Create
</Button>
</div>
</div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
</div>
);
}
const SLUG_RE = /^[a-z0-9-]+$/;

View file

@ -1,419 +0,0 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { Bot, Loader2, Plus, Sparkles, X } from "lucide-react";
import { useParams } from "next/navigation";
import { SettingsAiNav } from "@/components/settings/settings-ai-nav";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { api } from "@/lib/trpc";
import type { ProviderType } from "@tasks/shared/model-routing";
const PROVIDER_TYPES: ProviderType[] = [
"anthropic",
"openai",
"google",
"openai_compatible",
"azure_openai",
];
type ProviderForm = {
alias: string;
displayName: string;
providerType: ProviderType;
modelId: string;
baseUrl: string;
apiKeyEnvVar: string;
enabled: boolean;
};
const EMPTY_FORM: ProviderForm = {
alias: "",
displayName: "",
providerType: "openai_compatible",
modelId: "",
baseUrl: "",
apiKeyEnvVar: "",
enabled: true,
};
const PRESETS: Array<{ label: string; form: Partial<ProviderForm> }> = [
{
label: "Ollama local",
form: {
alias: "qwen-coder",
displayName: "Qwen Coder (Ollama)",
providerType: "openai_compatible",
modelId: "qwen2.5-coder:32b",
baseUrl: "http://127.0.0.1:11434/v1",
apiKeyEnvVar: "OLLAMA_API_KEY",
},
},
{
label: "MLX local",
form: {
alias: "qwen-coder-mlx",
displayName: "Qwen Coder (MLX)",
providerType: "openai_compatible",
modelId: "mlx-community/Qwen2.5-Coder-7B-Instruct-4bit",
baseUrl: "http://127.0.0.1:8000/v1",
apiKeyEnvVar: "MLX_API_KEY",
},
},
{
label: "Anthropic",
form: {
alias: "claude-planning",
displayName: "Claude Planning",
providerType: "anthropic",
modelId: "claude-sonnet-4-20250514",
apiKeyEnvVar: "ANTHROPIC_API_KEY",
},
},
{
label: "Gemini",
form: {
alias: "gemini-flash",
displayName: "Gemini Flash",
providerType: "google",
modelId: "gemini-2.0-flash",
apiKeyEnvVar: "GOOGLE_GENERATIVE_AI_API_KEY",
},
},
{
label: "NVIDIA NIM",
form: {
alias: "nim-llama-70b",
displayName: "NIM Llama 70B",
providerType: "openai_compatible",
modelId: "meta/llama-3.1-70b-instruct",
baseUrl: "https://integrate.api.nvidia.com/v1",
apiKeyEnvVar: "NVIDIA_API_KEY",
},
},
];
export default function AiProvidersSettingsPage() {
const params = useParams();
const workspaceSlug = params?.workspaceSlug as string | undefined;
const utils = api.useUtils();
const listQuery = api.ai.models.listProviders.useQuery(
{ workspace: workspaceSlug ?? "" },
{ enabled: Boolean(workspaceSlug) },
);
const [dialogOpen, setDialogOpen] = React.useState(false);
const [editingId, setEditingId] = React.useState<string | null>(null);
const [form, setForm] = React.useState<ProviderForm>(EMPTY_FORM);
const [testResult, setTestResult] = React.useState<Record<string, string>>({});
const createMut = api.ai.models.createProvider.useMutation({
onSuccess: () => {
void utils.ai.models.listProviders.invalidate();
setDialogOpen(false);
},
});
const updateMut = api.ai.models.updateProvider.useMutation({
onSuccess: () => {
void utils.ai.models.listProviders.invalidate();
setDialogOpen(false);
},
});
const deleteMut = api.ai.models.deleteProvider.useMutation({
onSuccess: () => void utils.ai.models.listProviders.invalidate(),
});
const testMut = api.ai.models.testProvider.useMutation({
onSuccess: (result, vars) => {
setTestResult((prev) => ({
...prev,
[vars.providerId]: result.ok
? `OK in ${result.latencyMs}ms`
: `Failed: ${result.error ?? "unknown"}`,
}));
},
});
const canManage = listQuery.data?.canManage ?? false;
const providers = listQuery.data?.providers ?? [];
const openCreate = () => {
setEditingId(null);
setForm(EMPTY_FORM);
setDialogOpen(true);
};
const openEdit = (p: (typeof providers)[number]) => {
setEditingId(p.id);
setForm({
alias: p.alias,
displayName: p.displayName,
providerType: p.providerType,
modelId: p.modelId,
baseUrl: p.baseUrl ?? "",
apiKeyEnvVar: p.apiKeyEnvVar ?? "",
enabled: p.enabled,
});
setDialogOpen(true);
};
const submit = () => {
if (!workspaceSlug) return;
const payload = {
workspace: workspaceSlug,
alias: form.alias,
displayName: form.displayName,
providerType: form.providerType,
modelId: form.modelId,
baseUrl: form.baseUrl.trim() || null,
apiKeyEnvVar: form.apiKeyEnvVar.trim() || null,
enabled: form.enabled,
};
if (editingId) {
updateMut.mutate({ ...payload, id: editingId });
} else {
createMut.mutate(payload);
}
};
if (!workspaceSlug) {
return <p className="p-8 text-sm text-muted-foreground">No workspace selected.</p>;
}
return (
<div className="mx-auto max-w-5xl px-8 py-10">
<div className="mb-4 flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Sparkles className="size-5 text-primary" />
</div>
<div>
<h1 className="text-xl font-semibold">AI model providers</h1>
<p className="text-xs text-muted-foreground">
Registry aliases for orchestrator and pipeline roles. API keys live in server env
vars only.
</p>
</div>
</div>
{canManage ? (
<Button size="sm" className="gap-1.5" onClick={openCreate}>
<Plus className="size-4" />
Add provider
</Button>
) : null}
</div>
<SettingsAiNav workspaceSlug={workspaceSlug} />
{listQuery.isLoading ? (
<div className="flex justify-center py-16">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : listQuery.error ? (
<p className="text-sm text-destructive">{listQuery.error.message}</p>
) : providers.length === 0 ? (
<div className="rounded-lg border border-dashed px-6 py-12 text-center text-sm text-muted-foreground">
<Bot className="mx-auto mb-3 size-8 opacity-40" />
No providers yet.{" "}
{canManage ? "Add Ollama, MLX, or cloud aliases to get started." : null}
</div>
) : (
<div className="overflow-hidden rounded-lg border">
<table className="w-full text-sm">
<thead className="bg-muted/40 text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-4 py-2 text-left font-medium">Alias</th>
<th className="px-4 py-2 text-left font-medium">Type</th>
<th className="px-4 py-2 text-left font-medium">Model</th>
<th className="px-4 py-2 text-left font-medium">Base URL</th>
<th className="px-4 py-2 text-left font-medium">On</th>
{canManage ? (
<th className="px-4 py-2 text-right font-medium">Actions</th>
) : null}
</tr>
</thead>
<tbody className="divide-y">
{providers.map((p) => (
<tr key={p.id} className="hover:bg-muted/20">
<td className="px-4 py-2">
<div className="font-medium">{p.alias}</div>
<div className="text-xs text-muted-foreground">{p.displayName}</div>
</td>
<td className="px-4 py-2 font-mono text-xs">{p.providerType}</td>
<td className="max-w-[20ch] truncate px-4 py-2 font-mono text-xs">
{p.modelId}
</td>
<td className="max-w-[24ch] truncate px-4 py-2 font-mono text-xs text-muted-foreground">
{p.baseUrl ?? "—"}
</td>
<td className="px-4 py-2 text-xs">{p.enabled ? "yes" : "no"}</td>
{canManage ? (
<td className="space-x-2 px-4 py-2 text-right">
<Button
variant="outline"
size="sm"
disabled={testMut.isPending}
onClick={() =>
testMut.mutate({ workspace: workspaceSlug, providerId: p.id })
}
>
Test
</Button>
<Button variant="ghost" size="sm" onClick={() => openEdit(p)}>
Edit
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive"
onClick={() => {
if (window.confirm(`Delete provider "${p.alias}"?`)) {
deleteMut.mutate({ workspace: workspaceSlug, id: p.id });
}
}}
>
Delete
</Button>
{testResult[p.id] ? (
<div className="mt-1 text-[10px] text-muted-foreground">
{testResult[p.id]}
</div>
) : null}
</td>
) : null}
</tr>
))}
</tbody>
</table>
</div>
)}
<DialogPrimitive.Root open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/50" />
<DialogPrimitive.Content className="fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-6 shadow-lg">
<div className="mb-4 flex items-center justify-between">
<DialogPrimitive.Title className="text-lg font-semibold">
{editingId ? "Edit provider" : "Add provider"}
</DialogPrimitive.Title>
<DialogPrimitive.Close asChild>
<button type="button" className="rounded-md p-1 hover:bg-muted">
<X className="size-4" />
</button>
</DialogPrimitive.Close>
</div>
<div className="mb-4 flex flex-wrap gap-1.5">
{PRESETS.map((preset) => (
<Button
key={preset.label}
type="button"
variant="outline"
size="sm"
className="text-xs"
onClick={() => setForm({ ...EMPTY_FORM, ...preset.form })}
>
{preset.label}
</Button>
))}
</div>
<div className="space-y-3">
<Field label="Alias">
<Input
value={form.alias}
onChange={(e) => setForm((f) => ({ ...f, alias: e.target.value }))}
/>
</Field>
<Field label="Display name">
<Input
value={form.displayName}
onChange={(e) => setForm((f) => ({ ...f, displayName: e.target.value }))}
/>
</Field>
<Field label="Provider type">
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
value={form.providerType}
onChange={(e) =>
setForm((f) => ({
...f,
providerType: e.target.value as ProviderType,
}))
}
>
{PROVIDER_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</Field>
<Field label="Model ID">
<Input
value={form.modelId}
onChange={(e) => setForm((f) => ({ ...f, modelId: e.target.value }))}
/>
</Field>
{form.providerType === "openai_compatible" ||
form.providerType === "azure_openai" ? (
<Field label="Base URL">
<Input
value={form.baseUrl}
onChange={(e) => setForm((f) => ({ ...f, baseUrl: e.target.value }))}
/>
</Field>
) : null}
<Field label="API key env var">
<Input
value={form.apiKeyEnvVar}
placeholder="ANTHROPIC_API_KEY"
onChange={(e) => setForm((f) => ({ ...f, apiKeyEnvVar: e.target.value }))}
/>
<p className="mt-1 text-[10px] text-muted-foreground">
Secret value is read from the server environment at inference time — never stored
in the database.
</p>
</Field>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.enabled}
onChange={(e) => setForm((f) => ({ ...f, enabled: e.target.checked }))}
/>
Enabled
</label>
</div>
<div className="mt-6 flex justify-end gap-2">
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={submit}
disabled={createMut.isPending || updateMut.isPending}
>
{createMut.isPending || updateMut.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Save"
)}
</Button>
</div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<label className="mb-1 block text-xs font-medium text-muted-foreground">{label}</label>
{children}
</div>
);
}

View file

@ -1,260 +0,0 @@
"use client";
import * as React from "react";
import { GitBranch, Loader2, Plus, Route } from "lucide-react";
import { useParams } from "next/navigation";
import { SettingsAiNav } from "@/components/settings/settings-ai-nav";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { api } from "@/lib/trpc";
const ROLE_LABELS: Record<string, string> = {
"role-conductor": "Conductor",
"role-architect": "Architect",
"role-ia-architect": "IA architect",
"role-ux-reviewer": "UX reviewer",
"role-implementer": "Implementer",
"role-reviewer": "Reviewer",
"role-design-system-auditor": "Design system auditor",
"role-a11y-auditor": "A11y auditor",
"role-doc-writer": "Doc writer",
"gate-copilot": "Gate co-pilot",
"chat-default": "Chat default",
};
function roleLabel(roleId: string): string {
return ROLE_LABELS[roleId] ?? roleId;
}
function readTier(metadata: unknown): string | null {
if (!metadata || typeof metadata !== "object") return null;
const tier = (metadata as { tier?: unknown }).tier;
return typeof tier === "string" ? tier : null;
}
export default function AiRoleRoutingSettingsPage() {
const params = useParams();
const workspaceSlug = params?.workspaceSlug as string | undefined;
const utils = api.useUtils();
const routesQuery = api.ai.models.listRoleRoutes.useQuery(
{ workspace: workspaceSlug ?? "" },
{ enabled: Boolean(workspaceSlug) },
);
const providersQuery = api.ai.models.listProviders.useQuery(
{ workspace: workspaceSlug ?? "" },
{ enabled: Boolean(workspaceSlug) },
);
const [newRoleId, setNewRoleId] = React.useState("");
const [newProviderId, setNewProviderId] = React.useState("");
const upsertMut = api.ai.models.upsertRoleRoute.useMutation({
onSuccess: () => {
void utils.ai.models.listRoleRoutes.invalidate();
setNewRoleId("");
setNewProviderId("");
},
});
const deleteMut = api.ai.models.deleteRoleRoute.useMutation({
onSuccess: () => void utils.ai.models.listRoleRoutes.invalidate(),
});
const resetMut = api.ai.models.resetPipelineDefaults.useMutation({
onSuccess: () => void utils.ai.models.listRoleRoutes.invalidate(),
});
const canManage = routesQuery.data?.canManage ?? false;
const routes = routesQuery.data?.routes ?? [];
const enabledProviders =
providersQuery.data?.providers.filter((p) => p.enabled) ?? [];
if (!workspaceSlug) {
return <p className="p-8 text-sm text-muted-foreground">No workspace selected.</p>;
}
return (
<div className="mx-auto max-w-5xl px-8 py-10">
<div className="mb-4 flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Route className="size-5 text-primary" />
</div>
<div>
<h1 className="text-xl font-semibold">Role model routing</h1>
<p className="text-xs text-muted-foreground">
Map pipeline <code className="text-[10px]">role_id</code> strings to provider aliases.
Convoy <code className="text-[10px]">model_policy</code> overrides these at dispatch
time — see docs/adr-model-routing.md.
</p>
</div>
</div>
{canManage ? (
<Button
variant="outline"
size="sm"
disabled={resetMut.isPending}
onClick={() => {
if (
window.confirm(
"Insert pipeline default routes for roles whose aliases already exist?",
)
) {
resetMut.mutate({ workspace: workspaceSlug });
}
}}
>
Reset pipeline defaults
</Button>
) : null}
</div>
<SettingsAiNav workspaceSlug={workspaceSlug} />
{routesQuery.isLoading ? (
<div className="flex justify-center py-16">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="overflow-hidden rounded-lg border">
<table className="w-full text-sm">
<thead className="bg-muted/40 text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-4 py-2 text-left font-medium">Role</th>
<th className="px-4 py-2 text-left font-medium">Provider alias</th>
<th className="px-4 py-2 text-left font-medium">Tier</th>
<th className="px-4 py-2 text-left font-medium">On</th>
{canManage ? (
<th className="px-4 py-2 text-right font-medium">Actions</th>
) : null}
</tr>
</thead>
<tbody className="divide-y">
{routes.length === 0 ? (
<tr>
<td
colSpan={canManage ? 5 : 4}
className="px-4 py-8 text-center text-muted-foreground"
>
No routes configured. Add providers first, then map roles.
</td>
</tr>
) : (
routes.map((route) => {
const tier = readTier(route.providerMetadata);
return (
<tr key={route.id} className="hover:bg-muted/20">
<td className="px-4 py-2">
<div className="font-medium">{roleLabel(route.roleId)}</div>
<div className="font-mono text-[10px] text-muted-foreground">
{route.roleId}
</div>
</td>
<td className="px-4 py-2 font-mono text-xs">{route.providerAlias}</td>
<td className="px-4 py-2">
{tier ? (
<Badge variant="secondary" className="text-[10px]">
{tier}
</Badge>
) : (
"—"
)}
</td>
<td className="px-4 py-2 text-xs">
{route.enabled && route.providerEnabled ? "yes" : "no"}
</td>
{canManage ? (
<td className="px-4 py-2 text-right">
<select
className="mr-2 rounded border border-input bg-background px-2 py-1 text-xs"
value={route.providerAliasId}
onChange={(e) =>
upsertMut.mutate({
workspace: workspaceSlug,
roleId: route.roleId,
providerAliasId: e.target.value,
})
}
>
{enabledProviders.map((p) => (
<option key={p.id} value={p.id}>
{p.alias}
</option>
))}
</select>
<Button
variant="ghost"
size="sm"
className="text-destructive"
onClick={() => {
if (window.confirm(`Remove route for ${route.roleId}?`)) {
deleteMut.mutate({ workspace: workspaceSlug, id: route.id });
}
}}
>
Remove
</Button>
</td>
) : null}
</tr>
);
})
)}
</tbody>
</table>
</div>
)}
{canManage ? (
<div className="mt-8 rounded-lg border bg-muted/20 p-4">
<div className="mb-3 flex items-center gap-2 text-sm font-medium">
<GitBranch className="size-4" />
Add custom role
</div>
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="mb-1 block text-xs text-muted-foreground">role_id</label>
<Input
className="w-48 font-mono text-xs"
placeholder="gate-copilot"
value={newRoleId}
onChange={(e) => setNewRoleId(e.target.value)}
/>
</div>
<div>
<label className="mb-1 block text-xs text-muted-foreground">Provider</label>
<select
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
value={newProviderId}
onChange={(e) => setNewProviderId(e.target.value)}
>
<option value="">Select…</option>
{enabledProviders.map((p) => (
<option key={p.id} value={p.id}>
{p.alias}
</option>
))}
</select>
</div>
<Button
size="sm"
className="gap-1"
disabled={!newRoleId.trim() || !newProviderId || upsertMut.isPending}
onClick={() =>
upsertMut.mutate({
workspace: workspaceSlug,
roleId: newRoleId.trim(),
providerAliasId: newProviderId,
})
}
>
<Plus className="size-3.5" />
Add
</Button>
</div>
</div>
) : null}
</div>
);
}

View file

@ -1,165 +0,0 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { Loader2, Plus, Wrench, X } from "lucide-react";
import { useParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { api } from "@/lib/trpc";
import type { HarnessType } from "@tasks/shared/worker";
const HARNESS_TYPES: HarnessType[] = ["minimal", "crewai", "langgraph", "remote-ag-ui"];
type HarnessForm = {
alias: string;
displayName: string;
harnessType: HarnessType;
endpointUrl: string;
authEnvVar: string;
enabled: boolean;
};
const EMPTY_FORM: HarnessForm = {
alias: "",
displayName: "",
harnessType: "minimal",
endpointUrl: "",
authEnvVar: "",
enabled: true,
};
export default function HarnessesSettingsPage() {
const params = useParams();
const workspaceSlug = params?.workspaceSlug as string | undefined;
const utils = api.useUtils();
const listQuery = api.harnesses.list.useQuery({ workspace: workspaceSlug ?? "" }, { enabled: Boolean(workspaceSlug) });
const routesQuery = api.harnesses.listRoutes.useQuery({ workspace: workspaceSlug ?? "" }, { enabled: Boolean(workspaceSlug) });
const [dialogOpen, setDialogOpen] = React.useState(false);
const [editingId, setEditingId] = React.useState<string | null>(null);
const [form, setForm] = React.useState<HarnessForm>(EMPTY_FORM);
const [testResult, setTestResult] = React.useState<Record<string, string>>({});
const [newRoleId, setNewRoleId] = React.useState("");
const [newHarnessId, setNewHarnessId] = React.useState("");
const createMut = api.harnesses.create.useMutation({ onSuccess: () => { void utils.harnesses.list.invalidate(); setDialogOpen(false); } });
const updateMut = api.harnesses.update.useMutation({ onSuccess: () => { void utils.harnesses.list.invalidate(); setDialogOpen(false); } });
const deleteMut = api.harnesses.delete.useMutation({ onSuccess: () => void utils.harnesses.list.invalidate() });
const testMut = api.harnesses.testHarness.useMutation({
onSuccess: (result, vars) => {
setTestResult((prev) => ({ ...prev, [vars.harnessId]: result.ok ? `OK in ${result.latencyMs}ms` : `Failed: ${result.error ?? "unknown"}` }));
},
});
const upsertRouteMut = api.harnesses.upsertRoute.useMutation({ onSuccess: () => { void utils.harnesses.listRoutes.invalidate(); setNewRoleId(""); setNewHarnessId(""); } });
const deleteRouteMut = api.harnesses.deleteRoute.useMutation({ onSuccess: () => void utils.harnesses.listRoutes.invalidate() });
const canManage = listQuery.data?.canManage ?? false;
const harnesses = listQuery.data?.harnesses ?? [];
const routes = routesQuery.data?.routes ?? [];
const openCreate = () => { setEditingId(null); setForm(EMPTY_FORM); setDialogOpen(true); };
const openEdit = (h: (typeof harnesses)[number]) => {
setEditingId(h.id);
setForm({ alias: h.alias, displayName: h.displayName, harnessType: h.harnessType as HarnessType, endpointUrl: h.endpointUrl ?? "", authEnvVar: h.authEnvVar ?? "", enabled: h.enabled });
setDialogOpen(true);
};
const submit = () => {
if (!workspaceSlug) return;
const payload = { workspace: workspaceSlug, alias: form.alias, displayName: form.displayName, harnessType: form.harnessType, endpointUrl: form.endpointUrl.trim() || null, authEnvVar: form.authEnvVar.trim() || null, enabled: form.enabled };
if (editingId) updateMut.mutate({ ...payload, id: editingId });
else createMut.mutate(payload);
};
if (!workspaceSlug) return <p className="p-8 text-sm text-muted-foreground">No workspace selected.</p>;
return (
<div className="mx-auto max-w-5xl px-8 py-10">
<div className="mb-4 flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10"><Wrench className="size-5 text-primary" /></div>
<div>
<h1 className="text-xl font-semibold">Harnesses</h1>
<p className="text-xs text-muted-foreground">Providers for <code className="text-[10px]">minimal|crewai|langgraph|remote-ag-ui</code> + role routing. Auth tokens stay in server env.</p>
</div>
</div>
{canManage ? <Button size="sm" className="gap-1.5" onClick={openCreate}><Plus className="size-4" />Add harness</Button> : null}
</div>
{listQuery.isLoading ? <div className="flex justify-center py-16"><Loader2 className="size-6 animate-spin text-muted-foreground" /></div>
: listQuery.error ? <p className="text-sm text-destructive">{listQuery.error.message}</p>
: harnesses.length === 0 ? <div className="rounded-lg border border-dashed px-6 py-12 text-center text-sm text-muted-foreground">No harnesses yet. Add minimal or a remote AG-UI endpoint.</div>
: (
<div className="overflow-hidden rounded-lg border">
<table className="w-full text-sm">
<thead className="bg-muted/40 text-xs uppercase tracking-wide text-muted-foreground">
<tr><th className="px-4 py-2 text-left font-medium">Alias</th><th className="px-4 py-2 text-left font-medium">Type</th><th className="px-4 py-2 text-left font-medium">Endpoint</th><th className="px-4 py-2 text-left font-medium">On</th>{canManage ? <th className="px-4 py-2 text-right font-medium">Actions</th> : null}</tr>
</thead>
<tbody className="divide-y">
{harnesses.map((h) => (
<tr key={h.id} className="hover:bg-muted/20">
<td className="px-4 py-2"><div className="font-medium">{h.alias}</div><div className="text-xs text-muted-foreground">{h.displayName}</div></td>
<td className="px-4 py-2 font-mono text-xs">{h.harnessType}</td>
<td className="max-w-[24ch] truncate px-4 py-2 font-mono text-xs text-muted-foreground">{h.endpointUrl ?? "—"}</td>
<td className="px-4 py-2 text-xs">{h.enabled ? "yes" : "no"}</td>
{canManage ? (
<td className="space-x-2 px-4 py-2 text-right">
<Button variant="outline" size="sm" disabled={testMut.isPending} onClick={() => testMut.mutate({ workspace: workspaceSlug, harnessId: h.id })}>Test</Button>
<Button variant="ghost" size="sm" onClick={() => openEdit(h)}>Edit</Button>
<Button variant="ghost" size="sm" className="text-destructive" onClick={() => { if (window.confirm(`Delete harness "${h.alias}"?`)) deleteMut.mutate({ workspace: workspaceSlug, id: h.id }); }}>Delete</Button>
{testResult[h.id] ? <div className="mt-1 text-[10px] text-muted-foreground">{testResult[h.id]}</div> : null}
</td>
) : null}
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="mt-10 rounded-lg border">
<div className="border-b px-4 py-3 text-sm font-medium">Role → harness routing</div>
{routesQuery.isLoading ? <div className="flex justify-center py-8"><Loader2 className="size-5 animate-spin text-muted-foreground" /></div>
: (
<table className="w-full text-sm">
<thead className="bg-muted/40 text-xs uppercase tracking-wide text-muted-foreground"><tr><th className="px-4 py-2 text-left font-medium">Role</th><th className="px-4 py-2 text-left font-medium">Harness</th><th className="px-4 py-2 text-left font-medium">Type</th>{canManage ? <th className="px-4 py-2 text-right font-medium">Actions</th> : null}</tr></thead>
<tbody className="divide-y">
{routes.length === 0 ? <tr><td colSpan={canManage ? 4 : 3} className="px-4 py-8 text-center text-muted-foreground">No harness routes. Map role-implementer to crewai etc.</td></tr>
: routes.map((r) => (
<tr key={r.id} className="hover:bg-muted/20">
<td className="px-4 py-2 font-mono text-xs">{r.roleId}</td>
<td className="px-4 py-2 font-mono text-xs">{r.harnessAlias}</td>
<td className="px-4 py-2 font-mono text-xs">{r.harnessType}</td>
{canManage ? <td className="px-4 py-2 text-right"><select className="mr-2 rounded border border-input bg-background px-2 py-1 text-xs" value={r.harnessProviderId} onChange={(e) => upsertRouteMut.mutate({ workspace: workspaceSlug!, roleId: r.roleId, harnessProviderId: e.target.value })}>{harnesses.filter((h) => h.enabled).map((h) => <option key={h.id} value={h.id}>{h.alias}</option>)}</select><Button variant="ghost" size="sm" className="text-destructive" onClick={() => { if (window.confirm(`Remove harness route for ${r.roleId}?`)) deleteRouteMut.mutate({ workspace: workspaceSlug!, id: r.id }); }}>Remove</Button></td> : null}
</tr>
))}
</tbody>
</table>
)}
{canManage && harnesses.length > 0 ? (
<div className="flex flex-wrap items-end gap-3 border-t bg-muted/20 p-4">
<div><label className="mb-1 block text-xs text-muted-foreground">role_id</label><Input className="w-48 font-mono text-xs" placeholder="role-implementer" value={newRoleId} onChange={(e) => setNewRoleId(e.target.value)} /></div>
<div><label className="mb-1 block text-xs text-muted-foreground">Harness</label><select className="h-9 rounded-md border border-input bg-background px-3 text-sm" value={newHarnessId} onChange={(e) => setNewHarnessId(e.target.value)}><option value="">Select…</option>{harnesses.filter((h) => h.enabled).map((h) => <option key={h.id} value={h.id}>{h.alias}</option>)}</select></div>
<Button size="sm" className="gap-1" disabled={!newRoleId.trim() || !newHarnessId || upsertRouteMut.isPending} onClick={() => upsertRouteMut.mutate({ workspace: workspaceSlug!, roleId: newRoleId.trim(), harnessProviderId: newHarnessId })}><Plus className="size-3.5" />Map</Button>
</div>
) : null}
</div>
<DialogPrimitive.Root open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogPrimitive.Portal><DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/50" /><DialogPrimitive.Content className="fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-6 shadow-lg">
<div className="mb-4 flex items-center justify-between"><DialogPrimitive.Title className="text-lg font-semibold">{editingId ? "Edit harness" : "Add harness"}</DialogPrimitive.Title><DialogPrimitive.Close asChild><button type="button" className="rounded-md p-1 hover:bg-muted"><X className="size-4" /></button></DialogPrimitive.Close></div>
<div className="space-y-3">
<Field label="Alias"><Input value={form.alias} onChange={(e) => setForm((f) => ({ ...f, alias: e.target.value }))} /></Field>
<Field label="Display name"><Input value={form.displayName} onChange={(e) => setForm((f) => ({ ...f, displayName: e.target.value }))} /></Field>
<Field label="Harness type"><select className="flex h-9 w-full rounded-md border border-input bg-background px-3 text-sm" value={form.harnessType} onChange={(e) => setForm((f) => ({ ...f, harnessType: e.target.value as HarnessType }))}>{HARNESS_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}</select></Field>
<Field label="Endpoint URL (nullable)"><Input placeholder="https://crew.example.com/ag-ui" value={form.endpointUrl} onChange={(e) => setForm((f) => ({ ...f, endpointUrl: e.target.value }))} /></Field>
<Field label="Auth env var (write-only)"><Input placeholder="HARNESS_AUTH_TOKEN" value={form.authEnvVar} onChange={(e) => setForm((f) => ({ ...f, authEnvVar: e.target.value }))} /><p className="mt-1 text-[10px] text-muted-foreground">Token lives in server env, never returned to browser.</p></Field>
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={form.enabled} onChange={(e) => setForm((f) => ({ ...f, enabled: e.target.checked }))} /> Enabled</label>
</div>
<div className="mt-6 flex justify-end gap-2"><Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button><Button onClick={submit} disabled={createMut.isPending || updateMut.isPending}>{createMut.isPending || updateMut.isPending ? <Loader2 className="size-4 animate-spin" /> : "Save"}</Button></div>
</DialogPrimitive.Content></DialogPrimitive.Portal>
</DialogPrimitive.Root>
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) { return <div><label className="mb-1 block text-xs font-medium text-muted-foreground">{label}</label>{children}</div>; }

View file

@ -1,346 +0,0 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { GitBranch, Loader2, Plus, X } from "lucide-react";
import { useParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { api } from "@/lib/trpc";
import type { ForgeType } from "@tasks/shared/connected-repos";
const FORGE_LABELS: Record<ForgeType, string> = {
github: "GitHub",
forgejo: "Forgejo / Gitea",
gitlab: "GitLab",
bitbucket: "Bitbucket",
};
type ConnectForm = {
remoteUrl: string;
displayName: string;
tokenEnvVar: string;
};
const EMPTY_FORM: ConnectForm = {
remoteUrl: "",
displayName: "",
tokenEnvVar: "",
};
export default function ConnectedReposSettingsPage() {
const params = useParams();
const workspaceSlug = params?.workspaceSlug as string | undefined;
const utils = api.useUtils();
const listQuery = api.connectedRepos.list.useQuery(
{ workspace: workspaceSlug ?? "" },
{ enabled: Boolean(workspaceSlug) },
);
const [dialogOpen, setDialogOpen] = React.useState(false);
const [form, setForm] = React.useState<ConnectForm>(EMPTY_FORM);
const [parsedPreview, setParsedPreview] = React.useState<{
forgeType: ForgeType;
owner: string;
repoName: string;
apiBaseUrl: string;
} | null>(null);
const [testResult, setTestResult] = React.useState<Record<string, string>>({});
const parseMut = api.connectedRepos.parseRemote.useMutation({
onSuccess: (parsed) => {
setParsedPreview({
forgeType: parsed.forgeType,
owner: parsed.owner,
repoName: parsed.repoName,
apiBaseUrl: parsed.apiBaseUrl,
});
setForm((prev) => ({
...prev,
displayName: prev.displayName || `${parsed.owner}/${parsed.repoName}`,
}));
},
onError: () => setParsedPreview(null),
});
const connectMut = api.connectedRepos.connect.useMutation({
onSuccess: () => {
void utils.connectedRepos.list.invalidate();
setDialogOpen(false);
setForm(EMPTY_FORM);
setParsedPreview(null);
},
});
const [connectError, setConnectError] = React.useState<string | null>(null);
const disconnectMut = api.connectedRepos.disconnect.useMutation({
onSuccess: () => void utils.connectedRepos.list.invalidate(),
});
const testMut = api.connectedRepos.testConnection.useMutation({
onSuccess: (result, vars) => {
setTestResult((prev) => ({
...prev,
[vars.repoId]: result.ok
? `OK — default branch ${result.defaultBranch} (${result.latencyMs}ms)`
: `Failed: ${result.error ?? "unknown"}`,
}));
if (result.ok) {
void utils.connectedRepos.list.invalidate();
}
},
});
const canManage = listQuery.data?.canManage ?? false;
const repos = listQuery.data?.repos ?? [];
const openCreate = () => {
setForm(EMPTY_FORM);
setParsedPreview(null);
setConnectError(null);
setDialogOpen(true);
};
const handleParse = () => {
if (!workspaceSlug || !form.remoteUrl.trim()) {
return;
}
parseMut.mutate({ workspace: workspaceSlug, remoteUrl: form.remoteUrl.trim() });
};
const handleConnect = () => {
if (!workspaceSlug || !form.remoteUrl.trim()) {
return;
}
setConnectError(null);
connectMut.mutate(
{
workspace: workspaceSlug,
remoteUrl: form.remoteUrl.trim(),
displayName: form.displayName.trim() || undefined,
tokenEnvVar: form.tokenEnvVar.trim() || null,
},
{
onError: (err) => setConnectError(err.message),
},
);
};
return (
<div className="mx-auto max-w-4xl px-6 py-8">
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Connected repos</h1>
<p className="mt-1 text-sm text-muted-foreground">
Link a git remote for worker automation (draft PRs, forge webhooks). Paste the clone
URL — OAuth repo pickers are planned; today you set the API token on the server and
reference its env var name here.
</p>
<details className="mt-3 text-sm text-muted-foreground">
<summary className="cursor-pointer font-medium text-foreground">Examples</summary>
<ul className="mt-2 list-disc space-y-2 pl-5">
<li>
<span className="font-medium text-foreground">Forgejo (self-hosted):</span>{" "}
<code className="text-xs">https://forgejo.stillwell.cloud/rstillwell/Submatter.git</code>
{" — "}token env var <code className="text-xs">FORGEJO_TOKEN</code> in{" "}
<code className="text-xs">/opt/submatter/.env</code>, then click Test connection.
</li>
<li>
<span className="font-medium text-foreground">GitHub.com:</span>{" "}
<code className="text-xs">https://github.com/org/repo.git</code>
{" — "}token env var <code className="text-xs">GITHUB_TOKEN</code> (PAT with repo
scope).
</li>
</ul>
</details>
</div>
{canManage && (
<Button type="button" onClick={openCreate}>
<Plus className="mr-2 size-4" />
Connect repo
</Button>
)}
</div>
{listQuery.isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading repos…
</div>
) : repos.length === 0 ? (
<div className="rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
No connected repos yet.
{canManage && " Connect one to prepare for orchestrator and worker automation."}
</div>
) : (
<ul className="divide-y divide-border rounded-lg border border-border">
{repos.map((repo) => (
<li key={repo.id} className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<GitBranch className="size-4 shrink-0 text-muted-foreground" />
<span className="font-medium">{repo.displayName}</span>
<span className="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{FORGE_LABELS[repo.forgeType]}
</span>
</div>
<p className="mt-1 truncate text-sm text-muted-foreground">{repo.gitRemoteUrl}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{repo.owner}/{repo.repoName}
{repo.defaultBranch ? ` · ${repo.defaultBranch}` : ""}
{repo.tokenEnvVar ? ` · token: ${repo.tokenEnvVar}` : ""}
</p>
{testResult[repo.id] && (
<p className="mt-1 text-xs text-muted-foreground">{testResult[repo.id]}</p>
)}
</div>
{canManage && (
<div className="flex shrink-0 gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={testMut.isPending}
onClick={() =>
workspaceSlug &&
testMut.mutate({ workspace: workspaceSlug, repoId: repo.id })
}
>
Test connection
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive"
disabled={disconnectMut.isPending}
onClick={() =>
workspaceSlug &&
disconnectMut.mutate({ workspace: workspaceSlug, repoId: repo.id })
}
>
Disconnect
</Button>
</div>
)}
</li>
))}
</ul>
)}
<DialogPrimitive.Root open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/50" />
<DialogPrimitive.Content className="fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-background p-6 shadow-lg">
<div className="mb-4 flex items-center justify-between">
<DialogPrimitive.Title className="text-lg font-semibold">
Connect repository
</DialogPrimitive.Title>
<DialogPrimitive.Close asChild>
<button type="button" className="rounded p-1 hover:bg-muted" aria-label="Close">
<X className="size-4" />
</button>
</DialogPrimitive.Close>
</div>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium" htmlFor="remote-url">
Git remote URL
</label>
<div className="flex gap-2">
<Input
id="remote-url"
placeholder="https://github.com/org/repo or git@host:org/repo.git"
value={form.remoteUrl}
onChange={(e) => setForm((f) => ({ ...f, remoteUrl: e.target.value }))}
/>
<Button
type="button"
variant="outline"
disabled={parseMut.isPending || !form.remoteUrl.trim()}
onClick={handleParse}
>
Parse
</Button>
</div>
{parseMut.error && (
<p className="mt-1 text-xs text-destructive">{parseMut.error.message}</p>
)}
</div>
{parsedPreview && (
<div className="rounded-md bg-muted/50 p-3 text-sm">
<p>
Detected{" "}
<span className="font-medium">{FORGE_LABELS[parsedPreview.forgeType]}</span> —{" "}
{parsedPreview.owner}/{parsedPreview.repoName}
</p>
<p className="mt-1 text-xs text-muted-foreground">{parsedPreview.apiBaseUrl}</p>
</div>
)}
<div>
<label className="mb-1 block text-sm font-medium" htmlFor="display-name">
Display name
</label>
<Input
id="display-name"
value={form.displayName}
onChange={(e) => setForm((f) => ({ ...f, displayName: e.target.value }))}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium" htmlFor="token-env">
API token env var
</label>
<Input
id="token-env"
placeholder="FORGEJO_TOKEN or GITHUB_TOKEN"
value={form.tokenEnvVar}
onChange={(e) => setForm((f) => ({ ...f, tokenEnvVar: e.target.value }))}
/>
<p className="mt-1 text-xs text-muted-foreground">
Name of the variable in the server <code className="text-xs">.env</code> — not the
secret itself. Required for Test connection to succeed.
</p>
</div>
{connectError && (
<p className="text-sm text-destructive">{connectError}</p>
)}
<div className="flex justify-end gap-2 pt-2">
<DialogPrimitive.Close asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</DialogPrimitive.Close>
<Button
type="button"
disabled={connectMut.isPending || !form.remoteUrl.trim()}
onClick={handleConnect}
>
{connectMut.isPending ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Connecting…
</>
) : (
"Connect"
)}
</Button>
</div>
{connectMut.error && (
<p className="text-xs text-destructive">{connectMut.error.message}</p>
)}
</div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
</div>
);
}

View file

@ -1,6 +1,7 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import {
Activity,
@ -66,7 +67,7 @@ function formatTimestamp(when: Date | string): string {
* for in-flight runs (no `finishedAt`). Below a minute we show seconds
* with a single decimal so quick MCP calls don't all look like "0s".
*/
function formatDuration(
export function formatDuration(
startedAt: Date | string,
finishedAt: Date | string | null,
): string {
@ -99,25 +100,41 @@ function describeOutcome(
return { label: outcome ?? "open", key: key as keyof typeof OUTCOME_CHIP };
}
/**
* Compose a URL to the task detail page when we have the full path
* triple. Returns null when:
* - the backlog item is a plan or epic (no detail page exists yet),
* - any path segment is missing (a row joined against a deleted item
* or one with the importer mid-flight),
* - or the workspace slug isn't known yet (initial render).
*
* The runs page renders the title unlinked when this returns null.
*/
function buildTaskHref(args: {
workspaceSlug: string | undefined;
taskKind: string | null;
planSlug: string | null;
epicSlug: string | null;
taskSlug: string | null;
}): string | null {
if (
args.taskKind !== "task" ||
!args.workspaceSlug ||
!args.planSlug ||
!args.epicSlug ||
!args.taskSlug
) {
return null;
}
return `/${args.workspaceSlug}/plans/${args.planSlug}/${args.epicSlug}/${args.taskSlug}`;
}
/**
* Read `client` / `model` out of a run's metadata blob. The column is
* JSONB so we're treating it as unstructured here — short-circuit on
* null and ignore values that aren't strings (defensive against
* hand-inserted rows from psql).
*/
function describeModel(row: {
modelAlias: string | null;
modelTier: string | null;
modelId: string | null;
metadata: unknown;
}): string | null {
if (row.modelAlias) {
const tier = row.modelTier ? ` (${row.modelTier})` : "";
return `${row.modelAlias}${tier}`;
}
return describeAgent(row.metadata).label;
}
function describeAgent(metadata: unknown): {
client: string | null;
model: string | null;
@ -234,7 +251,6 @@ export default function RunsPage() {
<th className="px-4 py-2 text-left font-medium">Started</th>
<th className="px-4 py-2 text-left font-medium">Duration</th>
<th className="px-4 py-2 text-left font-medium">Actor</th>
<th className="px-4 py-2 text-left font-medium">Model</th>
<th className="px-4 py-2 text-left font-medium">Outcome</th>
<th className="px-4 py-2 text-right font-medium">Tokens</th>
</tr>
@ -243,9 +259,15 @@ export default function RunsPage() {
{runsQuery.data.rows.map((row) => {
const { label, key } = describeOutcome(row.outcome, row.finishedAt);
const agent = describeAgent(row.metadata);
const modelLabel = describeModel(row);
const isOpen = expanded === row.id;
const hasDetail = Boolean(row.notes || row.error);
const taskHref = buildTaskHref({
workspaceSlug,
taskKind: row.taskKind,
planSlug: row.taskPlanSlug,
epicSlug: row.taskEpicSlug,
taskSlug: row.taskSlug,
});
return (
<React.Fragment key={row.id}>
<tr
@ -271,7 +293,21 @@ export default function RunsPage() {
) : null}
</td>
<td className="max-w-[24ch] truncate px-4 py-2 font-medium">
{row.taskTitle ?? (
{row.taskTitle ? (
taskHref ? (
<Link
href={taskHref}
// Stop the click so the row's expand/collapse
// handler doesn't also fire on link clicks.
onClick={(e) => e.stopPropagation()}
className="hover:underline focus-visible:underline focus-visible:outline-none"
>
{row.taskTitle}
</Link>
) : (
row.taskTitle
)
) : (
<span className="font-mono text-xs text-muted-foreground">
{row.backlogItemId.slice(0, 8)}
</span>
@ -304,9 +340,6 @@ export default function RunsPage() {
) : null}
</div>
</td>
<td className="max-w-[16ch] truncate px-4 py-2 text-xs text-muted-foreground">
{modelLabel ?? "—"}
</td>
<td className="px-4 py-2">
<span
className={cn(
@ -324,7 +357,7 @@ export default function RunsPage() {
{isOpen && hasDetail ? (
<tr>
<td />
<td colSpan={7} className="bg-muted/20 px-4 py-3 text-xs">
<td colSpan={6} className="bg-muted/20 px-4 py-3 text-xs">
{row.notes ? (
<div className="mb-2">
<div className="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground">

View file

@ -1,194 +0,0 @@
"use client";
import * as React from "react";
import { gateLabelForStatus } from "@tasks/shared/convoy-lifecycle";
import type { ConvoyStatus } from "@tasks/shared/convoy-lifecycle";
import { Loader2, Radar } from "lucide-react";
import { ConvoyDetailPanel } from "@/components/mission-control/convoy-detail-panel";
import { OpsCopilotFab } from "@/components/copilot/ops-copilot-panel";
import { FleetActivityFeed } from "@/components/fleet/fleet-activity-feed";
import { cn } from "@/lib/utils";
import { api } from "@/lib/trpc";
function formatAge(date: Date | string): string {
const d = new Date(date);
const diff = Date.now() - d.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days > 0) return `${days}d`;
const hours = Math.floor(diff / (1000 * 60 * 60));
if (hours > 0) return `${hours}h`;
const mins = Math.floor(diff / (1000 * 60));
return `${mins}m`;
}
export default function FleetPage() {
const fleetQuery = api.convoys.fleet.useQuery();
const [selected, setSelected] = React.useState<{ convoyId: string; workspaceSlug: string } | null>(null);
if (fleetQuery.isLoading) {
return (
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading fleet…
</div>
);
}
if (fleetQuery.error) {
return <p className="p-6 text-sm text-destructive">{fleetQuery.error.message}</p>;
}
const data = fleetQuery.data;
const byWorkspace = data?.byWorkspace ?? [];
const gateInboxAcross = data?.gateInboxAcross ?? [];
const defaultWorkspaceSlug =
selected?.workspaceSlug ?? byWorkspace[0]?.workspace.slug ?? gateInboxAcross[0]?.workspaceSlug ?? "";
// Group gate inbox by status for "× pr_review 2d" count display
const gateByStatus = new Map<string, typeof gateInboxAcross>();
for (const c of gateInboxAcross) {
const arr = gateByStatus.get(c.status) ?? [];
arr.push(c);
gateByStatus.set(c.status, arr);
}
return (
<div className="flex h-full min-h-0 flex-col">
<header className="flex shrink-0 items-center gap-3 border-b border-border px-6 py-4">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
<Radar className="size-5 text-primary" />
</div>
<div>
<h1 className="text-lg font-semibold">Fleet</h1>
<p className="text-xs text-muted-foreground">All workspaces · gates needing you · live convoy events</p>
</div>
</header>
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 overflow-y-auto p-4 lg:grid-cols-[260px_1fr_340px]">
{/* Needs you */}
<div className="flex min-h-0 flex-col gap-4">
<section className="rounded-lg border border-border bg-card">
<div className="border-b border-border p-4">
<h2 className="text-sm font-semibold">Needs you</h2>
<p className="text-xs text-muted-foreground">Gate inbox across all workspaces</p>
{gateInboxAcross.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2 text-xs">
{[...gateByStatus.entries()].map(([status, list]) => (
<span key={status} className="rounded bg-amber-500/10 px-2 py-0.5 text-amber-700 dark:text-amber-300">
× {status} {list.length} · {formatAge(list[0]?.updatedAt ?? new Date())}
</span>
))}
</div>
)}
</div>
<div className="p-3">
{gateInboxAcross.length === 0 ? (
<p className="text-sm text-muted-foreground">No pending gates.</p>
) : (
<ul className="space-y-2">
{gateInboxAcross.map((convoy) => (
<li key={`${convoy.workspaceId}-${convoy.id}`}>
<button
type="button"
onClick={() => setSelected({ convoyId: convoy.id, workspaceSlug: convoy.workspaceSlug })}
className={cn(
"w-full rounded-md border border-amber-500/30 bg-amber-500/5 p-3 text-left text-sm hover:bg-amber-500/10",
selected?.convoyId === convoy.id && "ring-2 ring-amber-500",
)}
>
<p className="font-medium">{convoy.content?.convoySlug ?? convoy.title}</p>
<p className="text-xs text-amber-700 dark:text-amber-300">{gateLabelForStatus(convoy.status as ConvoyStatus) ?? convoy.status}</p>
<p className="text-[10px] text-muted-foreground">
{convoy.workspaceName} · {convoy.workspaceSlug} · {formatAge(convoy.updatedAt)} ago
</p>
</button>
</li>
))}
</ul>
)}
</div>
</section>
<FleetActivityFeed />
</div>
{/* byWorkspace cards grid */}
<div className="min-h-0 space-y-4">
{byWorkspace.length === 0 ? (
<p className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground">No workspaces or no convoys yet.</p>
) : (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
{byWorkspace.map(({ workspace, convoys }) => {
const workspaceGateCount = gateInboxAcross.filter((c) => c.workspaceId === workspace.id).length;
return (
<div key={workspace.id} className="rounded-lg border border-border bg-card">
<div className="border-b border-border px-4 py-3">
<h3 className="text-sm font-semibold">{workspace.name}</h3>
<p className="text-xs text-muted-foreground">
{workspace.slug} · {convoys.length} convoys
{workspaceGateCount > 0 ? ` · ${workspaceGateCount} needing you` : ""}
</p>
</div>
<ul className="space-y-2 p-3">
{convoys.length === 0 ? (
<li className="text-xs text-muted-foreground">No convoys.</li>
) : (
convoys.slice(0, 8).map((convoy) => (
<li key={convoy.id}>
<button
type="button"
onClick={() => setSelected({ convoyId: convoy.id, workspaceSlug: convoy.workspaceSlug })}
className={cn(
"w-full rounded-md border border-border bg-background p-2 text-left text-sm transition-colors hover:bg-muted",
selected?.convoyId === convoy.id && "ring-2 ring-primary",
)}
>
<p className="font-medium">{convoy.content?.convoySlug ?? convoy.title}</p>
<p className="text-[10px] text-muted-foreground">
{convoy.status} · {convoy.content?.classification ?? ""} · {formatAge(convoy.updatedAt)} ago
</p>
</button>
</li>
))
)}
{convoys.length > 8 && (
<li className="text-center text-xs text-muted-foreground">+{convoys.length - 8} more</li>
)}
</ul>
</div>
);
})}
</div>
)}
{/* byStatus summary */}
{data?.byStatus && Object.keys(data.byStatus).length > 0 && (
<div className="rounded-lg border border-border bg-muted/10 p-3">
<p className="mb-2 text-xs font-semibold uppercase text-muted-foreground">By status</p>
<div className="flex flex-wrap gap-2">
{Object.entries(data.byStatus).map(([status, list]) => (
<span key={status} className="rounded border border-border bg-background px-2 py-1 text-xs">
{status}: {(list as unknown[]).length}
</span>
))}
</div>
</div>
)}
</div>
{/* Detail panel workspace-agnostic */}
<aside className="min-h-0 border border-border bg-background lg:rounded-lg">
{selected ? (
<ConvoyDetailPanel workspaceSlug={selected.workspaceSlug} convoyId={selected.convoyId} />
) : (
<div className="flex h-full items-center justify-center p-6 text-sm text-muted-foreground">
Select a convoy to review gates and co-pilot suggestions.
</div>
)}
</aside>
</div>
<OpsCopilotFab workspaceSlug={defaultWorkspaceSlug} />
</div>
);
}

View file

@ -1,30 +0,0 @@
import type { Metadata } from "next";
import { CtaSection } from "@/components/marketing/cta-section";
import { FeaturesSection } from "@/components/marketing/features-section";
import { HeroSection } from "@/components/marketing/hero-section";
import { HowItWorksSection } from "@/components/marketing/how-it-works-section";
import { ManifestSection } from "@/components/marketing/manifest-section";
import { SiteFooter } from "@/components/marketing/site-footer";
import { SiteHeader } from "@/components/marketing/site-header";
export const metadata: Metadata = {
title: "Subagents. Frontmatter. One workspace.",
};
export default function LandingPage() {
return (
<>
<SiteHeader />
<main>
<HeroSection />
<div className="marketing-hard-rule" aria-hidden />
<FeaturesSection />
<HowItWorksSection />
<ManifestSection />
<CtaSection />
</main>
<SiteFooter />
</>
);
}

View file

@ -1,99 +0,0 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { Reveal, Stagger, StaggerItem } from "@/components/marketing/reveal";
import { SiteFooter } from "@/components/marketing/site-footer";
import { SiteHeader } from "@/components/marketing/site-header";
import { cn } from "@/lib/utils";
export const metadata: Metadata = {
title: "Pricing",
};
const tiers = [
{
name: "Solo",
price: "Free",
period: "beta",
description: "Personal workspace for agent workflow experiments.",
features: ["1 workspace", "Markdown sync", "MCP tools", "Docs & planner"],
href: "/sign-up",
highlight: false,
},
{
name: "Team",
price: "$12",
period: "seat / mo",
description: "Convoys, run history, and routing for builder teams.",
features: ["Unlimited workspaces", "Agent runs", "Model routing", "Audit log"],
href: "/sign-up",
highlight: true,
},
{
name: "Fleet",
price: "Custom",
period: "contact",
description: "Mission control for repos, tenants, and orchestration.",
features: ["Connected repos", "Fleet dashboard", "Webhook gates", "SSO"],
href: "mailto:hello@submatter.dev",
highlight: false,
},
] as const;
export default function PricingPage() {
return (
<>
<SiteHeader />
<main className="marketing-paper-grain min-h-screen bg-background">
<div className="mx-auto max-w-6xl px-4 py-20 sm:px-6 sm:py-28">
<Reveal>
<p className="font-mono-brand text-[11px] uppercase tracking-[0.24em] text-muted-foreground">Pricing</p>
<h1 className="mt-4 font-marketing text-[clamp(2.5rem,6vw,4rem)] font-semibold leading-[1.05] tracking-[-0.03em]">
Seats, not tokens.
</h1>
<p className="mt-4 max-w-xl font-marketing text-muted-foreground">
Beta numbers for feedback. Bring your own model keys always.
</p>
</Reveal>
<Stagger className="mt-16 grid gap-0 border border-foreground lg:grid-cols-3">
{tiers.map((tier, i) => (
<StaggerItem
key={tier.name}
className={cn(
"flex flex-col border-foreground p-8",
i < tiers.length - 1 && "border-b lg:border-b-0 lg:border-r",
tier.highlight && "bg-[hsl(var(--hero))] text-[hsl(var(--hero-foreground))]",
)}
>
<p className="font-mono-brand text-[11px] uppercase tracking-[0.2em] opacity-70">{tier.name}</p>
<p className="mt-4 flex items-baseline gap-2">
<span className="font-marketing text-4xl font-semibold tracking-[-0.03em]">{tier.price}</span>
<span className="font-mono-brand text-[11px] uppercase tracking-widest opacity-60">{tier.period}</span>
</p>
<p className="mt-4 font-marketing text-sm opacity-80">{tier.description}</p>
<ul className="mt-8 flex-1 space-y-2 font-mono-brand text-[11px] uppercase tracking-wide">
{tier.features.map((f) => (
<li key={f}>{f}</li>
))}
</ul>
<Link
href={tier.href}
className={cn(
"mt-10 inline-flex items-center gap-2 font-mono-brand text-[11px] uppercase tracking-[0.18em]",
tier.highlight ? "text-[hsl(var(--matter))]" : "text-foreground",
)}
>
{tier.name === "Fleet" ? "Contact" : "Start"}
<ArrowRight className="size-3.5" aria-hidden />
</Link>
</StaggerItem>
))}
</Stagger>
</div>
</main>
<SiteFooter />
</>
);
}

View file

@ -1,47 +0,0 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
import { Barlow_Condensed, IBM_Plex_Sans, JetBrains_Mono } from "next/font/google";
import "@/styles/marketing.css";
const barlowCondensed = Barlow_Condensed({
subsets: ["latin"],
weight: ["700", "800"],
variable: "--font-display",
});
const ibmPlexSans = IBM_Plex_Sans({
subsets: ["latin"],
weight: ["400", "500", "600"],
variable: "--font-marketing-sans",
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
weight: ["400", "500"],
variable: "--font-mono-brand",
});
export const metadata: Metadata = {
title: {
default: "Submatter — Subagents. Frontmatter. One workspace.",
template: "%s · Submatter",
},
description:
"Agent-native workspace with markdown backlog sync, MCP task lifecycle, and convoy pipelines for Cursor subagents.",
openGraph: {
title: "SBMTR — Submatter",
description: "Subagents. Frontmatter. One workspace.",
siteName: "Submatter",
},
};
export default function MarketingLayout({ children }: { children: ReactNode }) {
return (
<div
className={`marketing-theme min-h-screen bg-background font-marketing text-foreground antialiased ${barlowCondensed.variable} ${ibmPlexSans.variable} ${jetbrainsMono.variable}`}
>
{children}
</div>
);
}

View file

@ -1,105 +0,0 @@
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { db } from "@tasks/database";
import { agentRuns, convoyEvents, objects } from "@tasks/database/schema";
import { auth } from "@/lib/auth";
import { resolveWorkspace } from "@/server/lib/resolve-workspace";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const agUiEnvelopeSchema = z.object({
convoyId: z.string().uuid().optional(),
briefId: z.string().uuid().nullable().optional(),
roleId: z.string().min(1).max(120).optional(),
eventType: z.string().min(1).max(64).optional(),
outcome: z.string().min(1).max(20).optional(),
harnessAlias: z.string().min(1).max(120).optional(),
summary: z.string().max(5000).optional(),
tokensTotal: z.number().int().nonnegative().optional(),
metadata: z.record(z.unknown()).optional(),
// Backlog lifecycle via ag-ui envelope (for remote harness that ran a task externally)
backlogItemId: z.string().uuid().optional(),
});
function json(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
}
export async function POST(req: Request, { params }: { params: Promise<{ workspace: string }> }) {
const { workspace: handle } = await params;
const session = await auth();
if (!session?.user?.id) return json(401, { error: "Unauthorized" });
let workspace;
try {
workspace = await resolveWorkspace({ handle, userId: session.user.id });
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "Workspace not found";
const code = (e as { code?: string })?.code;
const status = code === "FORBIDDEN" ? 403 : code === "NOT_FOUND" ? 404 : 400;
return json(status, { error: msg });
}
let body: z.infer<typeof agUiEnvelopeSchema>;
try {
body = agUiEnvelopeSchema.parse(await req.json());
} catch (e) {
if (e instanceof z.ZodError) return json(400, { error: e.issues[0]?.message ?? "Invalid body" });
return json(400, { error: "Invalid JSON" });
}
// Tenant fence: if convoyId provided, ensure it belongs to this workspace
if (body.convoyId) {
const [convoy] = await db.select({ id: objects.id, workspaceId: objects.workspaceId }).from(objects).where(and(eq(objects.id, body.convoyId), eq(objects.workspaceId, workspace.id))).limit(1);
if (!convoy) return json(403, { error: "convoyId not in workspace" });
}
if (body.briefId) {
const [brief] = await db.select({ id: objects.id, workspaceId: objects.workspaceId }).from(objects).where(and(eq(objects.id, body.briefId), eq(objects.workspaceId, workspace.id))).limit(1);
if (!brief) return json(403, { error: "briefId not in workspace" });
}
if (body.backlogItemId) {
// validate backlog item belongs to workspace via agent_runs path? We check objects? backlog stored in markdown_backlog_items — skip heavy check, just allow if workspace matches via conduct?
// For now allow but ensure if we can find agent run path.
}
// Write convoy event if we have convoy context
if (body.convoyId && body.roleId) {
await db.insert(convoyEvents).values({
workspaceId: workspace.id,
convoyId: body.convoyId,
briefId: body.briefId ?? null,
role: body.roleId,
actor: body.roleId,
eventType: body.eventType ?? "ag_ui",
outcome: body.outcome ?? null,
harnessAlias: body.harnessAlias ?? null,
metadata: {
source: "ag-ui",
summary: body.summary ?? null,
tokensTotal: body.tokensTotal ?? null,
...(body.metadata ?? {}),
},
});
}
// Optionally touch agent run if backlogItemId provided — create or update latest open run
if (body.backlogItemId && body.convoyId) {
// best-effort: insert completed run for observability; if harness already claimed, this will be additive
try {
await db.insert(agentRuns).values({
workspaceId: workspace.id,
backlogItemId: body.backlogItemId,
harnessAlias: body.harnessAlias ?? null,
outcome: body.outcome ?? null,
tokensTotal: body.tokensTotal ?? null,
notes: body.summary?.slice(0, 2000) ?? null,
metadata: { source: "ag-ui", ...(body.metadata ?? {}) },
});
} catch {
// ignore duplicate
}
}
return json(200, { ok: true });
}

Some files were not shown because too many files have changed in this diff Show more