Compare commits

..

No commits in common. "9bde5960bfa8b5d58135c1a4015a11760383df29" and "491d196384abd456cc3273b908248c5bf7924bab" have entirely different histories.

301 changed files with 262 additions and 53703 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,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,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

@ -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,41 +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 — OpenHands agent-server (optional; requires --profile agent-server)
# WORKER_SESSION_TYPE=minimal # set to `openhands` when agent-server is running
# 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
# OMNIROUTE_BASE_URL=https://omniroute.stillwell.cloud/v1
# OMNIROUTE_API_KEY=
# OMNIROUTE_MODEL=auto/coding
# ----- Auth (NextAuth v5) -----
# Local: http://localhost:3000 • Prod: https://echodo.stillwell.cloud
NEXTAUTH_URL="http://localhost:3000"
@ -80,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=""
@ -93,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

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

@ -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,8 +40,7 @@ import { toolCatch, toolErr, toolOk } from "./tool-result.js";
* `agent_runs` separately.
*/
const claimTaskInputSchema = z
.object({
const claimTaskInputSchema = z.object({
workspace: z
.string()
.min(1)
@ -55,10 +48,7 @@ const claimTaskInputSchema = z
backlogItemId: z
.string()
.uuid()
.optional()
.describe(
"UUID of the markdown_backlog_items row being claimed. Optional when repoPath or slug keys are provided.",
),
.describe("UUID of the markdown_backlog_items row being claimed."),
actorUserId: z
.string()
.uuid()
@ -88,19 +78,6 @@ const claimTaskInputSchema = z
.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).",
});
}
});
/**
@ -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) {
if (!item) {
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.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,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,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

@ -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

@ -66,7 +66,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 {
@ -105,19 +105,6 @@ function describeOutcome(
* 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 +221,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,7 +229,6 @@ 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);
return (
@ -304,9 +289,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 +306,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 });
}

View file

@ -1,165 +0,0 @@
import { desc, eq, inArray } from "drizzle-orm";
import { db } from "@tasks/database";
import { convoyEvents, workspaceMembers, workspaces } from "@tasks/database/schema";
import { auth } from "@/lib/auth";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
async function getUserWorkspaceIds(userId: string): Promise<string[]> {
const memberRows = await db
.select({ workspaceId: workspaceMembers.workspaceId })
.from(workspaceMembers)
.where(eq(workspaceMembers.userId, userId));
const ownedRows = await db
.select({ id: workspaces.id })
.from(workspaces)
.where(eq(workspaces.ownerUserId, userId));
const ids = new Set<string>([...memberRows.map((r) => r.workspaceId), ...ownedRows.map((r) => r.id)]);
return [...ids];
}
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return new Response("Unauthorized", { status: 401 });
}
const userId = session.user.id;
const workspaceIds = await getUserWorkspaceIds(userId);
if (workspaceIds.length === 0) {
// No workspaces: return empty SSE that stays open with keepalive
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
controller.enqueue(encoder.encode(`: no workspaces\n\n`));
const iv = setInterval(() => {
try {
controller.enqueue(encoder.encode(`: keepalive\n\n`));
} catch {
clearInterval(iv);
}
}, 15000);
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
});
}
const encoder = new TextEncoder();
let closed = false;
let pollInterval: ReturnType<typeof setInterval> | null = null;
const fetchRecent = async () =>
db
.select()
.from(convoyEvents)
.where(inArray(convoyEvents.workspaceId, workspaceIds))
.orderBy(desc(convoyEvents.occurredAt))
.limit(30);
const stream = new ReadableStream({
async start(controller) {
const send = (data: unknown) => {
if (closed) return;
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
} catch {
closed = true;
}
};
const sendComment = (comment: string) => {
if (closed) return;
try {
controller.enqueue(encoder.encode(`: ${comment}\n\n`));
} catch {
closed = true;
}
};
let initial: Awaited<ReturnType<typeof fetchRecent>> = [];
try {
initial = await fetchRecent();
} catch {
initial = [];
}
for (const ev of [...initial].reverse()) {
send({
id: ev.id,
workspaceId: ev.workspaceId,
convoyId: ev.convoyId,
briefId: ev.briefId,
role: ev.role,
actor: ev.actor,
eventType: ev.eventType,
harnessAlias: ev.harnessAlias,
occurredAt: ev.occurredAt.toISOString(),
metadata: ev.metadata,
classification: ev.classification,
durationS: ev.durationS,
outcome: ev.outcome,
});
}
const seen = new Set(initial.map((e) => e.id));
const poll = async () => {
if (closed) return;
try {
const rows = await fetchRecent();
const fresh = rows.filter((r) => !seen.has(r.id));
for (const ev of [...fresh].reverse()) {
seen.add(ev.id);
send({
id: ev.id,
workspaceId: ev.workspaceId,
convoyId: ev.convoyId,
briefId: ev.briefId,
role: ev.role,
actor: ev.actor,
eventType: ev.eventType,
harnessAlias: ev.harnessAlias,
occurredAt: ev.occurredAt.toISOString(),
metadata: ev.metadata,
classification: ev.classification,
durationS: ev.durationS,
outcome: ev.outcome,
});
}
if (seen.size > 400) {
const toKeep = new Set(rows.map((r) => r.id));
for (const id of seen) {
if (!toKeep.has(id) && seen.size > 200) seen.delete(id);
}
}
} catch {
// ignore poll errors
}
if (!closed) sendComment("keepalive");
};
pollInterval = setInterval(poll, 5000);
},
cancel() {
closed = true;
if (pollInterval) clearInterval(pollInterval);
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}

View file

@ -1,143 +0,0 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import { eq } from "drizzle-orm";
import { db } from "@tasks/database";
import { objects } from "@tasks/database/schema";
import { transitionConvoy } from "@/server/lib/convoy/convoy-service";
import { rateLimit } from "@/server/lib/rate-limit";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const CONVOY_MARKER_RE = /echodo-convoy:\s*([0-9a-f-]{36})/i;
const RATE_LIMIT = { limit: 60, windowMs: 60_000 } as const;
function getClientIp(req: Request): string {
const fwd = req.headers.get("x-forwarded-for");
if (fwd) {
const first = fwd.split(",")[0]?.trim();
if (first) return first;
}
const realIp = req.headers.get("x-real-ip");
if (realIp?.trim()) return realIp.trim();
return "unknown";
}
function verifyHmacSignature(rawBody: string, signatureHeader: string | null, secret: string): boolean {
if (!signatureHeader) return false;
let sig = signatureHeader.trim();
if (sig.startsWith("sha256=")) sig = sig.slice(7);
const computed = createHmac("sha256", secret).update(Buffer.from(rawBody, "utf8")).digest("hex");
const a = Buffer.from(computed, "utf8");
const b = Buffer.from(sig, "utf8");
if (a.length !== b.length) {
try {
timingSafeEqual(a, a);
} catch {
// ignore
}
return false;
}
return timingSafeEqual(a, b);
}
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): Promise<Response> {
const ip = getClientIp(req);
const rl = rateLimit({
key: `forge-webhook:${ip}`,
limit: RATE_LIMIT.limit,
windowMs: RATE_LIMIT.windowMs,
});
if (!rl.allowed) {
return json(429, { ok: false, error: "rate_limited", retryAfterMs: rl.retryAfterMs });
}
const secret = process.env.FORGE_WEBHOOK_SECRET?.trim();
if (!secret) {
return json(500, { ok: false, error: "webhook_not_configured" });
}
let rawBody: string;
try {
rawBody = await req.text();
} catch {
return json(400, { ok: false, error: "invalid_body" });
}
const hubSig = req.headers.get("x-hub-signature-256") ?? req.headers.get("x-hub-signature");
const forgejoSig = req.headers.get("x-forgejo-signature");
const providedSig = hubSig ?? forgejoSig;
if (!providedSig) {
return json(401, { ok: false, error: "missing_signature" });
}
if (!verifyHmacSignature(rawBody, providedSig, secret)) {
return json(401, { ok: false, error: "invalid_signature" });
}
let payload: Record<string, unknown>;
try {
payload = rawBody ? (JSON.parse(rawBody) as Record<string, unknown>) : {};
} catch {
return json(200, { ok: true });
}
const action = typeof payload.action === "string" ? payload.action : null;
const prRaw = payload.pull_request as Record<string, unknown> | null | undefined;
if (action !== "closed" || !prRaw || typeof prRaw !== "object") {
return json(200, { ok: true });
}
const merged = prRaw.merged === true;
if (!merged) {
return json(200, { ok: true });
}
const bodyText = typeof prRaw.body === "string" ? prRaw.body : "";
const match = CONVOY_MARKER_RE.exec(bodyText);
if (!match?.[1]) {
return json(200, { ok: true });
}
const convoyId = match[1].toLowerCase();
const prNumber = typeof prRaw.number === "number" ? prRaw.number : null;
const [convoyRow] = await db
.select({ id: objects.id, workspaceId: objects.workspaceId, status: objects.status, type: objects.type })
.from(objects)
.where(eq(objects.id, convoyId))
.limit(1);
if (!convoyRow || convoyRow.type !== "project") {
return json(200, { ok: true });
}
if (convoyRow.status !== "pr_review") {
return json(200, { ok: true });
}
const reason = prNumber !== null ? `pr#${prNumber} merged` : "pr merged";
try {
await transitionConvoy(db, {
workspaceId: convoyRow.workspaceId,
convoyId: convoyRow.id,
toStatus: "merged",
actor: "automation",
reason,
});
} catch {
return json(200, { ok: true });
}
return json(200, { ok: true });
}

View file

@ -6,14 +6,27 @@ import { db } from "@tasks/database/client";
import { workspaceMembers, workspaces } from "@tasks/database/schema";
/**
* Root landing route. Unauthenticated visitors go to the public marketing site;
* signed-in users land on their oldest workspace home (`/{slug}/`).
* Root landing route. Decides where an authenticated user goes when they
* hit `/`. The post-sign-in `callbackUrl` defaults here, so this is the
* single source of truth for "where does a user actually land?"
*
* Decision: land on the user's oldest workspace home (`/{slug}/`). Why oldest:
* we don't have a "last visited" column yet (deliberately deferred — see
* Task-pick-workspace-landing-route) and oldest-membership is stable across
* page loads. The workspace home is no longer a mockup; it now shows real
* stats and recent activity via `objects.stats` / `objects.listRecent`.
*
* The auth callback (`ensureUserHasWorkspace` in `apps/web/lib/auth.ts`)
* provisions a personal workspace on first sign-in, so the zero-workspace
* branch below should be effectively unreachable for fresh sign-ins. It
* survives only as a defensive fallback for sessions that were minted
* before that provisioning logic existed.
*/
export default async function HomePage() {
const session = await auth();
if (!session?.user?.id) {
redirect("/landing");
redirect("/sign-in");
}
const [membership] = await db
@ -28,5 +41,11 @@ export default async function HomePage() {
redirect(`/${membership.slug}`);
}
// Zero-workspace fallback. See header doc — this should be unreachable
// post-`ensureUserHasWorkspace`. If we hit it anyway, bounce through
// sign-in so the auth callback re-runs and provisions a workspace.
// Track follow-up: a proper onboarding flow for users in this state
// is captured in Plan-daily-driver-finish/Epic-shipping-the-shell/
// Task-onboarding-zero-workspace-flow.md.
redirect("/sign-in?error=no_workspace");
}

View file

@ -1,375 +0,0 @@
"use client";
import * as React from "react";
import { Loader2, Send, Sparkles, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { api } from "@/lib/trpc";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from "@/components/ui/sheet";
type ChatRole = "user" | "assistant";
type ChatMessage = {
id: string;
role: ChatRole;
content: string;
};
function TypingDots() {
return (
<div className="flex items-center gap-1.5 px-1 py-2" aria-hidden>
{[0, 1, 2].map((i) => (
<span
key={i}
className="inline-block h-2 w-2 animate-bounce rounded-full bg-muted-foreground/70"
style={{ animationDelay: `${i * 0.15}s` }}
/>
))}
</div>
);
}
export function OpsCopilotPanel({
workspaceSlug,
open,
onOpenChange,
}: {
workspaceSlug: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [messages, setMessages] = React.useState<ChatMessage[]>([]);
const [input, setInput] = React.useState("");
const [draftOpen, setDraftOpen] = React.useState(false);
const [draftPlanSlug, setDraftPlanSlug] = React.useState("");
const [draftEpicSlug, setDraftEpicSlug] = React.useState("");
const [draftSlug, setDraftSlug] = React.useState("");
const [draftTitle, setDraftTitle] = React.useState("");
const [draftBody, setDraftBody] = React.useState("");
const bottomRef = React.useRef<HTMLDivElement>(null);
const copilotMut = api.opsCopilot.generateText.useMutation({
onSuccess: (data) => {
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), role: "assistant", content: data.text },
]);
},
});
const createTaskMut = api.backlog.createTask.useMutation({
onSuccess: () => {
setDraftOpen(false);
setDraftPlanSlug("");
setDraftEpicSlug("");
setDraftSlug("");
setDraftTitle("");
setDraftBody("");
},
});
React.useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, copilotMut.isPending]);
const send = React.useCallback(() => {
const trimmed = input.trim();
if (!trimmed || copilotMut.isPending) return;
if (!workspaceSlug) return;
const userMsg: ChatMessage = {
id: crypto.randomUUID(),
role: "user",
content: trimmed,
};
const next = [...messages, userMsg];
setMessages(next);
setInput("");
const history = next.slice(0, -1).map((m) => ({
role: m.role,
content: m.content,
}));
copilotMut.mutate({
workspace: workspaceSlug,
prompt: trimmed,
messages: history.length > 0 ? history : undefined,
});
}, [input, copilotMut, messages, workspaceSlug]);
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
};
const handleDraftTask = () => {
if (!workspaceSlug) return;
if (!draftPlanSlug.trim() || !draftEpicSlug.trim() || !draftSlug.trim() || !draftTitle.trim()) {
return;
}
createTaskMut.mutate({
workspace: workspaceSlug,
planSlug: draftPlanSlug.trim().toLowerCase(),
epicSlug: draftEpicSlug.trim().toLowerCase(),
slug: draftSlug.trim().toLowerCase(),
title: draftTitle.trim(),
bodyMarkdown: draftBody.trim() || undefined,
});
};
const isBusy = copilotMut.isPending;
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="flex w-full max-w-md flex-col p-0 sm:max-w-md">
<SheetHeader className="shrink-0 border-b px-4 py-3 text-left">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Sparkles className="size-5 text-primary" />
<SheetTitle className="text-base">Ops copilot</SheetTitle>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => onOpenChange(false)}
aria-label="Close"
>
<X className="size-4" />
</Button>
</div>
<SheetDescription className="text-xs">
Ask &quot;what needs me?&quot; — grounded in fleet, convoys and backlog. Links use [workspace/slug].
{workspaceSlug ? ` · ${workspaceSlug}` : " · no workspace"}
</SheetDescription>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-3">
{messages.length === 0 && !isBusy ? (
<div className="space-y-3 px-1 py-6 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
<Sparkles className="size-6 text-primary" />
</div>
<p className="text-sm font-medium">What needs you?</p>
<p className="mx-auto max-w-[22rem] text-xs text-muted-foreground">
Try &quot;what needs my approval?&quot; — fleet → needs you will be cited with [fleet] links.
</p>
<div className="flex flex-wrap justify-center gap-2">
{["What needs me?", "What's ready to start?", "Show my gate inbox"].map((chip) => (
<button
key={chip}
type="button"
onClick={() => setInput(chip)}
className="rounded-full border bg-card px-3 py-1.5 text-xs hover:bg-muted"
>
{chip}
</button>
))}
</div>
{!workspaceSlug && (
<p className="text-xs text-destructive">No workspace selected — create or select one first.</p>
)}
</div>
) : null}
<div className="space-y-3">
{messages.map((m) => (
<div
key={m.id}
className={cn("flex gap-2", m.role === "user" ? "justify-end" : "justify-start")}
>
<div
className={cn(
"max-w-[85%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap",
m.role === "user"
? "bg-primary text-primary-foreground"
: "border bg-muted",
)}
>
{m.content}
</div>
</div>
))}
{isBusy && (
<div className="flex gap-2">
<div className="rounded-lg border bg-card px-3 py-2">
<TypingDots />
</div>
</div>
)}
{copilotMut.error && (
<p className="rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
{copilotMut.error.message}
</p>
)}
{copilotMut.data && (
<p className="px-1 text-[10px] text-muted-foreground">
Model: {copilotMut.data.resolvedAlias} ({copilotMut.data.modelId})
</p>
)}
<div ref={bottomRef} />
</div>
</div>
<div className="shrink-0 border-t bg-muted/20 px-3 py-3">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-[11px] text-muted-foreground">
{lastAssistant ? "Need to create work from this answer?" : "Draft a Task from chat"}
</p>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setDraftOpen((v) => !v)}
>
Draft Task
</Button>
</div>
{draftOpen && (
<div className="mb-3 rounded-md border bg-background p-3">
<p className="mb-2 text-xs font-medium">Create backlog task via create-backlog-item shape</p>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<label className="text-[10px] font-medium uppercase text-muted-foreground">Plan slug</label>
<Input
value={draftPlanSlug}
onChange={(e) => setDraftPlanSlug(e.target.value)}
placeholder="automation-foundation"
className="h-8 text-xs"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] font-medium uppercase text-muted-foreground">Epic slug</label>
<Input
value={draftEpicSlug}
onChange={(e) => setDraftEpicSlug(e.target.value)}
placeholder="ops-copilot"
className="h-8 text-xs"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] font-medium uppercase text-muted-foreground">Task slug</label>
<Input
value={draftSlug}
onChange={(e) => setDraftSlug(e.target.value)}
placeholder="triage-bug"
className="h-8 text-xs"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] font-medium uppercase text-muted-foreground">Title</label>
<Input
value={draftTitle}
onChange={(e) => setDraftTitle(e.target.value)}
placeholder="Triage bug from copilot"
className="h-8 text-xs"
/>
</div>
</div>
<div className="mt-2 space-y-1">
<label className="text-[10px] font-medium uppercase text-muted-foreground">Body markdown</label>
<textarea
value={draftBody}
onChange={(e) => setDraftBody(e.target.value)}
placeholder="One or two sentences. Will become Task-*.md"
rows={3}
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs"
/>
</div>
<div className="mt-2 flex items-center justify-end gap-2">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setDraftOpen(false)}
className="h-7 text-xs"
>
Cancel
</Button>
<Button
type="button"
size="sm"
className="h-7 text-xs"
disabled={
createTaskMut.isPending ||
!draftPlanSlug.trim() ||
!draftEpicSlug.trim() ||
!draftSlug.trim() ||
!draftTitle.trim() ||
!workspaceSlug
}
onClick={handleDraftTask}
>
{createTaskMut.isPending ? <Loader2 className="size-3 animate-spin" /> : "Create Task"}
</Button>
</div>
{createTaskMut.error && (
<p className="mt-2 text-xs text-destructive">{createTaskMut.error.message}</p>
)}
{createTaskMut.isSuccess && (
<p className="mt-2 text-xs text-green-600">Task created — check Plans tree.</p>
)}
</div>
)}
<div className="flex gap-2">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={onKeyDown}
placeholder={workspaceSlug ? "What needs me?" : "Select workspace first"}
disabled={isBusy || !workspaceSlug}
rows={2}
className={cn(
"min-h-[44px] flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm",
"placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
/>
<Button
type="button"
size="icon"
className="h-[44px] w-[44px] shrink-0"
onClick={send}
disabled={isBusy || !input.trim() || !workspaceSlug}
aria-label="Send"
>
{isBusy ? <Loader2 className="size-4 animate-spin" /> : <Send className="size-4" />}
</Button>
</div>
</div>
</div>
</SheetContent>
</Sheet>
);
}
export function OpsCopilotFab({ workspaceSlug }: { workspaceSlug: string }) {
const [open, setOpen] = React.useState(false);
return (
<>
<Button
type="button"
onClick={() => setOpen(true)}
className="fixed bottom-6 right-6 z-40 h-12 w-12 rounded-full shadow-lg"
size="icon"
aria-label="Open ops copilot"
>
<Sparkles className="size-5" />
</Button>
<OpsCopilotPanel workspaceSlug={workspaceSlug} open={open} onOpenChange={setOpen} />
</>
);
}

View file

@ -1,101 +0,0 @@
"use client";
import * as React from "react";
type FleetEvent = {
id: string;
workspaceId: string;
convoyId: string;
briefId: string | null;
role: string;
actor: string | null;
eventType: string;
harnessAlias: string | null;
occurredAt: string;
metadata: Record<string, unknown> | null;
classification?: string | null;
durationS?: number | null;
outcome?: string | null;
};
function formatRelative(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const s = Math.floor(diff / 1000);
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
export function FleetActivityFeed() {
const [events, setEvents] = React.useState<FleetEvent[]>([]);
const [connected, setConnected] = React.useState(false);
const seenRef = React.useRef<Set<string>>(new Set());
React.useEffect(() => {
const es = new EventSource("/api/fleet/events");
es.onopen = () => setConnected(true);
es.onerror = () => setConnected(false);
es.onmessage = (e) => {
try {
const parsed = JSON.parse(e.data) as FleetEvent | FleetEvent[];
const incoming = Array.isArray(parsed) ? parsed : [parsed];
// Filter duplicates by id
const fresh = incoming.filter((ev) => {
if (!ev?.id || seenRef.current.has(ev.id)) return false;
seenRef.current.add(ev.id);
return true;
});
if (fresh.length === 0) return;
setEvents((prev) => {
const merged = [...fresh, ...prev];
// keep sort by occurredAt desc, dedup already handled, limit 50
merged.sort((a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime());
return merged.slice(0, 100);
});
} catch {
// ignore malformed
}
};
return () => {
es.close();
};
}, []);
return (
<div className="rounded-lg border border-border bg-card">
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h3 className="text-sm font-semibold">Activity feed</h3>
<span className="text-[10px] text-muted-foreground">{connected ? "live" : "reconnecting…"}</span>
</div>
<div className="max-h-[360px] overflow-y-auto">
{events.length === 0 ? (
<p className="p-4 text-sm text-muted-foreground">No recent convoy events.</p>
) : (
<ul className="divide-y divide-border">
{events.map((ev) => (
<li key={ev.id} className="px-4 py-2.5 text-sm">
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{ev.eventType}</span>
<span className="text-xs text-muted-foreground">{formatRelative(ev.occurredAt)}</span>
</div>
<p className="text-xs text-muted-foreground">
{ev.role}
{ev.actor && ev.actor !== ev.role ? ` · ${ev.actor}` : ""}
{ev.harnessAlias ? ` · ${ev.harnessAlias}` : ""}
{ev.outcome ? ` · ${ev.outcome}` : ""}
</p>
<p className="text-[10px] text-muted-foreground">convoy {ev.convoyId.slice(0, 8)}</p>
</li>
))}
</ul>
)}
</div>
</div>
);
}

View file

@ -9,7 +9,6 @@ import {
LayoutGrid,
MoreHorizontal,
Presentation,
Radar,
Sparkles,
UserPlus,
Users,
@ -35,7 +34,6 @@ const NAV_ITEMS: NavItem[] = [
{ id: "home", label: "Home", segment: "", icon: Home },
{ id: "spaces", label: "Spaces", segment: "", icon: LayoutGrid },
{ id: "planner", label: "Planner", segment: "planner", icon: CalendarDays },
{ id: "mission-control", label: "Mission control", segment: "mission-control", icon: Radar },
{ id: "ai", label: "AI", segment: "ai", icon: Sparkles },
{ id: "teams", label: "Teams", segment: "teams", icon: Users },
{ id: "docs", label: "Docs", segment: "docs", icon: FileText },
@ -49,7 +47,6 @@ function workspaceHref(workspaceSegment: string, segment: string) {
const RESERVED_ROOT_SEGMENTS = new Set([
"planner",
"mission-control",
"ai",
"teams",
"docs",

View file

@ -10,18 +10,15 @@ import {
CheckSquare,
ChevronDown,
FileText,
GitBranch,
LayoutGrid,
LayoutTemplate,
LogOut,
Pencil,
Plus,
Presentation,
Radar,
ScrollText,
Search,
Settings,
Sparkles,
User,
Users,
Zap,
@ -147,42 +144,6 @@ export function TopHeader({ onOpenSearch, onQuickAction }: TopHeaderProps) {
<ScrollText className="size-4 text-muted-foreground" />
Audit log
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2.5 px-4"
onSelect={() =>
workspaceId && router.push(`/${workspaceId}/settings/ai/providers`)
}
>
<Sparkles className="size-4 text-muted-foreground" />
AI providers
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2.5 px-4"
onSelect={() =>
workspaceId && router.push(`/${workspaceId}/settings/ai/role-routing`)
}
>
<Sparkles className="size-4 text-muted-foreground" />
Role routing
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2.5 px-4"
onSelect={() =>
workspace?.slug && router.push(`/${workspace.slug}/mission-control`)
}
>
<Radar className="size-4 text-muted-foreground" />
Mission control
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2.5 px-4"
onSelect={() =>
workspace?.slug && router.push(`/${workspace.slug}/settings/repos`)
}
>
<GitBranch className="size-4 text-muted-foreground" />
Connected repos
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2.5 px-4"
onSelect={() =>

View file

@ -1,40 +0,0 @@
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { Reveal } from "@/components/marketing/reveal";
export function CtaSection() {
return (
<section className="border-b border-foreground bg-background py-24 sm:py-32">
<div className="mx-auto max-w-6xl px-4 sm:px-6">
<Reveal className="border border-foreground bg-[hsl(var(--hero))] p-10 text-[hsl(var(--hero-foreground))] sm:p-16">
<p className="font-mono-brand text-[11px] uppercase tracking-[0.28em] text-[hsl(var(--hero-muted))]">---</p>
<h2 className="mt-6 font-display text-[clamp(3.5rem,12vw,8rem)] font-extrabold leading-[0.9] tracking-[0.08em]">
SBMTR
</h2>
<p className="mt-8 max-w-xl font-marketing text-lg text-[hsl(var(--hero-muted))]">
Open Monday morning in Submatter. Your backlog is already in git — give your agents a workspace that
respects that.
</p>
<div className="mt-10 flex flex-wrap items-center gap-8">
<Link
href="/sign-up"
className="group inline-flex items-center gap-3 font-mono-brand text-[11px] uppercase tracking-[0.18em]"
>
<span className="border-b-2 border-[hsl(var(--matter))] pb-1 text-[hsl(var(--hero-foreground))] transition-colors group-hover:text-[hsl(var(--matter))]">
Start free
</span>
<ArrowRight className="size-4" aria-hidden />
</Link>
<Link
href="/landing/pricing"
className="font-mono-brand text-[11px] uppercase tracking-[0.18em] text-[hsl(var(--hero-muted))] hover:text-[hsl(var(--hero-foreground))]"
>
Pricing
</Link>
</div>
</Reveal>
</div>
</section>
);
}

View file

@ -1,71 +0,0 @@
import { Reveal, Stagger, StaggerItem } from "@/components/marketing/reveal";
const capabilities = [
{
index: "01",
title: "Markdown backlog",
body: "Plan / Epic / Task files in git. Frontmatter is the schema agents and humans both read.",
mono: "plans/Plan-*/Epic-*/Task-*.md",
},
{
index: "02",
title: "Bidirectional sync",
body: "Importer mirrors YAML into Postgres. Cursor mappings keep slugs aligned with agent ids.",
mono: "watch:markdown-backlog",
},
{
index: "03",
title: "MCP lifecycle",
body: "claim_task → work → complete_task. Auditable runs instead of ad-hoc object patches.",
mono: "workspace_id scoped",
},
{
index: "04",
title: "Convoy gates",
body: "Role-based subagents with human approval between architect, implementer, and reviewer.",
mono: "ready → in_progress → done",
},
{
index: "05",
title: "Full workspace",
body: "Docs, boards, forms, whiteboards — one object model, not a task queue bolted on.",
mono: "multitenant · workspace_id",
},
{
index: "06",
title: "Model routing",
body: "Per-role tiers in convoy frontmatter — fast implementers, premium architects.",
mono: "model_policy: composer-fast",
},
] as const;
export function FeaturesSection() {
return (
<section id="features" className="marketing-paper-grain border-b border-foreground bg-background py-24 sm:py-32">
<div className="relative mx-auto max-w-6xl px-4 sm:px-6">
<Reveal className="mb-16 grid gap-8 lg:grid-cols-12 lg:items-end">
<div className="lg:col-span-8">
<p className="font-mono-brand text-[11px] uppercase tracking-[0.24em] text-muted-foreground">Capabilities</p>
<h2 className="mt-4 font-marketing text-[clamp(2rem,5vw,3.5rem)] font-semibold leading-[1.05] tracking-[-0.03em]">
Built for the agent pipeline era — not the slide-deck era.
</h2>
</div>
<p className="font-marketing text-sm leading-relaxed text-muted-foreground lg:col-span-4 lg:text-right">
Swiss grid. Zero decoration. Every line earns its place.
</p>
</Reveal>
<Stagger className="grid border border-foreground lg:grid-cols-2 [&>*]:border-b [&>*]:border-foreground [&>*:last-child]:border-b-0 lg:[&>*:nth-child(odd)]:border-r lg:[&>*:nth-last-child(-n+2)]:border-b-0">
{capabilities.map((item) => (
<StaggerItem key={item.index} className="p-8">
<p className="font-mono-brand text-[11px] text-matter">{item.index}</p>
<h3 className="mt-4 font-marketing text-xl font-semibold tracking-[-0.02em]">{item.title}</h3>
<p className="mt-3 font-marketing text-sm leading-relaxed text-muted-foreground">{item.body}</p>
<p className="mt-6 font-mono-brand text-[11px] text-foreground">{item.mono}</p>
</StaggerItem>
))}
</Stagger>
</div>
</section>
);
}

View file

@ -1,64 +0,0 @@
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { MarqueeStrip } from "@/components/marketing/marquee-strip";
import { ProductDemo } from "@/components/marketing/product-demo";
import { Reveal } from "@/components/marketing/reveal";
import { ScrambleText } from "@/components/marketing/scramble-text";
export function HeroSection() {
return (
<section className="marketing-aurora marketing-grid-field relative min-h-[92vh] overflow-hidden text-[hsl(var(--hero-foreground))]">
<div className="relative mx-auto flex min-h-[92vh] max-w-6xl flex-col justify-between px-4 pb-0 pt-10 sm:px-6">
<div className="flex flex-1 flex-col justify-center py-12 lg:py-20">
<Reveal>
<p className="font-mono-brand text-[11px] uppercase tracking-[0.28em] text-[hsl(var(--hero-muted))]">
Submatter · agent-native workspace
</p>
</Reveal>
<Reveal delay={0.05} className="mt-8">
<h1 className="font-display text-[clamp(4.5rem,16vw,11rem)] font-extrabold leading-[0.85] tracking-[0.06em]">
<ScrambleText text="SBMTR" durationMs={1400} />
</h1>
</Reveal>
<Reveal delay={0.12} className="mt-10 max-w-2xl">
<p className="font-marketing text-xl leading-snug tracking-[-0.02em] text-[hsl(var(--hero-foreground))] sm:text-2xl">
The coordination plane between{" "}
<span className="font-mono-brand text-[hsl(var(--matter))]">plans/</span>, your team, and
Cursor subagents.
</p>
<p className="mt-4 font-marketing text-base leading-relaxed text-[hsl(var(--hero-muted))]">
Markdown frontmatter is the contract. MCP is the handshake. Convoys are how work ships.
</p>
</Reveal>
<Reveal delay={0.18} className="mt-10 flex flex-wrap items-center gap-8">
<Link
href="/sign-up"
className="group inline-flex items-center gap-3 font-marketing text-sm font-medium uppercase tracking-[0.12em] text-[hsl(var(--hero-foreground))]"
>
<span className="border-b-2 border-[hsl(var(--matter))] pb-1 transition-colors group-hover:text-[hsl(var(--matter))]">
Start free
</span>
<ArrowRight className="size-4 transition-transform group-hover:translate-x-1" aria-hidden />
</Link>
<Link
href="#manifest"
className="font-mono-brand text-[11px] uppercase tracking-[0.2em] text-[hsl(var(--hero-muted))] transition-colors hover:text-[hsl(var(--hero-foreground))]"
>
Read the manifest
</Link>
</Reveal>
</div>
<Reveal delay={0.22} y={40} className="pb-8">
<ProductDemo className="border-[hsl(var(--hero-border))]" />
</Reveal>
</div>
<MarqueeStrip />
</section>
);
}

View file

@ -1,58 +0,0 @@
import { Reveal } from "@/components/marketing/reveal";
const logLines = [
{ time: "09:14:02", level: "sync", msg: "imported plans/Plan-automation-foundation/Epic-convoy-lifecycle-mcp/Task-mcp-claim-task-tool.md" },
{ time: "09:14:03", level: "mcp", msg: "claim_task · workspace_id=ws_8a2 · agent=cursor-ide · run=ar_8f2a" },
{ time: "09:14:04", level: "convoy", msg: "role-implementer · model=composer-fast · status=in_progress" },
{ time: "09:18:41", level: "gate", msg: "human approval required · role-reviewer · awaiting operator" },
{ time: "09:22:15", level: "sync", msg: "frontmatter status → done · cursor_todo_id mapped" },
{ time: "09:22:16", level: "mcp", msg: "complete_task · run=ar_8f2a · duration=8m14s" },
] as const;
const levelColor: Record<string, string> = {
sync: "text-agent",
mcp: "text-matter",
convoy: "text-foreground",
gate: "text-matter",
};
export function HowItWorksSection() {
return (
<section id="pipeline" className="border-b border-foreground bg-[hsl(var(--hero))] py-24 text-[hsl(var(--hero-foreground))] sm:py-32">
<div className="mx-auto max-w-6xl px-4 sm:px-6">
<Reveal className="mb-12 flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="font-mono-brand text-[11px] uppercase tracking-[0.24em] text-[hsl(var(--hero-muted))]">
Pipeline log
</p>
<h2 className="mt-4 font-marketing text-[clamp(2rem,4vw,3rem)] font-semibold tracking-[-0.03em]">
From frontmatter to shipped
</h2>
</div>
<p className="max-w-md font-mono-brand text-[11px] leading-relaxed text-[hsl(var(--hero-muted))]">
tail -f .convoys/.metrics.jsonl
<br />
# auditable agent runs, not black-box chat
</p>
</Reveal>
<Reveal delay={0.08}>
<div className="border border-[hsl(var(--hero-border))] bg-[hsl(0_0%_6%)]">
<div className="border-b border-[hsl(var(--hero-border))] px-4 py-2 font-mono-brand text-[10px] uppercase tracking-widest text-[hsl(var(--hero-muted))]">
submatter · fleet · live
</div>
<ul className="divide-y divide-[hsl(var(--hero-border))]">
{logLines.map((line) => (
<li key={line.time} className="grid gap-2 px-4 py-3 font-mono-brand text-[12px] sm:grid-cols-[5rem_4rem_1fr] sm:gap-4">
<span className="text-[hsl(var(--hero-muted))]">{line.time}</span>
<span className={levelColor[line.level] ?? "text-[hsl(var(--hero-foreground))]"}>{line.level}</span>
<span className="text-[hsl(var(--hero-foreground)/0.9)]">{line.msg}</span>
</li>
))}
</ul>
</div>
</Reveal>
</div>
</section>
);
}

View file

@ -1,61 +0,0 @@
import Link from "next/link";
import { cn } from "@/lib/utils";
type LogoProps = {
className?: string;
showTagline?: boolean;
size?: "sm" | "md" | "lg";
variant?: "dark" | "light";
};
const sizeClasses = {
sm: { rule: "w-8", word: "text-xl tracking-[0.22em]", tag: "text-[10px]" },
md: { rule: "w-10", word: "text-2xl tracking-[0.24em]", tag: "text-xs" },
lg: { rule: "w-14", word: "text-4xl tracking-[0.26em]", tag: "text-sm" },
};
export function Logo({
className,
showTagline = false,
size = "md",
variant = "light",
}: LogoProps) {
const s = sizeClasses[size];
const isDark = variant === "dark";
return (
<Link href="/landing" className={cn("group inline-flex flex-col gap-1", className)}>
<span
className={cn(
"h-0.5 rounded-full transition-colors",
s.rule,
isDark
? "bg-[hsl(var(--hero-foreground))] group-hover:bg-[hsl(var(--matter))]"
: "bg-foreground group-hover:bg-primary",
)}
aria-hidden
/>
<span
className={cn(
"font-display font-extrabold leading-none",
s.word,
isDark ? "text-[hsl(var(--hero-foreground))]" : "text-foreground",
)}
>
SBMTR
</span>
{showTagline ? (
<span
className={cn(
"font-marketing",
s.tag,
isDark ? "text-[hsl(var(--hero-muted))]" : "text-muted-foreground",
)}
>
Submatter
</span>
) : null}
</Link>
);
}

View file

@ -1,57 +0,0 @@
import { Reveal } from "@/components/marketing/reveal";
const principles = [
{
title: "Frontmatter is law",
body: "If it is not in YAML at the top of the file, it is not part of the contract. Agents read the same fields humans edit.",
},
{
title: "Repo-native backlog",
body: "plans/ lives in git. Submatter syncs — it does not replace your source of truth.",
},
{
title: "Humans at the gates",
body: "Convoys pause for approval. Nothing merges because an agent got confident.",
},
{
title: "Tenant-scoped everything",
body: "workspace_id on every row. Multitenancy is not a migration you schedule for later.",
},
] as const;
export function ManifestSection() {
return (
<section id="manifest" className="marketing-paper-grain border-b border-foreground bg-background py-24 sm:py-32">
<div className="mx-auto max-w-6xl px-4 sm:px-6">
<Reveal className="mb-16">
<p className="font-mono-brand text-[11px] uppercase tracking-[0.24em] text-muted-foreground">Manifest</p>
<h2 className="mt-4 max-w-3xl font-marketing text-[clamp(2rem,5vw,3.25rem)] font-semibold leading-[1.08] tracking-[-0.03em]">
We are not building another chat box with a kanban view glued on.
</h2>
</Reveal>
<div className="grid gap-0 border border-foreground lg:grid-cols-2">
{principles.map((item, i) => (
<Reveal key={item.title} delay={i * 0.06}>
<article
className={`border-foreground p-8 ${i % 2 === 0 ? "lg:border-r" : ""} ${i < 2 ? "border-b" : ""}`}
>
<h3 className="font-marketing text-lg font-semibold tracking-[-0.02em]">{item.title}</h3>
<p className="mt-3 font-marketing text-sm leading-relaxed text-muted-foreground">{item.body}</p>
</article>
</Reveal>
))}
</div>
<Reveal delay={0.2} className="mt-16 border-l-4 border-matter pl-6">
<p className="font-marketing text-lg leading-relaxed text-foreground">
Subagents. Frontmatter. One workspace.
</p>
<p className="mt-2 font-mono-brand text-[11px] text-muted-foreground">
kind: manifest · version: 2 · status: locked
</p>
</Reveal>
</div>
</section>
);
}

View file

@ -1,49 +0,0 @@
import { cn } from "@/lib/utils";
const items = [
"frontmatter",
"subagents",
"claim_task",
"complete_task",
"convoy",
"workspace_id",
"plans/",
"mcp",
"markdown-backlog",
"human-gates",
"model_policy",
"role-implementer",
"role-reviewer",
"role-architect",
] as const;
export function MarqueeStrip({ className, variant = "dark" }: { className?: string; variant?: "dark" | "light" }) {
const track = [...items, ...items];
return (
<div
className={cn(
"overflow-hidden border-y py-3",
variant === "dark"
? "border-[hsl(var(--hero-border))] bg-[hsl(var(--hero))]"
: "border-foreground bg-background",
className,
)}
aria-hidden
>
<div className="marketing-marquee-track flex w-max gap-8 whitespace-nowrap px-4">
{track.map((item, i) => (
<span
key={`${item}-${i}`}
className={cn(
"font-mono-brand text-[11px] uppercase tracking-[0.18em]",
variant === "dark" ? "text-[hsl(var(--hero-muted))]" : "text-muted-foreground",
)}
>
{item}
</span>
))}
</div>
</div>
);
}

View file

@ -1,91 +0,0 @@
"use client";
import { motion, useReducedMotion } from "motion/react";
import { Bot, CheckCircle2, GitBranch, Terminal } from "lucide-react";
import { cn } from "@/lib/utils";
const pipelineSteps = [
{ role: "architect", model: "opus", status: "done" as const },
{ role: "implementer", model: "fast", status: "active" as const },
{ role: "reviewer", model: "fast", status: "queued" as const },
];
export function ProductDemo({ className }: { className?: string }) {
const reduceMotion = useReducedMotion();
return (
<div className={cn("relative overflow-hidden border bg-[hsl(0_0%_6%)]", className)}>
<div className="flex items-center justify-between border-b border-[hsl(var(--hero-border))] px-4 py-3">
<div className="flex gap-2 font-mono-brand text-[10px] uppercase tracking-widest text-[hsl(var(--hero-muted))]">
<Terminal className="size-3.5" aria-hidden />
<span>workspace · convoy</span>
</div>
<span className="inline-flex items-center gap-2 font-mono-brand text-[10px] text-[hsl(var(--agent))]">
<span className="marketing-pulse-dot size-1.5 bg-[hsl(var(--agent))]" />
mcp connected
</span>
</div>
<div className="grid lg:grid-cols-2">
<div className="border-b border-[hsl(var(--hero-border))] p-5 lg:border-b-0 lg:border-r">
<p className="mb-3 font-mono-brand text-[10px] text-[hsl(var(--hero-muted))]">plans/…/Task-*.md</p>
<pre className="font-mono-brand text-[12px] leading-[1.7] text-[hsl(var(--hero-foreground))]">
<span className="text-[hsl(var(--hero-muted))]">---{"\n"}</span>
<span className="text-[hsl(var(--agent))]">kind</span>
<span className="text-[hsl(var(--hero-muted))]">: </span>task{"\n"}
<span className="text-[hsl(var(--agent))]">status</span>
<span className="text-[hsl(var(--hero-muted))]">: </span>
<span className="text-[hsl(var(--matter))]">in_progress</span>
{"\n"}
<span className="text-[hsl(var(--agent))]">priority</span>
<span className="text-[hsl(var(--hero-muted))]">: </span>P1{"\n"}
<span className="text-[hsl(var(--agent))]">model_policy</span>
<span className="text-[hsl(var(--hero-muted))]">: </span>composer-fast{"\n"}
<span className="text-[hsl(var(--hero-muted))]">---</span>
<span className="marketing-cursor-blink text-[hsl(var(--matter))]">█</span>
</pre>
</div>
<div className="p-5">
<div className="mb-4 flex items-center gap-2 font-mono-brand text-[10px] uppercase tracking-widest text-[hsl(var(--hero-muted))]">
<GitBranch className="size-3.5" aria-hidden />
convoy pipeline
</div>
<ol className="space-y-2">
{pipelineSteps.map((step, index) => (
<motion.li
key={step.role}
className={cn(
"flex items-center justify-between border px-3 py-2.5",
step.status === "active"
? "border-[hsl(var(--matter))] bg-[hsl(var(--matter)/0.07)]"
: "border-[hsl(var(--hero-border))]",
)}
initial={reduceMotion ? false : { opacity: 0, x: 16 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 + index * 0.1, duration: 0.4 }}
>
<div className="flex items-center gap-2">
{step.status === "done" ? (
<CheckCircle2 className="size-3.5 text-[hsl(var(--agent))]" aria-hidden />
) : (
<Bot
className={cn(
"size-3.5",
step.status === "active" ? "text-[hsl(var(--matter))]" : "text-[hsl(var(--hero-muted))]",
)}
aria-hidden
/>
)}
<span className="font-mono-brand text-xs">role-{step.role}</span>
</div>
<span className="font-mono-brand text-[10px] text-[hsl(var(--hero-muted))]">{step.model}</span>
</motion.li>
))}
</ol>
</div>
</div>
</div>
);
}

View file

@ -1,90 +0,0 @@
"use client";
import type { ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
type RevealProps = {
children: ReactNode;
className?: string;
delay?: number;
y?: number;
};
export function Reveal({ children, className, delay = 0, y = 24 }: RevealProps) {
const reduceMotion = useReducedMotion();
if (reduceMotion) {
return <div className={className}>{children}</div>;
}
return (
<motion.div
className={className}
initial={{ opacity: 0, y }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-60px" }}
transition={{ duration: 0.55, delay, ease: [0.22, 1, 0.36, 1] }}
>
{children}
</motion.div>
);
}
type StaggerProps = {
children: ReactNode;
className?: string;
stagger?: number;
};
export function Stagger({ children, className, stagger = 0.08 }: StaggerProps) {
const reduceMotion = useReducedMotion();
if (reduceMotion) {
return <div className={className}>{children}</div>;
}
return (
<motion.div
className={className}
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-40px" }}
variants={{
hidden: {},
visible: { transition: { staggerChildren: stagger } },
}}
>
{children}
</motion.div>
);
}
export function StaggerItem({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
const reduceMotion = useReducedMotion();
if (reduceMotion) {
return <div className={className}>{children}</div>;
}
return (
<motion.div
className={className}
variants={{
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.5, ease: [0.22, 1, 0.36, 1] },
},
}}
>
{children}
</motion.div>
);
}

View file

@ -1,64 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
const CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/_-";
type ScrambleTextProps = {
text: string;
className?: string;
durationMs?: number;
};
export function ScrambleText({ text, className, durationMs = 1200 }: ScrambleTextProps) {
const reduceMotion = useReducedMotion();
const [display, setDisplay] = useState(text);
useEffect(() => {
if (reduceMotion) {
setDisplay(text);
return;
}
let frame = 0;
const totalFrames = Math.ceil(durationMs / 32);
const locked = new Set<number>();
const id = window.setInterval(() => {
frame += 1;
const progress = frame / totalFrames;
for (let i = 0; i < text.length; i++) {
if (text[i] === " ") continue;
if (Math.random() < progress * 0.15 + 0.05) locked.add(i);
}
const next = text
.split("")
.map((char, i) => {
if (char === " ") return " ";
if (locked.has(i) || progress > 0.92) return char;
return CHARSET[Math.floor(Math.random() * CHARSET.length)] ?? char;
})
.join("");
setDisplay(next);
if (frame >= totalFrames) {
window.clearInterval(id);
setDisplay(text);
}
}, 32);
return () => window.clearInterval(id);
}, [text, durationMs, reduceMotion]);
return (
<motion.span className={cn("inline-block", className)} initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
{display}
</motion.span>
);
}

View file

@ -1,45 +0,0 @@
import Link from "next/link";
import { Logo } from "@/components/marketing/logo";
import { MarqueeStrip } from "@/components/marketing/marquee-strip";
const footerLinks = [
{ href: "/landing#features", label: "Capabilities" },
{ href: "/landing#manifest", label: "Manifest" },
{ href: "/landing/pricing", label: "Pricing" },
{ href: "/sign-in", label: "Sign in" },
] as const;
export function SiteFooter() {
return (
<footer className="bg-background">
<MarqueeStrip variant="light" />
<div className="mx-auto flex max-w-6xl flex-col gap-10 px-4 py-14 sm:px-6 md:flex-row md:items-start md:justify-between">
<div className="space-y-3">
<Logo size="sm" showTagline variant="light" />
<p className="max-w-xs font-marketing text-sm text-muted-foreground">
Subagents. Frontmatter. One workspace.
</p>
</div>
<nav className="flex flex-wrap gap-x-10 gap-y-3" aria-label="Footer">
{footerLinks.map((item) => (
<Link
key={item.href}
href={item.href}
className="font-mono-brand text-[11px] uppercase tracking-[0.16em] text-muted-foreground transition-colors hover:text-foreground"
>
{item.label}
</Link>
))}
</nav>
</div>
<div className="marketing-hard-rule" aria-hidden />
<div className="mx-auto flex max-w-6xl flex-col gap-2 px-4 py-5 sm:flex-row sm:items-center sm:justify-between sm:px-6">
<p className="font-mono-brand text-[11px] text-muted-foreground">© {new Date().getFullYear()} Submatter</p>
<p className="font-mono-brand text-[11px] text-muted-foreground">v2 · sbmtr.io</p>
</div>
</footer>
);
}

View file

@ -1,53 +0,0 @@
import Link from "next/link";
import { Logo } from "@/components/marketing/logo";
import { cn } from "@/lib/utils";
const nav = [
{ href: "/landing#features", label: "Capabilities" },
{ href: "/landing#pipeline", label: "Pipeline" },
{ href: "/landing#manifest", label: "Manifest" },
{ href: "/landing/pricing", label: "Pricing" },
] as const;
export function SiteHeader({ className }: { className?: string }) {
return (
<header
className={cn(
"sticky top-0 z-50 border-b border-[hsl(var(--hero-border))] bg-[hsl(var(--hero)/0.92)] text-[hsl(var(--hero-foreground))] backdrop-blur-sm",
className,
)}
>
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between gap-6 px-4 sm:px-6">
<Logo size="sm" variant="dark" />
<nav className="hidden items-center gap-8 md:flex" aria-label="Main">
{nav.map((item) => (
<Link
key={item.href}
href={item.href}
className="font-mono-brand text-[11px] uppercase tracking-[0.16em] text-[hsl(var(--hero-muted))] transition-colors hover:text-[hsl(var(--hero-foreground))]"
>
{item.label}
</Link>
))}
</nav>
<div className="flex items-center gap-4">
<Link
href="/sign-in"
className="hidden font-mono-brand text-[11px] uppercase tracking-[0.16em] text-[hsl(var(--hero-muted))] transition-colors hover:text-[hsl(var(--hero-foreground))] sm:inline"
>
Sign in
</Link>
<Link
href="/sign-up"
className="inline-flex h-9 items-center border border-[hsl(var(--matter))] px-4 font-mono-brand text-[11px] uppercase tracking-[0.14em] text-[hsl(var(--matter))] transition-colors hover:bg-[hsl(var(--matter))] hover:text-[hsl(var(--hero))]"
>
Start
</Link>
</div>
</div>
</header>
);
}

View file

@ -1,130 +0,0 @@
"use client";
import { BriefSummary } from "@tasks/shared/orchestrator";
interface ConvoyDagProps {
briefs: Array<{
id: string;
briefNumber: number;
sliceDependencies: number[];
status: string | null;
}>;
completedBriefIds: Set<string>;
}
export function ConvoyDag({ briefs, completedBriefIds }: ConvoyDagProps) {
if (briefs.length === 0) return null;
const briefByNum = new Map(briefs.map((b) => [b.briefNumber, b]));
const nodeSize = 60;
const spacing = 180;
const startX = 80;
const startY = 80;
const getPosition = (num: number) => ({
x: startX + ((num - 1) % 4) * spacing,
y: startY + Math.floor((num - 1) / 4) * 140,
});
const briefsSorted = [...briefs].sort((a, b) => a.briefNumber - b.briefNumber);
return (
<div className="w-full h-[300px] overflow-auto bg-muted/30 rounded-lg p-4 relative">
<svg
className="w-full h-full"
style={{ width: "100%", height: "100%" }}
viewBox="0 0 800 600"
>
<defs>
<marker
id="arrowhead"
markerWidth="10"
markerHeight="7"
refX="9"
refY="3.5"
orient="auto"
>
<path d="M0,0 L10,3.5 L0,7 Z" fill="currentColor" />
</marker>
</defs>
{briefsSorted.map((brief) => {
const pos = getPosition(brief.briefNumber);
const isComplete = completedBriefIds.has(brief.id);
const isEligible = !isComplete && brief.sliceDependencies.every((dep) => {
const depBrief = briefByNum.get(dep);
return depBrief && completedBriefIds.has(depBrief.id);
});
brief.sliceDependencies.forEach((depNum) => {
const dep = briefByNum.get(depNum);
if (dep) {
const depPos = getPosition(depNum);
return (
<line
key={`edge-${depNum}-${brief.briefNumber}`}
x1={depPos.x + nodeSize}
y1={depPos.y + nodeSize / 2}
x2={pos.x}
y2={pos.y + nodeSize / 2}
stroke="currentColor"
strokeWidth={2}
markerEnd="url(#arrowhead)"
opacity={0.4}
/>
);
}
});
return (
<g key={`node-${brief.briefNumber}`} transform={`translate(${pos.x}, ${pos.y})`}>
<rect
width={nodeSize}
height={nodeSize}
rx={8}
fill={isComplete ? "var(--green-3)" : isEligible ? "var(--blue-3)" : "var(--gray-4)"}
stroke={isComplete ? "var(--green-7)" : isEligible ? "var(--blue-7)" : "var(--gray-7)"}
strokeWidth={2}
/>
<text
x={nodeSize / 2}
y={nodeSize / 2 + 4}
textAnchor="middle"
dominantBaseline="middle"
fill="var(--white)"
fontSize={14}
fontWeight={600}
>
B{brief.briefNumber}
</text>
{isComplete && (
<circle
cx={nodeSize - 12}
cy={12}
r={10}
fill="var(--green-7)"
/>
)}
</g>
);
})}
</svg>
<p className="mt-4 text-xs text-muted-foreground">
Green = Complete | Blue = Ready | Gray = Blocked
</p>
</div>
);
}
function getPosition(num: number) {
const nodeSize = 60;
const spacing = 180;
const startX = 80;
const startY = 80;
return {
x: 80 + ((num - 1) % 4) * 180,
y: 80 + Math.floor((num - 1) / 4) * 140,
};
}

View file

@ -1,298 +0,0 @@
"use client";
import * as React from "react";
import type { ConvoyStatus } from "@tasks/shared/convoy-lifecycle";
import { Loader2, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { api } from "@/lib/trpc";
function gateActionsForStatus(status: ConvoyStatus): Array<{
action: "approve" | "revise" | "request_changes" | "promote";
label: string;
variant?: "default" | "outline" | "destructive";
}> {
switch (status) {
case "convoy_review":
return [
{ action: "approve", label: "Approve plan" },
{ action: "revise", label: "Request changes", variant: "outline" },
];
case "pr_review":
return [
{ action: "approve", label: "Approve merge" },
{ action: "request_changes", label: "Request changes", variant: "outline" },
];
case "merged":
return [{ action: "promote", label: "Promote to prod" }];
default:
return [];
}
}
export function ConvoyDetailPanel({
workspaceSlug,
convoyId,
}: {
workspaceSlug: string;
convoyId: string | null;
}) {
const utils = api.useUtils();
const [copilotText, setCopilotText] = React.useState<string | null>(null);
const [noteBody, setNoteBody] = React.useState("");
const [noteDelivery, setNoteDelivery] = React.useState<"queued" | "direct">("queued");
const detailQuery = api.convoys.get.useQuery(
{ workspace: workspaceSlug, convoyId: convoyId ?? "" },
{ enabled: Boolean(workspaceSlug && convoyId) },
);
const gateMut = api.convoys.gateAction.useMutation({
onSuccess: () => {
void utils.convoys.kanban.invalidate();
if (convoyId) void utils.convoys.get.invalidate({ workspace: workspaceSlug, convoyId });
},
});
const copilotMut = api.convoys.gateCopilot.useMutation({
onSuccess: (result) => setCopilotText(result.text),
});
const operatorNotesQuery = api.convoys.listOperatorNotes.useQuery(
{ workspace: workspaceSlug, convoyId: convoyId ?? "" },
{ enabled: Boolean(workspaceSlug && convoyId) },
);
const postNoteMut = api.convoys.postOperatorNote.useMutation({
onSuccess: () => {
setNoteBody("");
void operatorNotesQuery.refetch();
},
});
if (!convoyId) {
return (
<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>
);
}
if (detailQuery.isLoading) {
return (
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading convoy…
</div>
);
}
const convoy = detailQuery.data?.convoy;
if (!convoy) {
return <p className="p-6 text-sm text-muted-foreground">Convoy not found.</p>;
}
const status = convoy.status as ConvoyStatus;
const actions = gateActionsForStatus(status);
return (
<div className="flex h-full min-h-0 flex-col overflow-y-auto p-4">
<div className="mb-4">
<h2 className="text-lg font-semibold">
{convoy.content?.convoySlug ?? convoy.title}
</h2>
<p className="text-xs text-muted-foreground">Status: {status}</p>
{convoy.content?.successMetric && (
<p className="mt-2 text-sm text-muted-foreground">{convoy.content.successMetric}</p>
)}
</div>
<div className="mb-4 rounded-md border border-border bg-muted/30 p-3 text-sm">
<p className="mb-1 text-xs font-medium uppercase text-muted-foreground">
Worker checkpoints
</p>
<p>
{detailQuery.data?.checkpoints.count ?? 0} checkpoint
{(detailQuery.data?.checkpoints.count ?? 0) === 1 ? "" : "s"}
{detailQuery.data?.checkpoints.lastOccurredAt
? ` · last ${new Date(detailQuery.data.checkpoints.lastOccurredAt).toLocaleString()}`
: ""}
</p>
</div>
{detailQuery.data?.pr?.prUrl && (
<div className="mb-4 rounded-md border border-border bg-muted/30 p-3 text-sm">
<p className="mb-1 text-xs font-medium uppercase text-muted-foreground">PR</p>
<a
href={detailQuery.data.pr.prUrl}
target="_blank"
rel="noreferrer"
className="break-all text-primary underline"
>
{detailQuery.data.pr.prUrl}
{typeof detailQuery.data.pr.prNumber === "number" && detailQuery.data.pr.prNumber
? ` (#${detailQuery.data.pr.prNumber})`
: ""}
</a>
</div>
)}
{convoy.description && (
<div className="mb-4 rounded-md border border-border bg-muted/30 p-3">
<p className="mb-1 text-xs font-medium uppercase text-muted-foreground">Plan</p>
<pre className="whitespace-pre-wrap text-sm">{convoy.description}</pre>
</div>
)}
{actions.length > 0 && (
<div className="mb-4 flex flex-wrap gap-2">
{actions.map((item) => (
<Button
key={item.action}
type="button"
size="sm"
variant={item.variant ?? "default"}
disabled={gateMut.isPending}
onClick={() =>
gateMut.mutate({
workspace: workspaceSlug,
convoyId,
action: item.action,
})
}
>
{gateMut.isPending ? <Loader2 className="size-4 animate-spin" /> : item.label}
</Button>
))}
</div>
)}
<div className="mb-4 rounded-md border border-border p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="flex items-center gap-1 text-sm font-medium">
<Sparkles className="size-4 text-primary" />
Gate co-pilot
</p>
<Button
type="button"
size="sm"
variant="outline"
disabled={copilotMut.isPending}
onClick={() =>
copilotMut.mutate({
workspace: workspaceSlug,
convoyId,
})
}
>
{copilotMut.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Summarize"
)}
</Button>
</div>
{copilotMut.data && (
<p className="mb-2 text-[10px] text-muted-foreground">
Model: {copilotMut.data.resolvedAlias} ({copilotMut.data.modelId})
</p>
)}
{copilotMut.error && (
<p className="text-xs text-destructive">{copilotMut.error.message}</p>
)}
{copilotText && (
<div className="prose prose-sm dark:prose-invert max-w-none text-sm">
<p className="whitespace-pre-wrap">{copilotText}</p>
</div>
)}
</div>
{(detailQuery.data?.briefs.length ?? 0) > 0 && (
<div className="mb-4">
<p className="mb-2 text-xs font-medium uppercase text-muted-foreground">Briefs</p>
<ul className="space-y-1 text-sm">
{detailQuery.data?.briefs.map((brief) => (
<li key={brief.id} className="rounded border border-border px-2 py-1">
{brief.title}{" "}
<span className="text-muted-foreground">({brief.status ?? "—"})</span>
</li>
))}
</ul>
</div>
)}
{status === "in_progress" && convoyId && (
<div className="rounded-md border border-border p-3">
<p className="mb-2 text-xs font-medium uppercase text-muted-foreground">
Operator notes — in progress thread
</p>
<p className="mb-2 text-[11px] text-muted-foreground">
Distinct from gate co-pilot. Queued notes inject at next tool round; direct messages prepend before next model call.
</p>
<div className="mb-3 space-y-2 max-h-64 overflow-y-auto">
{(operatorNotesQuery.data ?? []).length === 0 ? (
<p className="text-xs text-muted-foreground">No operator messages yet.</p>
) : (
(operatorNotesQuery.data ?? []).map((note) => (
<div key={note.id} className="rounded border border-border bg-muted/20 p-2 text-xs">
<div className="mb-1 flex items-center gap-2">
<span
className={
note.delivery === "direct"
? "rounded bg-primary/10 px-1.5 py-0.5 font-medium text-primary"
: "rounded bg-muted px-1.5 py-0.5 font-medium"
}
>
{note.delivery}
</span>
<span className="text-muted-foreground">{new Date(note.createdAt).toLocaleString()}</span>
{note.deliveredAt && (
<span className="text-green-600">delivered {new Date(note.deliveredAt).toLocaleString()}</span>
)}
</div>
<p className="whitespace-pre-wrap text-sm">{note.body}</p>
<p className="mt-1 text-[10px] text-muted-foreground">author {note.authorUserId.slice(0, 8)}</p>
</div>
))
)}
</div>
<div className="space-y-2">
<textarea
className="min-h-[72px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder="Queued note: use existing auth middleware"
value={noteBody}
onChange={(e) => setNoteBody(e.target.value)}
maxLength={2000}
/>
<div className="flex items-center justify-between gap-2">
<select
className="rounded-md border border-input bg-background px-2 py-1 text-xs"
value={noteDelivery}
onChange={(e) => setNoteDelivery(e.target.value as "queued" | "direct")}
>
<option value="queued">Queued note</option>
<option value="direct">Direct message</option>
</select>
<Button
type="button"
size="sm"
disabled={postNoteMut.isPending || !noteBody.trim()}
onClick={() =>
postNoteMut.mutate({
workspace: workspaceSlug,
convoyId,
delivery: noteDelivery,
body: noteBody.trim(),
})
}
>
{postNoteMut.isPending ? <Loader2 className="size-4 animate-spin" /> : "Send"}
</Button>
</div>
{postNoteMut.error && <p className="text-xs text-destructive">{postNoteMut.error.message}</p>}
</div>
</div>
)}
</div>
);
}

View file

@ -1,128 +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 } from "lucide-react";
import { cn } from "@/lib/utils";
import { api } from "@/lib/trpc";
type ConvoyCard = {
id: string;
title: string;
status: ConvoyStatus;
content: { convoySlug: string; classification: string; successMetric: string } | null;
};
export function ConvoyKanban({
workspaceSlug,
selectedId,
onSelect,
}: {
workspaceSlug: string;
selectedId: string | null;
onSelect: (id: string) => void;
}) {
const kanbanQuery = api.convoys.kanban.useQuery(
{ workspace: workspaceSlug },
{ enabled: Boolean(workspaceSlug) },
);
if (kanbanQuery.isLoading) {
return (
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading kanban…
</div>
);
}
const columns = kanbanQuery.data?.columns ?? [];
return (
<div className="flex min-h-0 flex-1 gap-3 overflow-x-auto p-4">
{columns.map((column) => (
<div
key={column.status}
className="flex w-56 shrink-0 flex-col rounded-lg border border-border bg-muted/20"
>
<div className="border-b border-border px-3 py-2">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{column.label}
</p>
<p className="text-[10px] text-muted-foreground">{column.convoys.length}</p>
</div>
<ul className="flex flex-1 flex-col gap-2 overflow-y-auto p-2">
{column.convoys.map((convoy) => (
<li key={convoy.id}>
<button
type="button"
onClick={() => onSelect(convoy.id)}
className={cn(
"w-full rounded-md border border-border bg-background p-2 text-left text-sm transition-colors hover:bg-muted",
selectedId === convoy.id && "ring-2 ring-primary",
)}
>
<p className="font-medium">{convoy.content?.convoySlug ?? convoy.title}</p>
{convoy.content?.classification && (
<p className="mt-0.5 text-[10px] text-muted-foreground">
{convoy.content.classification}
</p>
)}
</button>
</li>
))}
</ul>
</div>
))}
</div>
);
}
export function GateInbox({
workspaceSlug,
selectedId,
onSelect,
}: {
workspaceSlug: string;
selectedId: string | null;
onSelect: (id: string) => void;
}) {
const kanbanQuery = api.convoys.kanban.useQuery(
{ workspace: workspaceSlug },
{ enabled: Boolean(workspaceSlug) },
);
const inbox = kanbanQuery.data?.gateInbox ?? [];
return (
<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 1, 2, and 3 approvals</p>
{inbox.length === 0 ? (
<p className="mt-3 text-sm text-muted-foreground">No pending gates.</p>
) : (
<ul className="mt-3 space-y-2">
{inbox.map((convoy: ConvoyCard) => (
<li key={convoy.id}>
<button
type="button"
onClick={() => onSelect(convoy.id)}
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",
selectedId === 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)}
</p>
</button>
</li>
))}
</ul>
)}
</div>
);
}

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