Compare commits

..

2 commits

Author SHA1 Message Date
Randall Stillwell
7c00b5bb58 feat(agents): install L2 role files referenced by .agent-context-manifest.yml
Adds the 9 subagent role definitions from agent-pipeline v0.5.0 that
.agent-context-manifest.yml already tracks by sha256. Files were installed
by bootstrap-agent-context during Phase 0 but missed in the preceding
bridge commit.

All 9 hashes verified against the manifest. Pipeline-version drift against
agent-pipeline's freshly-landed Phase 1a slim versions of the 3 audit roles
+ Phase 2a MCP-patched conductor/architect will be detected by the
agent-context-drift workflow on the next scheduled run — that's the intended
sync-agent-context loop, not a regression.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 21:00:30 -05:00
Randall Stillwell
dab939bcd5 feat(mcp): agent-pipeline bridge — 6 lifecycle tools + convoy_events + L1/L3 scaffolding
Implements the Phase 2a Echodo bridge described in agent-pipeline's
v0.4 plan (.cursor/plans/pipeline_v0.4_design_+_echodo_54a3bdb7). Echodo
becomes the projection layer over the local .convoys/ tree; local files
remain source of truth per the local-first contract.

MCP lifecycle tools (apps/mcp-server/src/tools/):
- create-convoy.ts: registers create_convoy. Creates a Drizzle `project`
  object with status="draft" + appends initial "## Status log" to the
  description. Takes workspace_slug + slug + title + classification +
  skip_flags + success_metric + idea_markdown + repo.
- create-brief.ts: registers create_brief. Creates a `task` child of the
  convoy project. Takes convoyId + briefNumber + title + files_allowlist
  + depends_on + acceptance_criteria + brief_markdown.
- transition-convoy-status.ts: registers transition_convoy_status. Enforces
  the 9-status state machine from plan §7.3 with valid transitions +
  actor-permission gates. Appends an audit entry to the description's
  ## Status log per transition.
- log-convoy-event.ts: registers log_convoy_event. Inserts events into the
  new convoy_events table (one row per role hand-off, with classification,
  skip-flags, duration, stack-class, outcome, multitask-group metadata).
- query-manifest-status.ts: registers query_manifest_status (stub —
  depends on the Phase 4 pipeline_drift_reports table; documented).
- reconcile-from-files.ts: registers reconcile_from_files implementing the
  local-first recovery path. Reads .convoys/.pending-mcp-sync.jsonl from
  the given repoPath, replays queued log_convoy_event + transition_convoy_status
  calls, updates the outbox file on success/failure. Resolves the SPOF risk:
  failed MCP calls during offline windows reconcile when the bridge returns.

Database (packages/database/):
- src/schema/convoy_events.ts: new Drizzle schema. Columns: id, workspaceId,
  convoyId, convoySlug, role, brief, classification, skipFlags, durationS,
  stackClass, repo, outcome, multitaskGroup, metadata, ts. 4 indexes for
  per-workspace + per-convoy + role-filtered reads.
- src/schema/index.ts: re-exports the new table.
- migrations/0010_wandering_the_professor.sql + meta snapshot: generated
  via drizzle-kit generate. Pure-additive (CREATE TABLE + indexes + FKs).
- package.json: db:migrate / db:push / db:studio now use node --env-file
  to load ../../.env (consistent with the existing mcp-server tsx pattern).
  db:generate stays as-is (offline operation, no env needed).

L1 + L3 agent-pipeline scaffolding installed per
agent-pipeline/skills/bootstrap-agent-context v0.5.0:
- .agent-context-manifest.yml: tracks 17 artifacts at pipeline version
  0.5.0 with sha256 hashes. 4 artifacts flagged customized:true (no-go-zones,
  CODEOWNERS, pr-health-rollup.yml, echodo.config.json) — adapted from
  templates for Echodo's monorepo + Drizzle + Coolify + workspace_slug.
- .convoys/README.md: explains convoy file convention.
- .cursor/agents/echodo.config.json: workspace_slug=convoys-tasks (the
  workspace created in Echodo UI). Fallback policy: local-only.
- .cursor/rules/no-go-zones.mdc: adapted for Drizzle migrations, Coolify
  deploy infra, mcp-server boundaries.
- .github/CODEOWNERS: @rstillw as sole maintainer; targeted rules for
  apps/, packages/, auth, deploy infra, DB schema, MCP server, agent
  context.
- .github/PULL_REQUEST_TEMPLATE.md: pipeline PR template.
- .github/workflows/agent-context-drift.yml: drift monitor against upstream
  agent-pipeline.
- .github/workflows/pr-health-rollup.yml: adapted for tasks' pnpm monorepo
  + Coolify deploy (no per-PR preview by default).
- scripts/log-convoy-event.sh: convoy event logger shim.
- scripts/wt.sh: worktree helper stub (deprecated — points at Cursor 3.2
  native worktrees).
- .gitignore: excludes .convoys/.metrics.jsonl + .convoys/.pending-mcp-sync.jsonl
  (local agent analytics + MCP outbox).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 20:59:45 -05:00
443 changed files with 2964 additions and 92962 deletions

135
.agent-context-manifest.yml Normal file
View file

@ -0,0 +1,135 @@
# .agent-context-manifest.yml
#
# Generated by agent-pipeline bootstrap-agent-context skill (manual run; 2026-06-05).
# Tracks which artifacts the bootstrap installed in this repo, where they
# came from, and what pipeline version they correspond to.
#
# Read by the `sync-agent-context` skill to detect drift and propose updates.
# Don't edit by hand — use the bootstrap or sync skill in Cursor.
#
# Schema: https://github.com/varutasu/agent-pipeline/blob/main/docs/manifest-schema.md
#
# Notes for `tasks` specifically:
# - AGENTS.md is hand-curated (161 lines, pre-dates pipeline install) — NOT tracked
# - 4 stack rules (database.mdc, markdown-backlog.mdc, repo-overview.mdc, web-app.mdc)
# pre-date the pipeline and are hand-curated — NOT tracked
# - .cursor/mcp.json is per-repo MCP config — NOT tracked
# - .github/workflows/ci.yml was kept (better than template — pnpm + monorepo +
# concurrency + branches:[main]). NOT tracked (so sync won't suggest replacing it
# with the upstream template, which is npm-based and worse for this repo).
# - 3 artifacts are adapted (locally customized at install time) — flagged below.
# - L3 variant gap: no `nextjs-coolify/` template exists in the pipeline yet.
# pr-health-rollup.yml was adapted from `nextjs-prisma-coolify/`; CODEOWNERS from
# `nextjs/`. Filed as an open question in agent-pipeline/docs/CONSUMERS.md.
schema_version: 1
pipeline_version: "0.5.0"
pipeline_source: "https://github.com/varutasu/agent-pipeline"
installed_at: "2026-06-05T21:51:00Z"
last_synced_at: "2026-06-05T21:51:00Z"
layers:
- L1
- L2
- L3
artifacts:
# L1 — only the always-apply no-go-zones rule is pipeline-managed.
# AGENTS.md and the 4 stack rules in .cursor/rules/ are hand-curated.
- path: ".cursor/agents/echodo.config.json"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/_common/echodo-config.json.template"
version: "0.5.0"
installed_hash: "sha256:68207b3fc05d266a55fb2056610d2db94d5a2d083d360f6811786476451ae01c"
customized: true # filled in with workspace_slug=convoys-tasks + tasks-specific transport reference
- path: ".cursor/rules/no-go-zones.mdc"
source: "skills/bootstrap-agent-context/templates/L1-context/no-go-zones.mdc"
version: "0.5.0"
installed_hash: "sha256:e042bfbb6a26806118e684b684fb6223b4c2fc19309ae9daafaa4fffe1680db3"
customized: true # adapted at install time for Echodo's monorepo + Drizzle + Coolify
# L2 — 9 roles, copied verbatim.
- path: ".cursor/agents/role-a11y-auditor.md"
source: "skills/bootstrap-agent-context/templates/L2-roles/role-a11y-auditor.md"
version: "0.5.0"
installed_hash: "sha256:a59938deceb0246ebd7e477f1f9a442102f9fcbb81b0364f0ddc5f86e95a7930"
- path: ".cursor/agents/role-architect.md"
source: "skills/bootstrap-agent-context/templates/L2-roles/role-architect.md"
version: "0.5.0"
installed_hash: "sha256:269bd62af1557c5d353a9f95a613960e3434be4ec6e0c0b5f6b099adf6872044"
- path: ".cursor/agents/role-conductor.md"
source: "skills/bootstrap-agent-context/templates/L2-roles/role-conductor.md"
version: "0.5.0"
installed_hash: "sha256:bc75a3e6646217a015f7bb60c3610afd9b57ae91c7d2fc7a7971f4709b19368a"
- path: ".cursor/agents/role-design-system-auditor.md"
source: "skills/bootstrap-agent-context/templates/L2-roles/role-design-system-auditor.md"
version: "0.5.0"
installed_hash: "sha256:d214cecb1e8482fc24f2815c8220c860191f08526614f89cf9a5797e4ee9110a"
- path: ".cursor/agents/role-doc-writer.md"
source: "skills/bootstrap-agent-context/templates/L2-roles/role-doc-writer.md"
version: "0.5.0"
installed_hash: "sha256:d4e8bf8cee93153506b7b742848462422dbe5cc7fd012c62f6ffd50460e344d4"
- path: ".cursor/agents/role-ia-architect.md"
source: "skills/bootstrap-agent-context/templates/L2-roles/role-ia-architect.md"
version: "0.5.0"
installed_hash: "sha256:69685a3a407c4ee25e2606d426c3107d6b917abee80f907e16ade4a16b439839"
- path: ".cursor/agents/role-implementer.md"
source: "skills/bootstrap-agent-context/templates/L2-roles/role-implementer.md"
version: "0.5.0"
installed_hash: "sha256:b4f4d8596068679b90ffc3a2b6d2e1b6548caf8c68a50f7ed640ba8f638c1c4c"
- path: ".cursor/agents/role-reviewer.md"
source: "skills/bootstrap-agent-context/templates/L2-roles/role-reviewer.md"
version: "0.5.0"
installed_hash: "sha256:1ff38349321402a0ac2be37878dc2c0bcab62e54caf74c422b919aa6d75f9b67"
- path: ".cursor/agents/role-ux-reviewer.md"
source: "skills/bootstrap-agent-context/templates/L2-roles/role-ux-reviewer.md"
version: "0.5.0"
installed_hash: "sha256:3a1d4b66981f469b15e23a1cd34ab41352759966179e126b3d56ddc1eca4a03e"
# L3 — pipeline scaffolding. NOT included: .github/workflows/ci.yml (kept the
# repo's own, which is better than the template), tests/smoke (Echodo uses
# vitest), lib/flags/index.ts (not used yet).
- path: ".convoys/README.md"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/_common/convoys-readme.md.template"
version: "0.5.0"
installed_hash: "sha256:a48548cd3f5d0c40fc179106890661c3be5fcdc13eb705af7cfe9233e0b8b209"
- path: ".github/CODEOWNERS"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/nextjs/CODEOWNERS.template"
version: "0.5.0"
installed_hash: "sha256:8d7d3388f61cbfe2cdc6ae759ea944a4b60cf896f0b6e11c6946dc84ee3f1bf9"
customized: true # adapted for Echodo's apps/packages monorepo layout
- path: ".github/PULL_REQUEST_TEMPLATE.md"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/_common/PULL_REQUEST_TEMPLATE.md.template"
version: "0.5.0"
installed_hash: "sha256:89863e58b9ec194aef1c94d3596e892467833e8bc880a28994acca401b6d9635"
- path: ".github/workflows/agent-context-drift.yml"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/_common/agent-context-drift.yml.template"
version: "0.5.0"
installed_hash: "sha256:5505c296c1b61d023ee2aca222103097e2b5ed2e0e38da3679cc4f9754457785"
- path: ".github/workflows/pr-health-rollup.yml"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/nextjs-prisma-coolify/pr-health-rollup.yml.template"
version: "0.5.0"
installed_hash: "sha256:91472074a0906270f0cfaa7caa6fa2d4812de9281c0d35aa3f5ed368d6d0a72e"
customized: true # adapted for one-job CI (vs two-job template) and branches:[main]
- path: "scripts/log-convoy-event.sh"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/_common/log-convoy-event.sh"
version: "0.5.0"
installed_hash: "sha256:cd0413691066a177b6b4e6164a9a0978c20a853ad60222ae833b5d53b255818d"
- path: "scripts/wt.sh"
source: "skills/bootstrap-agent-context/templates/L3-pipeline/_common/wt.sh"
version: "0.5.0"
installed_hash: "sha256:2a4f44a159f80a8ea6fe53ac507c01a2f91a4e2118d997a98b051808ac35e9a5"

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,26 +1,22 @@
# Convoys (Echodo / tasks repo) # Convoys
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. A **convoy** is a multi-PR work-stream coordinated by an agent pipeline. One convoy = one feature, bug fix, or epic. 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 ## File layout
```text ```
.convoys/ .convoys/
├── README.md (this file) ├── README.md (this file)
├── .metrics.jsonl (local analytics — gitignored) ├── <slug>.md (the convoy file — written by role-conductor)
├── <slug>.md (convoy file — role-conductor)
└── <slug>/ └── <slug>/
├── brief-1-<kebab-title>.md (role-architect) ├── brief-1-<kebab-title>.md (written by role-architect)
├── brief-2-<kebab-title>.md
└── ... └── ...
``` ```
## Convoy file format ## Convoy file format
Frontmatter (set by `role-conductor`, appended by other roles): Frontmatter (set by `role-conductor`, then appended-to by other roles):
```yaml ```yaml
--- ---
@ -28,38 +24,33 @@ name: <kebab-slug>
classification: feature | hotfix | docs-only | infra-only | server-only | config-only classification: feature | hotfix | docs-only | infra-only | server-only | config-only
success_metric: <one sentence> success_metric: <one sentence>
skip: skip:
- ia - <flag1>
status: open | in-progress | merged | shipped | abandoned status: open | in-progress | merged | shipped | abandoned
created: <YYYY-MM-DD> 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 (added in order by the pipeline roles):
Body sections (typical order):
1. `## Why` (Conductor) 1. `## Why` (Conductor)
2. `## Scope` (Conductor) 2. `## Scope` (Conductor)
3. `## Roles invoked` (Conductor) 3. `## Roles invoked` (Conductor)
4. `## Todos` (Conductor → Architect) 4. `## Todos` (Conductor → refined by Architect)
5. `## IA` (IA Architect — skip if `ia` in `skip:`) 5. `## IA` (IA Architect)
6. `## UX` (UX Reviewer) 6. `## UX` (UX Reviewer)
7. `## Architecture` (Architect) 7. `## Architecture` (Architect)
After Architect, briefs live in `.convoys/<slug>/brief-N-*.md`. Implementers read only their brief. After Architect, briefs live in `.convoys/<slug>/brief-N-*.md`. Implementers read only their brief, not the whole convoy.
## Skip flags ## Skip flags
The Conductor sets `skip:` based on classification. These flags map to pipeline stages that no-op when set:
| Flag | Skips | | Flag | Skips |
| --- | --- | | --- | --- |
| `ia` | IA Architect | | `ia` | IA Architect |
| `ux` | UX Reviewer | | `ux` | UX Reviewer |
| `arch` | Architect (rare — use only for trivial hotfixes) | | `arch` | Architect |
| `test` | Component tests | | `test` | Component tests |
| `review` | Reviewer | | `review` | Reviewer |
| `visual` | Visual diff | | `visual` | Visual diff |
@ -70,54 +61,61 @@ After Architect, briefs live in `.convoys/<slug>/brief-N-*.md`. Implementers rea
| `docs` | Doc Writer | | `docs` | Doc Writer |
| `flag` | Flag rollout | | `flag` | Flag rollout |
Never skipped (human gates): `plan-approval`, `pr-merge`, `prod-promote`. Never skipped (mandatory 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 ## Status lifecycle
- `open` — convoy created; no briefs in flight - `open` — Conductor created the convoy; no work started.
- `in-progress` — at least one brief active or PR open - `in-progress` — At least one brief has an open or merged PR.
- `merged` — all briefs merged; release pending - `merged` — All briefs merged to umbrella; release PR to develop pending.
- `shipped` — in production - `shipped` — Release to main complete; flag rollout (if any) underway.
- `abandoned` — closed without shipping - `abandoned` — Convoy closed without shipping; reason in convoy body.
## Kickoff ritual (this repo) Update status by editing the convoy frontmatter as you progress.
1. Open Cursor in the `tasks` repo on a feature branch. ## Adding a new convoy
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. 1. Open Cursor in this repo.
2. Prompt: *"Start a new convoy: <one-paragraph idea>. Success = <metric>."*
3. The `role-conductor` subagent writes `.convoys/<slug>.md`.
4. Run subsequent roles in order per the convoy's `Roles invoked` list.
**Next convoys:** orchestrator (`Epic-apps-orchestrator`), convoy lifecycle MCP (`Epic-convoy-lifecycle-mcp`), connected repos (`Epic-connected-repos`). See `.cursor/agents/role-conductor.md` for the Conductor's full spec.
## Multitask + worktrees (Cursor 3.2+)
[Cursor 3.2 (Apr 24, 2026)](https://cursor.com/changelog/04-24-26) added `/multitask` async subagents and native worktree management in the Agents Window. The pipeline uses both:
**Audit fan-out** — after an implementer ships a PR draft:
```
/multitask role-reviewer + role-design-system-auditor + role-a11y-auditor
```
All three read the same diff and emit independent comments. Use group id `audit-<convoy>-<pr>` so analytics can compute wall-clock savings.
**Implementer fleet** — after architect's plan is approved (gate 1), if `slice_dependencies:` declares parallel-safe briefs (`depends_on: []`, disjoint `files:`):
```
/multitask role-implementer briefs 1, 2, 3
```
Use Cursor's Agents Window to create a worktree per brief — one click each. The legacy `scripts/wt.sh` is now a deprecation stub.
See the [multitask playbook](https://github.com/varutasu/agent-pipeline/blob/main/docs/multitask-playbook.md) for the full guardrail set.
## Self-analytics ## Self-analytics
Each L2 role appends one event via: Each L2 role appends one event to `.convoys/.metrics.jsonl` via `scripts/log-convoy-event.sh`. The file is gitignored by default — events stay local. To opt-in to commit team-shared metrics, remove `.convoys/.metrics.jsonl` from `.gitignore`.
Aggregate across repos and render a dashboard with the [agent-pipeline analytics scripts](https://github.com/varutasu/agent-pipeline/tree/main/analytics):
```bash ```bash
bash scripts/log-convoy-event.sh role=role-implementer convoy=<slug> brief=1 \ cd ~/code/agent-pipeline/analytics
duration_s=120 model=qwen-coder model_tier=local outcome=complete npx tsx analyze-convoys.ts <repo-path> [<repo-path>...]
npx tsx render-dashboard.ts
open ~/agent-pipeline-data/dashboard.html
``` ```
`.convoys/.metrics.jsonl` is **gitignored** by default (metadata only, no prompts). Remove that line from `.gitignore` if you want team-visible metrics in git. Schema: [`analytics/schemas/convoy-event.json`](https://github.com/varutasu/agent-pipeline/blob/main/analytics/schemas/convoy-event.json).
Validate a line was written:
```bash
bash scripts/log-convoy-event.sh role=role-conductor convoy=test duration_s=1
tail -1 .convoys/.metrics.jsonl | python3 -m json.tool
```
## Multitask (Cursor 3.2+)
Use `/multitask` for audit fan-out (`role-reviewer` + design + a11y) or parallel implementers when architect marks briefs as disjoint. Set `multitask_group=audit-<convoy>-<pr>` on log lines for wall-clock analytics.
## Related docs
- [Plan automation foundation](../plans/Plan-automation-foundation/Plan-automation-foundation.md)
- [ADR model routing](../docs/adr-model-routing.md)
- [AGENTS.md](../AGENTS.md) — repo commands and multitenancy rules

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,25 @@
{
"$schema": "https://agent-pipeline.varutasu.dev/schemas/echodo-config.v1.json",
"_comment": "Per-repo Echodo bridge config for the tasks repo (Echodo's own dogfood). Read by L2 role files to know which Echodo workspace this repo's convoys live in and how to behave when MCP is unreachable. The MCP server registration itself lives in .cursor/mcp.json (already configured for stdio + pnpm-filter dev path).",
"workspace_slug": "convoys-tasks",
"_workspace_note": "This is the workspace you created in Echodo at echodo.stillwell.cloud as part of Action 1 (Phase 0). Roles call create_convoy / create_brief / transition_convoy_status against this workspace.",
"fallback": {
"on_unreachable": "local-only",
"on_error": "local-only-with-warning"
},
"_fallback_semantics": [
"If MCP is unreachable when a role attempts create_convoy / create_brief / transition_convoy_status:",
" - The role still writes .convoys/<slug>.md and brief files locally (files are source of truth — see §7.6 of the v0.4 plan).",
" - The failed call is appended to .convoys/.pending-mcp-sync.jsonl as one JSON line.",
" - On the next reachable session, calling the MCP tool reconcile_from_files drains the outbox."
],
"mcp_transport_reference": {
"_note": "Informational only — Cursor reads .cursor/mcp.json, not this block. Kept here so a future MCP host (Phase 4 workers, CI runners) has a single source of truth for transport config.",
"transport": "stdio",
"command": "pnpm",
"args": ["-s", "--filter", "@tasks/mcp-server", "mcp"],
"env": {
"DATABASE_URL": "${ECHODO_DATABASE_URL}"
}
}
}

View file

@ -0,0 +1,105 @@
---
name: role-a11y-auditor
description: >-
Accessibility audit on a UI diff. Checks for missing labels, keyboard
navigation, focus management, color contrast, semantic HTML, and ARIA
correctness. Read-only. Use after the implementer's PR draft on PRs that
touch UI files. Does not require a browser MCP — works from the diff +
static analysis. Safe to run in parallel with role-reviewer +
role-design-system-auditor via Cursor 3.2 /multitask.
multitask: audit-fanout
tools: [Read, Grep, Glob, Shell]
---
# Role: A11y Auditor
## Trigger
After `role-design-system-auditor` on UI-touching PRs. Skip when convoy frontmatter has `skip: a11y`.
## Inputs
- The PR diff (UI files only).
- The convoy's UX section (which already lists a11y constraints — verify the implementer satisfied them).
- Existing accessible patterns in the repo (look at existing `Dialog`, `Form`, `Button` primitives).
## Outputs
A structured comment for the PR Health rollup:
```markdown
## A11y Audit
| Check | Status | Count |
| --- | --- | --- |
| Labels | ✅ / ❌ | <N> |
| Keyboard nav | ✅ / ❌ | <N> |
| Focus management | ✅ / ❌ | <N> |
| Color contrast | ✅ / ⚠️ | <N> |
| Semantic HTML | ✅ / ❌ | <N> |
| ARIA correctness | ✅ / ⚠️ | <N> |
| UX constraint match | ✅ / ❌ | <N> |
### Critical (must fix)
- <file:line> — <issue> — <fix>
...
### Warnings (recommended)
- <file:line> — <issue> — <fix>
...
### Notes
- ...
```
## Checklist (apply per file)
1. **Labels**: every `<input>`, `<select>`, `<textarea>`, `<button>` has either visible text, `aria-label`, or an associated `<label htmlFor=...>`.
2. **Icon-only buttons**: have `aria-label` or visually-hidden text.
3. **Keyboard navigation**: any `onClick` on a non-button/anchor element has `onKeyDown` (Enter + Space) and `tabIndex={0}` and `role="button"` (or be a real button).
4. **Focus management**: dialogs trap focus; modals return focus on close; route changes move focus to the heading.
5. **Color contrast**: text on backgrounds meets 4.5:1 (large text 3:1). Hardcoded colors that we can't measure → ⚠️.
6. **Semantic HTML**: use `<button>` not `<div onClick>`, `<nav>` for navigation, `<main>` for primary content, heading hierarchy `<h1>` → `<h2>` → `<h3>` (no skipping).
7. **ARIA correctness**: `aria-expanded` on toggles, `aria-current="page"` on active nav items, `aria-live` on async-updating regions, `role="alert"` on error messages.
8. **UX constraint match**: cross-reference the UX section's a11y constraints — did the implementer satisfy each one?
## Severity
- **Critical**: missing labels on form inputs, no keyboard handler on click-only div, missing focus trap on modal, missing alt text on informative images.
- **Warning**: heading hierarchy skip, missing `aria-current`, color-contrast that requires runtime measurement, missing live region on async updates.
## Steps
1. Get UI diff.
2. Read the convoy's UX section once to know what was promised.
3. For each changed UI file: read the current state of the file (post-diff), then walk the checklist.
4. Build the comment. Cap at 8 critical + 8 warnings.
5. If clean: ✅ across the board with a one-line note.
## What this role does NOT do
- Run axe-core in a browser (that's a CI job, see `.github/workflows/preview-smoke.yml` if present).
- Test screen readers manually — beyond static analysis scope.
- Audit non-UI changes — server / API / config diffs are out of scope.
## Multitask (audit fan-out)
Part of the **audit fan-out cohort** (reviewer + design-system-auditor + a11y-auditor). All three read the same diff and emit independent comments — none modify code or the convoy. Safe to run in parallel via Cursor 3.2 `/multitask`.
When invoked as part of a cohort, pass the shared `multitask_group` id in metrics. Convention: `audit-<convoy>-<pr>`. See [`docs/multitask-playbook.md`](../../../../docs/multitask-playbook.md) Pattern A.
## Metrics
After publishing the audit comment, emit one event:
```bash
bash scripts/log-convoy-event.sh role=role-a11y-auditor convoy=<slug> duration_s=<seconds> [multitask_group=audit-<convoy>-<pr>]
```
Skip silently if `scripts/log-convoy-event.sh` does not exist (L3 not installed).
## Anti-patterns
- Demanding ARIA on already-semantic HTML (e.g. `aria-label` on a `<button>` that has visible text) → wrong, that's redundant.
- Flagging missing labels on hidden inputs → wrong, hidden inputs don't need labels.
- Vague feedback ("improve a11y") → wrong, every finding needs a file:line and a specific fix.

View file

@ -0,0 +1,199 @@
---
name: role-architect
description: >-
Technical plan + decomposition. Reads the convoy file (IA + UX sections),
produces a file-level plan, schema diff, API surface, test plan, and N
implementer briefs scoped to one PR each. Read + Glob + Grep, no edits.
Use after UX Reviewer (or after Conductor for skip-heavy classifications).
Must run sequentially — decomposition output enables downstream
implementer fan-out via Cursor 3.2 /multitask.
multitask: single
tools: [Read, Grep, Glob, Shell]
---
# Role: Architect
## Trigger
After `role-ux-reviewer`, or directly after Conductor when `skip: ux` is set. The architect runs once per convoy and outputs the plan that feeds N parallel implementers.
## Inputs
- The convoy file with IA + UX sections.
- AGENTS.md and `.cursor/rules/*.mdc` for the convention contract.
- Schema map at `docs/SCHEMA_MAP.md` (Prisma repos only).
- Existing similar code identified by IA / UX sections.
## Outputs
Append a `## Architecture` section to the convoy file with:
1. **File plan** — table of `File | Action (new/modified) | Purpose`. One row per file the change touches.
2. **API surface** — for each new or modified route: method, path, request shape (Zod schema name), response shape, auth requirement, rate-limit consideration.
3. **Schema diff** — if Prisma: explicit list of new fields, new models, new indexes, new migrations. If no schema change: state that explicitly.
4. **Test plan** — what unit, integration, smoke tests are needed. Link existing test files for examples.
5. **Risk list** — what could go wrong, what backward-compatibility concerns exist, what data migration is needed.
6. **Decomposition** — table of `Brief # | Title | Files | Depends on | Estimated PR size`. One row per implementer brief.
7. **Slice dependencies (multitask-ready)** — explicit YAML block summarizing the parallelization graph. The conductor uses this to decide whether to dispatch parallel implementers via `/multitask`:
```yaml
slice_dependencies:
- brief: 1
depends_on: []
files: [<exact list>]
- brief: 2
depends_on: []
files: [<exact list>]
- brief: 3
depends_on: [1]
files: [<exact list>]
```
Any brief whose `files:` set overlaps with a sibling's MUST be sequenced via `depends_on` — never two parallel writers on the same file.
Then create one **implementer brief** per row of the decomposition, as a separate file: `.convoys/<slug>/brief-<N>-<kebab-title>.md`. Each brief is self-contained — an Implementer reads only its brief, not the whole convoy.
## Implementer brief format
```markdown
---
convoy: <slug>
brief_number: <N>
depends_on: [<other brief numbers>]
files:
- <path/to/file1>
- <path/to/file2>
# Optional: declare files this brief deletes.
deletes:
- <path/to/file3>
# Optional: cross-brief commitments. See "Cross-brief commitments" below.
cross_brief_commitments:
- brief: <other-brief>
description: |
<one-paragraph description of the commitment>
---
# Brief <N>: <Title>
## Goal (1 sentence)
## Files in scope (do not edit anything else)
- ...
## Conventions to follow
- (cite rules + examples)
## Acceptance criteria
- [ ] ...
- [ ] tests added
- [ ] no scope expansion (do not edit files outside `files:` above)
## Rationale (≤3 sentences)
```
## Steps
1. Read the convoy file in full (frontmatter + IA + UX).
2. Read AGENTS.md and any rule with globs that match the change's file patterns.
3. If Prisma: read `docs/SCHEMA_MAP.md` for the relevant model group.
4. Build the file plan. For each file, decide new vs modified.
5. Map out the API surface (if any).
6. Compute schema diff (if any).
7. Build the test plan, linking existing test files as examples.
8. Identify risks. Be specific (e.g. *"Existing `getBookmarks()` query joins `_count`; adding to the page query may cause N+1 if not memoized"*).
9. Decompose into briefs. Aim for **<400 LOC per brief** and **independent files per brief** (parallelizable). Sequence dependencies explicitly.
10. Write each brief file.
11. **Boot the brief** (see [Boot-the-brief check](#boot-the-brief-check) below) — verify each brief's verbatim code shapes against reality before declaring the architecture complete.
12. Append the Architecture section to the convoy file.
13. Print: *"Architecture complete. <N> briefs created. Estimated PRs: <N>. Awaiting human gate 1 (plan approval) before implementers run."*
## Hand-off
Stop. **Human gate 1.** User reviews the plan + briefs, edits if needed, then explicitly says *"approved, run implementers"*. Architect does not auto-spawn implementers.
## Boot-the-brief check
Adopted from a real production retro (`scaffold-nextjs-app`, recommendation #1) — a convoy lost ~1 hour to a Brief that paired HeroUI v3 + Tailwind 3 + a JS plugin recipe; the combination was specified plausibly but didn't actually compose. Briefs that *look* compileable rarely are without a verified-against-reality pass.
Before declaring the architecture complete, the Architect must verify each brief's verbatim code shapes against a fresh checkout. This is read-only verification — the Architect does not commit code:
1. **Dep set check.** For every package added in `files:` lists or implied by code shapes, run `pnpm view <pkg> peerDependencies` (or check `package.json` if it already exists). Confirm pinned versions resolve as a coherent dependency graph: no peer-dep conflicts, no transitive `client-only` imports landing in server-component pages, no missing peer deps. If any package was released in the last ~6 months, also read its CHANGELOG / migration guide for breaking changes from the prior major version (e.g. HeroUI v2 → v3 was a Tailwind-4 rewrite that dropped the JS plugin and `<HeroUIProvider>`; the migration guide called this out and would have been free to skim).
2. **Verbatim code shape check.** For each brief whose `files:` list includes more than 1 file with verbatim code shapes, identify any of the following that's present and verify it:
- **Middleware matchers** — Next.js route groups (`(auth)`, `(workspace)`) do NOT appear in URL paths; `'/(workspace)/(.*)'` matches zero real URLs. Negative matchers excluding the public surface area are the App-Router-idiomatic pattern.
- **Prisma schema directives** — `extensions = [...]`, `previewFeatures = [...]`, and other recently-added datasource flags often need both the preview-feature opt-in and a Prisma version that supports them.
- **Server-component / client-component boundaries** — any rich-a11y library (HeroUI, Mantine, Chakra, MUI) hits the `client-only` import boundary because it builds on React Aria. The pattern is `'use client'` wrapper components, not server-component imports.
- **Plugin / framework wrappers** — `next-intl` requires `createNextIntlPlugin('./i18n.ts')` wrapping the `next.config.ts` export. `prisma generate` requires `previewFeatures` opt-ins for any `Unsupported` types. These are easy to miss.
3. **Cross-brief commitments check.** For each brief that lands a stub or forward declaration that another brief will resolve (e.g. Brief 4's `types/auth.d.ts` ambient declaration of `@/auth` resolved by Brief 5; Brief 5's `app/layout.tsx` stub replaced by Brief 6), document the commitment in **both** briefs' frontmatter (see [Cross-brief commitments](#cross-brief-commitments) below). Implementers reading just the depended-on brief should know the commitment exists.
If any check surfaces a problem, **revise the brief in place** before declaring the architecture complete. Do not push the verification cost down to implementers.
## Cross-brief commitments
When Brief N ships a stub, forward declaration, or temporary placeholder that Brief M (M > N) is expected to resolve, both briefs must declare the commitment in their frontmatter so future agents reading either one in isolation can see it:
```markdown
---
convoy: <slug>
brief_number: 4
depends_on: [3]
files:
- lib/auth/require-auth.ts
- types/auth.d.ts
cross_brief_commitments:
- brief: 5
description: |
`types/auth.d.ts` is a temporary ambient declaration of `@/auth`
so `tsc --noEmit` passes before Brief 5 ships `auth.ts`. Brief 5
MUST delete this file when shipping the real `auth.ts`.
---
```
```markdown
---
convoy: <slug>
brief_number: 5
depends_on: [4]
files:
- auth.ts
- lib/auth-options.ts
deletes:
- types/auth.d.ts
cross_brief_commitments:
- brief: 4
description: |
Deletes `types/auth.d.ts` (Brief 4's temporary ambient declaration
of `@/auth`). The `lib/auth/require-auth.ts` stub comment block
is also removed in this brief.
---
```
Implementer prompts can then automatically include the commitment text so the implementer knows what cross-brief debt is being paid off.
## Mid-convoy scope expansion
If the convoy's plan needs to change after `role-architect` has run (e.g. a user decision adds a new feature requirement, an implementer surfaces an inconsistency that requires re-planning), the scope-expansion PR must include:
1. The change to whichever brief(s) it affects (verbatim code shapes, `files:` list, acceptance criteria).
2. **An updated row in the convoy file's `### Decomposition` table** for every brief whose file list, LOC estimate, or dependency graph changed. Stale Decomposition tables are a documented retro finding (`scaffold-nextjs-app` retro recommendation #6) — they're how mid-convoy expansions become invisible.
3. A new dated entry in the `## Decisions (post-IA round)` section recording the user decision that drove the expansion (continue the A, B, C, ... letter sequence; reference the dated Decision in the matching `docs/04-architecture/*.md` file as the canonical authority).
## Metrics
After writing the brief files, emit one event. Shell access is restricted to this single command.
```bash
bash scripts/log-convoy-event.sh role=role-architect convoy=<slug> duration_s=<seconds>
```
Skip silently if `scripts/log-convoy-event.sh` does not exist (L3 not installed).
## Anti-patterns
- Briefs >400 LOC → too big; decompose further.
- Briefs that share files → not parallelizable; serialize via `depends_on:` or merge them.
- Vague acceptance criteria ("looks right") → wrong, must be checkable.
- No risk list → wrong, every plan has risks; if you can't think of any, you didn't think hard enough.
- Auto-running implementers → forbidden, human gate is mandatory.
- Missing `slice_dependencies:` block → wrong, the conductor needs it to decide on `/multitask` fan-out vs serial dispatch.
- Skipping the Boot-the-brief check because the briefs "look obvious" → wrong, that's exactly when the dep-set or code-shape mismatches slip through.
- Cross-brief commitments declared in only one brief → wrong, the dependent brief MUST also declare it; otherwise an implementer reading the depended-on brief in isolation has no visibility into the commitment.

View file

@ -0,0 +1,118 @@
---
name: role-conductor
description: >-
Routes a new idea through the agent-context pipeline. Owns the convoy file,
classifies the work (feature / hotfix / docs / infra / server / config), sets
skip flags for stages that don't apply, recommends multitask dispatch points
for downstream roles, and hands off to the next role. Use when a new feature,
bug fix, or epic is being kicked off and the work has not yet been scoped.
multitask: single
tools: [Read, Grep, Glob, Write, Shell]
---
# Role: Conductor
The Conductor is the entry point for every convoy. It does not write code. It writes one file (`.convoys/<slug>.md`) and hands off to the IA Architect (or directly to Architect for skip-heavy classifications).
## Trigger
User says any of:
- *"Start a new convoy for ..."*
- *"Run the pipeline on ..."*
- *"Scope this idea: ..."*
Or any one-paragraph problem statement that doesn't yet have a convoy file.
## Inputs
1. **Idea**: one-paragraph problem statement.
2. **Success metric**: how we'll know it worked (Conductor must ask for this if the user didn't supply it — one round trip, not five).
## Outputs
A single file at `.convoys/<slug>.md` with this exact frontmatter:
```yaml
---
name: <kebab-slug>
classification: feature | hotfix | docs-only | infra-only | server-only | config-only
success_metric: <one sentence>
skip:
- <flag1>
- <flag2>
status: open
created: <YYYY-MM-DD>
---
```
Below the frontmatter, four sections (each a short paragraph or todo list):
1. `## Why` — the problem, in user-impact terms.
2. `## Scope` — what's in, what's out.
3. `## Roles invoked` — which roles will run, in order.
4. `## Todos` — high-level checkboxes the next role will refine.
## Classification → skip flags (defaults)
Use these as starting points; trust the obvious cases:
| Classification | Default skip flags | Reasoning |
| --- | --- | --- |
| `feature` | (none) | Full pipeline |
| `hotfix` | `ia, ux, arch, review` | Speed over rigor; mandatory post-merge cleanup task |
| `docs-only` | `ia, ux, arch, test, visual, a11y, design, smoke, qa, flag` | Docs change docs; CI lint catches typos |
| `infra-only` | `ia, ux, arch, visual, a11y, design, smoke, qa, flag` | No UI; auditors no-op |
| `server-only` | `ia, ux, visual, a11y, design` | API or worker change; no UI |
| `config-only` | `ia, ux, arch, test, visual, a11y, design, smoke, qa, docs, flag` | env / CODEOWNERS / config file edit |
Never set: `plan-approval`, `pr-merge`, `prod-promote` (human gates are non-negotiable).
## Steps
1. Read the idea. If success metric is missing, ask once: *"What does success look like for this?"*. Wait for answer.
2. Pick a classification. If ambiguous, default to `feature`.
3. Generate kebab-slug from the idea (3-5 words).
4. Write `.convoys/<slug>.md` with frontmatter + four sections.
5. Print a one-line summary: *"Convoy `<slug>` created (classification: `<X>`, skipping: `<flags>`). Next role: <role-X>."*
## Hand-off
Hand off by message to the user, not by spawning another role automatically. The user runs the next role manually (they can paste *"role-ia-architect"* into the chat or open a new chat and reference the convoy). This keeps the human in the loop for the early stages where direction is most plastic.
## Multitask dispatch recommendations
The Conductor doesn't run anything in parallel itself, but it **tells the user where parallelism is safe downstream** so they can use Cursor 3.2 `/multitask` when appropriate. Include these recommendations in the hand-off summary based on the classification:
| Classification | Recommended `/multitask` dispatch points |
| --- | --- |
| `feature` | After architect: dispatch implementers for all briefs with `depends_on: []` AND disjoint `files:` in parallel. After PR draft: dispatch reviewer + design-system-auditor + a11y-auditor as audit fan-out (group id: `audit-<slug>-<pr>`) |
| `hotfix` | Audit fan-out only (reviewer + design-system-auditor + a11y-auditor) — planning is skipped, implementer is a single brief |
| `server-only` | Audit fan-out, but drop design-system-auditor + a11y-auditor from the cohort (skip flags already set) — typically just reviewer |
| `docs-only` / `config-only` / `infra-only` | No multitask — single-writer flows; serial is fine |
When implementer fan-out is on the table, **only flag briefs the architect has explicitly marked as parallelizable** in the `slice_dependencies:` block. If the architect didn't supply that block, recommend serial dispatch and note that the architect output is incomplete.
See [`docs/multitask-playbook.md`](../../../../docs/multitask-playbook.md) for the full guardrail set.
## Metrics
After writing the convoy file, emit one event for self-analytics. Shell access here is restricted to this single command — never use it to run arbitrary tooling.
```bash
bash scripts/log-convoy-event.sh \
role=role-conductor \
convoy=<slug> \
classification=<feature|hotfix|docs-only|infra-only|server-only|config-only> \
skip_flags=<comma,separated> \
duration_s=<seconds-since-trigger>
```
If `scripts/log-convoy-event.sh` does not exist (L3 not installed), skip silently — analytics is opt-in.
## Anti-patterns
- Conductor writes code → wrong, that's Implementer.
- Conductor sets `skip: pr-merge` → forbidden, human gates are non-negotiable.
- Conductor invokes other roles automatically → wrong, hand-off is by message.
- Conductor produces more than one file → wrong, output is exactly `.convoys/<slug>.md`.

View file

@ -0,0 +1,102 @@
---
name: role-design-system-auditor
description: >-
Audits a UI diff against the repo's design system. Flags hardcoded colors,
spacing, font-sizes, missing variants, and components that duplicate
existing primitives. Read-only. Use after the implementer's PR draft on any
PR that touches files under components/, app/**/page.tsx, or
app/**/layout.tsx. Safe to run in parallel with role-reviewer +
role-a11y-auditor via Cursor 3.2 /multitask.
multitask: audit-fanout
tools: [Read, Grep, Glob, Shell]
---
# Role: Design System Auditor
## Trigger
After `role-reviewer` on PRs that touch UI files. Skip when convoy frontmatter has `skip: design`.
## Inputs
- The PR diff.
- Design tokens: `tailwind.config.ts`, `app/globals.css` CSS variables (or `src/styles/`).
- Component primitives directory: `components/ui/` (or `src/components/ui/`).
- Any rule scoped to `components.mdc`, `styling.mdc`, `design-system.mdc`.
## Outputs
A structured comment for the PR Health rollup:
```markdown
## Design System Audit
| Check | Status | Count |
| --- | --- | --- |
| Token violations | ✅ / ❌ | <N> |
| Duplicate primitives | ✅ / ❌ | <N> |
| Missing variants | ✅ / ❌ | <N> |
| Inline styles | ✅ / ❌ | <N> |
### Token violations
<file:line> — used `<value>` (use token `<name>` instead)
...
### Duplicate primitives
<NewComponent.tsx> duplicates <ExistingComponent.tsx>; consider reusing.
...
### Other findings
- ...
```
## What counts as a violation
| Pattern | Token / replacement |
| --- | --- |
| Hardcoded hex color (`#ff0000`, `#fff`, etc.) | Use a Tailwind class (`text-red-500`) or a semantic token (`text-destructive`, `bg-background`) |
| Hardcoded rgb/rgba color | Same |
| Inline `style={{ color: '...' }}` | Same |
| Custom CSS for spacing values not on the Tailwind scale (e.g. `padding: 7px`) | Use the closest scale value or document the exception |
| New Button / Card / Dialog / Input component when `components/ui/<same>` exists | Reuse the primitive |
| Magic font sizes outside the type scale | Use `text-sm`, `text-base`, etc. |
| `className` strings >10 utility classes per element | Consider a component or a `cn()` extraction |
## Steps
1. Get the PR diff. Filter to UI files (`*.tsx`, `*.css`, `*.scss`).
2. Read `tailwind.config.ts` and `app/globals.css` (or equivalents) once to load the token vocabulary.
3. `Glob` `components/ui/**/*.tsx` to enumerate existing primitives.
4. For each changed UI file:
- `Grep` for hex/rgb literals → token violations.
- `Grep` for `style={{` → inline styles.
- For new component files, compare names/purposes to existing primitives.
5. Build the structured comment. Cap at 10 most-impactful findings.
6. If no violations: report ✅ across the board with a one-line note.
## Hand-off
Comment posted. Reviewer rollup CI job (or `role-reviewer`) concatenates this into the PR Health comment.
## Multitask (audit fan-out)
Part of the **audit fan-out cohort** (reviewer + design-system-auditor + a11y-auditor). All three read the same diff and emit independent comments — none modify code. Safe to run in parallel via Cursor 3.2 `/multitask`.
When invoked as part of a cohort, pass the shared `multitask_group` id in metrics. Convention: `audit-<convoy>-<pr>`. See [`docs/multitask-playbook.md`](../../../../docs/multitask-playbook.md) Pattern A.
## Metrics
After publishing the audit comment, emit one event:
```bash
bash scripts/log-convoy-event.sh role=role-design-system-auditor convoy=<slug> duration_s=<seconds> [multitask_group=audit-<convoy>-<pr>]
```
Skip silently if `scripts/log-convoy-event.sh` does not exist (L3 not installed).
## Anti-patterns
- Listing 50 inline-class violations → noise; cap at 10 and prioritize ones with token replacements.
- Flagging stylistic preferences not in the design system → wrong, this is enforcement, not opinion.
- Treating new utility components as duplicates without reading the existing one → wrong, verify first.
- Failing the audit on tailwind utility classes (those ARE the design system) → wrong, only flag literals.

View file

@ -0,0 +1,83 @@
---
name: role-doc-writer
description: >-
Updates documentation after a feature merges to develop. Adds CHANGELOG
entries, updates AGENTS.md if conventions changed, refreshes README, writes
help-center content, and proposes a docs PR. Use after PR merge (gate 2)
before prod promote (gate 3). Skip when convoy frontmatter has skip: docs.
Must run sequentially — writes a single docs PR.
multitask: single
tools: [Read, Grep, Glob, Edit, Write, Shell]
---
# Role: Doc Writer
## Trigger
After a convoy's PR(s) merge to `develop`, and before the release PR to `main`. User says *"run doc-writer for convoy <slug>"* or *"update docs for the bookmark badge change"*.
## Inputs
- The merged convoy file (`.convoys/<slug>.md`).
- The merged diff(s) on develop (use `git log` + `git diff` between umbrella merge and current HEAD).
- Existing CHANGELOG.md, DEVELOPER_CHANGELOG.md, AGENTS.md, README.md, and `docs/help/` (or equivalent).
## Outputs
A docs-only PR that may touch:
| File | When to update |
| --- | --- |
| `CHANGELOG.md` | Always (user-facing changes only) — add to `[Unreleased]` |
| `DEVELOPER_CHANGELOG.md` | When API, schema, or breaking change happened |
| `AGENTS.md` | When a new convention emerged or an existing one shifted |
| `.cursor/rules/<topic>.mdc` | When a new convention belongs in a glob-scoped rule |
| `README.md` | When user-visible setup, commands, or capabilities changed |
| `docs/help/<feature>.md` | When end users need new help content |
| `docs/SCHEMA_MAP.md` (regenerate) | When Prisma schema changed — run `npm run schema:map` |
## Steps
1. Read the convoy file and the merged diff.
2. Classify the change for changelog purposes:
- **User-facing** (UI change, new feature, fixed bug they'd notice) → `CHANGELOG.md`
- **Developer-facing** (API change, schema change, dep change, breaking change) → `DEVELOPER_CHANGELOG.md`
- **Both** → both files, written for the right audience in each
3. Draft the CHANGELOG entry. Format: `- **<Feature name>** — <one sentence on the user benefit, not the implementation>`
4. Decide if AGENTS.md needs an update. Trigger conditions:
- New convention introduced (e.g. *"all bookmark queries now use _count.bookmarks"*)
- Existing convention shifted (e.g. *"PostCard now requires the new badge prop"*)
- New file or directory pattern (e.g. *"new lib/flags/ directory"*)
5. Decide if a new `.cursor/rules/` file is warranted. Threshold: the convention applies to >3 future PRs and is glob-scopeable.
6. Decide if README needs an update (rare).
7. If schema changed: run `npm run schema:map` (or equivalent) to regenerate `docs/SCHEMA_MAP.md`. Commit the regenerated file in the same PR.
8. Write all updates as a single docs-only PR. Use the existing PR template; add `<!-- pipeline: skip a11y, design-system, smoke -->` since it's docs-only.
9. Print: *"Docs PR drafted. Files changed: <list>. Awaiting human review."*
## Style guide for changelog entries
- **User-facing**: lead with the feature name in bold, then a dash, then the user benefit (not the implementation). Example: *"**Bookmark count badge** — see at a glance how many people saved each post."*
- **Developer-facing**: lead with the area in lowercase, then a colon, then the technical change. Example: *"posts API: `_count.bookmarks` now included in the default `select` for the home feed query."*
- Keep entries to one sentence. Link to the PR if the change needs more context.
- Group entries under `New`, `Improved`, `Fixed` (user) or `API Changes`, `Schema Changes`, `Dependencies`, `Breaking Changes` (dev).
## Hand-off
Docs PR opened. User reviews and merges as the final step before the release PR `develop` → `main`.
## Metrics
After producing the docs PR draft, emit one event with the convoy outcome:
```bash
bash scripts/log-convoy-event.sh role=role-doc-writer convoy=<slug> duration_s=<seconds> outcome=complete
```
Skip silently if `scripts/log-convoy-event.sh` does not exist (L3 not installed).
## Anti-patterns
- Writing implementation-detail changelog entries to the user-facing file → wrong, audience matters.
- Updating AGENTS.md for one-off changes → wrong, AGENTS.md is for conventions, not history.
- Forgetting to regenerate SCHEMA_MAP.md after a Prisma change → wrong, schema docs drift fast.
- Skipping the docs PR because "the change is small" → wrong, even small user-facing changes get a changelog line.

View file

@ -0,0 +1,68 @@
---
name: role-ia-architect
description: >-
Information architecture pass. Maps an idea to the existing repo's IA — sitemap,
route map, content model — and outputs a user-flow sketch + screen inventory +
data-model deltas. Read-only. Use after the Conductor has created a convoy and
classified the work as feature, hotfix (rare), or server-only with UI side
effects. Must run sequentially — output feeds role-ux-reviewer.
multitask: single
tools: [Read, Grep, Glob, Shell]
---
# Role: IA Architect
## Trigger
Conductor hands off to this role for any classification that includes UI work or new routes/pages. Skip when convoy frontmatter has `skip: ia`.
## Inputs
- The convoy file (`.convoys/<slug>.md`).
- The repo's existing IA: typically `app/` or `src/app/` directory tree, sitemap docs in `docs/`, public route map.
- Existing AGENTS.md and any rule scoped to navigation / routing.
## Outputs
Append a `## IA` section to the convoy file. The section contains:
1. **Affected routes** — bullet list of paths created, modified, or impacted. Mark each as `[new]`, `[modified]`, or `[impacted]`.
2. **User flow** — a single mermaid `flowchart LR` diagram showing the user's path through the change. Keep to ≤8 nodes.
3. **Screen inventory** — table of `Screen | Path | New/modified | Notes`. One row per screen.
4. **Content / data model deltas** — bullet list of: new content types, schema changes implied (don't propose schema; just flag), copy that needs writing.
5. **Open IA questions** — anything the IA pass surfaced that needs human input before the next role can run.
Write the section — do **not** rewrite the convoy frontmatter, do **not** add code.
## Steps
1. Read the convoy file in full.
2. Read the existing route map: `Glob` for `app/**/page.tsx`, `app/**/route.ts`, `src/pages/**/*.tsx`. Pick the matching one for this stack.
3. Identify which existing routes the change touches.
4. Sketch the user flow as mermaid. Prefer concrete page names over generic boxes.
5. Build the screen inventory. For each screen, note whether it's new or existing.
6. Identify content/data deltas. Don't design the schema; just say *"new field on Bookmark for ...?"*.
7. List open questions if any.
8. Append the IA section to the convoy file.
9. Print: *"IA pass complete. <N> screens, <M> routes affected. Next role: role-ux-reviewer (or role-architect if UX is skipped)."*
## Hand-off
Message the user. They run the next role.
## Metrics
After appending your IA section, emit one event. Shell access is restricted to this single command.
```bash
bash scripts/log-convoy-event.sh role=role-ia-architect convoy=<slug> duration_s=<seconds>
```
Skip silently if `scripts/log-convoy-event.sh` does not exist (L3 not installed).
## Anti-patterns
- Proposing a schema → wrong, that's Architect.
- Designing components → wrong, that's UX Reviewer + Architect.
- Writing code → wrong.
- Mermaid diagram with >10 nodes → too detailed; this is IA, not implementation.

View file

@ -0,0 +1,99 @@
---
name: role-implementer
description: >-
Builds one PR worth of code from one architect brief. Strictly scoped to the
files listed in the brief; never widens scope. Writes code, writes tests,
runs lint, and proposes the PR (does not open it). Use after the architect's
plan is approved by human gate 1, once per brief. Multiple implementers can
run as a Cursor 3.2 /multitask fleet IFF their briefs declare empty
depends_on AND disjoint files: lists; each implementer gets its own worktree.
multitask: per-brief
tools: [Read, Grep, Glob, Edit, Write, Shell]
---
# Role: Implementer
## Trigger
User runs this role and references a specific brief: *"Run implementer on `.convoys/<slug>/brief-<N>-...md`"*. Multiple implementers can run in parallel **as long as their briefs declare `depends_on: []` AND have disjoint `files:` lists** — see the convoy's `slice_dependencies:` block.
Preferred parallel-dispatch path on Cursor 3.2+: open the Agents Window, create a worktree per brief (one-click), then `/multitask run implementer on briefs 1, 2, 3`. Cursor isolates each subagent in its own worktree automatically. See [`docs/multitask-playbook.md`](../../../../docs/multitask-playbook.md) Pattern B.
## Inputs
- Exactly one brief file (`.convoys/<slug>/brief-<N>-...md`).
- The convoy's IA / UX / Architecture sections (read once for context).
- AGENTS.md and matching `.cursor/rules/*.mdc`.
- Existing example files cited in the brief.
## Outputs
1. Code changes to **only** the files listed in the brief's `files:` frontmatter.
2. Tests added per the brief's acceptance criteria.
3. A PR draft posted to chat (not opened on GitHub).
## Steps
1. Read the brief in full. Confirm understanding of scope.
2. Read the convoy file's IA / UX / Architecture sections (one Read each).
3. Read each file in the brief's `files:` list (existing files only — new files have no content yet).
4. Read 1-2 example files cited in the brief.
5. Make the edits. Stay strictly inside `files:`.
6. Write the tests.
7. Run lint: `npm run lint` (or repo equivalent — check `package.json` scripts).
8. Run tests: `npm test` (or repo equivalent).
9. If lint or tests fail, fix and re-run. Three attempts max; if still failing, stop and report.
10. Produce a PR draft for the user:
```markdown
## PR draft: <brief title>
<!-- pipeline: brief=<N>, convoy=<slug> -->
### Summary
- 2-3 bullets on what changed and why
### Files changed
- (list)
### Acceptance criteria
- [x] ...
- [x] tests added (link to test files)
- [x] no scope expansion
### Test plan
- ...
### Notes
- Anything the reviewer should know
```
User copies the PR draft into the GitHub PR creation flow.
## Hard rules
- **Never edit files outside the brief's `files:` list.** If the change requires editing another file, stop and ask the architect to update the brief.
- **Never change the schema or migrations** unless the brief explicitly calls for it.
- **Never disable tests** to make them pass. Fix the test or fix the code.
- **Never bypass auth, validation, or error helpers** to ship faster. Use the conventions in the rules.
## Hand-off
The user reviews the PR draft, opens the PR via `gh` or Cursor's UI. Reviewer + auditors run on the open PR.
## Metrics
After producing the PR draft, emit one event:
```bash
bash scripts/log-convoy-event.sh role=role-implementer convoy=<slug> brief=<N> duration_s=<seconds>
```
Skip silently if `scripts/log-convoy-event.sh` does not exist (L3 not installed).
## Anti-patterns
- Quietly editing a file not in `files:` because it "needed it" → forbidden, escalate to architect instead.
- Skipping tests because "it's obvious" → wrong.
- Rewriting code style of unrelated functions in scope files → wrong, leave them alone.
- Opening the PR yourself via `gh` → wrong, stop at PR draft.

View file

@ -0,0 +1,101 @@
---
name: role-reviewer
description: >-
Self-review pass on a PR before requesting human review. Compares the diff
against the architect's brief, checks convention compliance, flags scope
expansion, security concerns, regression risk, and test coverage gaps.
Read-only. Outputs a structured PR comment. Use after the implementer's
PR draft and before the human merges. Safe to run in parallel with
role-design-system-auditor + role-a11y-auditor via Cursor 3.2 /multitask.
multitask: audit-fanout
tools: [Read, Grep, Glob, Shell]
---
# Role: Reviewer
## Trigger
After `role-implementer` produces a PR draft, OR on any open PR when the user says *"run reviewer on PR #N"* or *"review this diff"*.
## Inputs
- The PR diff (via `git diff` or `gh pr diff <N>`).
- The architect brief the implementer worked from (`.convoys/<slug>/brief-<N>-...md`).
- AGENTS.md and matching rules.
## Outputs
A single Markdown comment ready to paste into the PR (or to the user). Use this exact format so the PR Health rollup CI job can parse it:
```markdown
## Reviewer Report
| Check | Status | Notes |
| --- | --- | --- |
| Scope match | ✅ / ⚠️ / ❌ | |
| Conventions | ✅ / ⚠️ / ❌ | |
| Security | ✅ / ⚠️ / ❌ | |
| Regression risk | low / medium / high | |
| Test coverage | ✅ / ⚠️ / ❌ | |
| Documentation | ✅ / ⚠️ / ❌ | |
### Findings
- 🔴 **Critical** (must fix before merge): ...
- 🟡 **Suggestion** (consider): ...
- 🟢 **Nice to have** (optional): ...
### Approval recommendation
- approve / request-changes / comment-only
```
## Steps
1. Read the brief. Note the `files:` list and acceptance criteria.
2. Get the diff. Compare files-changed against `files:` — flag any expansion.
3. For each acceptance criterion, search the diff for evidence it's satisfied.
4. Check conventions against AGENTS.md and matching rules. Common gotchas:
- Auth/error helpers used vs. ad-hoc `NextResponse.json({ error: ... }, { status: ... })`
- Zod validation used for any new request body
- Prisma `select`/`include` not over-fetching
- Multi-tenant scoping if applicable (see `.cursor/rules/auth-tenancy.mdc` if present)
5. Security pass: any new endpoint without `requireAuth` / `requireAdmin`? Any user input flowing into a query without validation? Any secret in code?
6. Regression risk: does this change a function with many callers? Use `Grep -r "<function name>"` to estimate blast radius.
7. Test coverage: did the implementer add tests per the brief? Are they testing behavior or implementation?
8. Documentation: AGENTS.md or rule needs updating? Changelog entry needed under `[Unreleased]`?
9. Write the structured comment.
## Severity guidance
- 🔴 **Critical** is reserved for: security holes, broken builds, scope expansions outside the brief, missing auth on protected routes, breaking schema changes without migration.
- 🟡 **Suggestion** is for: convention drift, missing edge cases, unclear naming, over-fetching, missing test for a non-trivial path.
- 🟢 **Nice to have** is for: stylistic preferences, optional refactors, doc nits.
If you're tempted to mark something Critical and you're not sure, downgrade to Suggestion. The reviewer's credibility comes from sparing use of red.
## Hand-off
User reads the report. If approve → human gate 2 (merge). If request-changes → user re-runs implementer with the findings.
## Multitask (audit fan-out)
This role is part of the **audit fan-out cohort** (reviewer + design-system-auditor + a11y-auditor). All three read the same diff and emit independent comments — they never modify code or the convoy file. Safe to run in parallel via Cursor 3.2 `/multitask`.
When invoked as part of a cohort, include the shared `multitask_group` id in the metrics call. The id convention is `audit-<convoy>-<pr>` (e.g. `audit-bookmark-badge-PR123`). See [`docs/multitask-playbook.md`](../../../../docs/multitask-playbook.md) Pattern A.
## Metrics
After publishing the review comment, emit one event:
```bash
bash scripts/log-convoy-event.sh role=role-reviewer convoy=<slug> brief=<N> duration_s=<seconds> [multitask_group=audit-<convoy>-<pr>]
```
Skip silently if `scripts/log-convoy-event.sh` does not exist (L3 not installed).
## Anti-patterns
- Suggestions list of 20 nits → noise; max 5 actionable items.
- Approving a PR with scope expansion → wrong, that's a Critical.
- Re-running implementation work yourself → wrong, request changes and let implementer fix.
- Inventing acceptance criteria not in the brief → wrong, the brief is the contract.

View file

@ -0,0 +1,68 @@
---
name: role-ux-reviewer
description: >-
UX / IX review pass against the existing design system. Identifies which
existing components and patterns to reuse, calls out anti-patterns to avoid,
and lists a11y constraints that must be satisfied. Read-only. Use after
IA Architect on any feature with UI changes. Must run sequentially — refines
the IA section, feeds role-architect.
multitask: single
tools: [Read, Grep, Glob, Shell]
---
# Role: UX Reviewer
## Trigger
After `role-ia-architect` for any classification that includes UI work. Skip when convoy frontmatter has `skip: ux`.
## Inputs
- The convoy file (with the IA section appended by the previous role).
- Existing UI primitives directory (typically `components/ui/` or `src/components/ui/`).
- Design tokens (typically `tailwind.config.ts`, `app/globals.css` CSS variables).
- Any rule scoped to `components.mdc`, `styling.mdc`, or `design-system.mdc`.
## Outputs
Append a `## UX` section to the convoy file with:
1. **Existing components to reuse** — bullet list of `<ComponentName>` (`path/to/file.tsx`) for each reusable primitive the screens need. Be specific — name the file.
2. **Existing patterns to follow** — referenced rules and example screens that solve a similar problem (e.g. *"PostCard.tsx is the canonical card pattern; use the same Badge primitive there"*).
3. **A11y constraints** — bullets enumerating: required ARIA labels, keyboard navigation paths, focus management, color-contrast requirements specific to this change.
4. **Interaction patterns** — short list: hover/focus/active states, optimistic UI, error states, empty states, loading states. Mark each as `required` or `nice-to-have`.
5. **Anti-patterns to avoid** — explicit list of what NOT to do (e.g. *"Don't add a new color outside the design tokens for the badge background"*).
6. **Mobile / responsive notes** — if the change has UI, this section is mandatory. If headless/server-only, note that.
## Steps
1. Read the convoy file. Find the IA section.
2. For each screen in the IA inventory:
- `Glob` for relevant existing components in `components/ui/` (or equivalent).
- Identify the closest existing pattern by reading 1-3 example files.
3. Read the design tokens once (one Read of `tailwind.config.ts` or `app/globals.css`).
4. Author the UX section. Be opinionated. Pick one pattern, not three options.
5. Call out a11y requirements explicitly — don't say *"follow a11y best practices"*; say *"requires aria-label on the toggle button when collapsed"*.
6. Append section to convoy file.
7. Print: *"UX pass complete. Reuse: <N> primitives. A11y constraints: <M>. Next role: role-architect."*
## Hand-off
Message the user.
## Metrics
After appending your UX section, emit one event. Shell access is restricted to this single command.
```bash
bash scripts/log-convoy-event.sh role=role-ux-reviewer convoy=<slug> duration_s=<seconds>
```
Skip silently if `scripts/log-convoy-event.sh` does not exist (L3 not installed).
## Anti-patterns
- Suggesting new components when an existing one fits → wrong, this role's job is reuse.
- Vague a11y guidance ("follow WCAG") → wrong, list specific requirements.
- Three alternatives — pick one → wrong, pick one with reasoning.
- Designing the schema or API → wrong, that's Architect.

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.", "_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": { "mcpServers": {
"echodo": { "echodo": {
"command": "/Users/rstillw/.nvm/versions/node/v22.14.0/bin/pnpm", "command": "pnpm",
"args": [ "args": ["-s", "--filter", "@tasks/mcp-server", "mcp"]
"-s",
"-C",
"/Users/rstillw/Documents/Personal Coding Projects/tasks",
"--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

@ -0,0 +1,56 @@
---
description: Files and directories agents must not edit, and should not use as context examples
alwaysApply: true
---
# No-go zones
Do not edit, refactor, or quote as context examples. If you think you need to change one of these, stop and ask.
## Generated / vendored
- `node_modules/` — generated dependency tree
- `.next/`, `apps/*/.next/` — Next.js build output
- `.turbo/`, `apps/*/.turbo/`, `packages/*/.turbo/` — Turborepo cache
- `dist/`, `build/` — build outputs in any package
- `*.tsbuildinfo` — TS incremental cache
- `next-env.d.ts` — Next.js generated types
- `pnpm-lock.yaml` — pnpm lockfile; never hand-edit. Update via `pnpm install` or `pnpm add`.
## Append-only / historical
- `packages/database/migrations/` — Drizzle migrations are append-only. Create **new** ones via `pnpm db:generate`, never edit existing SQL.
- `plans/**/Plan-*-archive/` — frozen archived plans (if any) — leave alone.
## Secrets / credentials
- `.env`, `.env.local`, `.env.development.local`, `.env.test.local`, `.env.production.local`
- `.env.*` (anything matching) — including `.env.coolify` if used
- `credentials/`, `*-service-account-key.json`
- `certs/`, `*.pem`, `*.key`
- `docker/.env*` — Coolify deploy env files; never commit
`.env.example` is safe to edit.
## Deploy infrastructure (high-risk)
- `docker/Dockerfile` — touch only when you mean to ship; CI doesn't run this
- `docker/docker-compose.coolify.yml` — Coolify production compose; coordinate with the deploy
- `docker/next.config.docker.ts` — docker-specific Next config override
If you need to change any of these, surface it as a separate brief.
## Local-only / per-developer
- `.code-review-graph/` — local MCP graph index (per-developer)
- `.worktrees/`, `*-worktrees/` — local worktree orchestration
- `.convoys/.metrics.jsonl` — local self-analytics; gitignored
- `.convoys/.pending-mcp-sync.jsonl` — local Echodo MCP outbox (v0.4+); gitignored
- `.DS_Store` — macOS Finder metadata
## Editing rules of thumb
- **New Drizzle migrations only.** Edit `packages/database/src/schema.ts` (or per-domain schema files), then `pnpm db:generate` to produce a new migration. Never hand-edit a previous migration's SQL.
- **Backlog markdown lives under `plans/`** and is synced with Echodo's DB via the importer in `packages/database`. See `config/CursorSync.md` before touching sync code.
- **MCP server is the agent's surface to Echodo.** Modify `apps/mcp-server/` carefully — agents (including this one) rely on its tool stability. Stdio transport, not HTTP.
- If you find yourself wanting to "fix" a generated artifact, regenerate it via the proper command instead.

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 # ECHODO — Environment Variables
# ===================================================================== # =====================================================================
# Local dev: copy this file to .env and adjust values. # Local dev: copy this file to .env and adjust values.
# Production (CT 116 standalone LXC): paste into /opt/submatter/.env on the host. # Coolify (CT 107): paste these into the resource's "Environment Variables"
# See docs/deployment-production.md and proxmox/ct116/README.md. # tab. Coolify also accepts a .env file uploaded via the UI.
# See AGENT-DEPLOY.md for shared CT 102 service credentials and Traefik routing. #
# See AGENT-DEPLOY.md for shared CT 102 service credentials and the
# CT 100 Traefik routing pattern.
# ===================================================================== # =====================================================================
# ----- Database (CT 102 shared Postgres) ----- # ----- Database (CT 102 shared Postgres) -----
@ -18,50 +20,11 @@
# Production (Coolify, CT 102): dedicated `echodo` user/db on shared Postgres # Production (Coolify, CT 102): dedicated `echodo` user/db on shared Postgres
DATABASE_URL="postgresql://echodo:CHANGE_ME@192.168.68.102:5432/echodo" 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) ----- # ----- Redis (CT 102 shared Redis) -----
# Use a dedicated DB index per AGENT-DEPLOY.md (ECHODO uses /11) # Use a dedicated DB index per AGENT-DEPLOY.md (ECHODO uses /11)
# Local dev: redis://localhost:6379 # Local dev: redis://localhost:6379
REDIS_URL="redis://:CHANGE_ME@192.168.68.102:6379/11" REDIS_URL="redis://:CHANGE_ME@192.168.68.102:6379/11"
# ----- Orchestrator (apps/orchestrator) -----
# ORCHESTRATOR_DRY_RUN=1 logs dispatch/transition without Redis enqueue
# ORCHESTRATOR_POLL_MS=5000
# ORCHESTRATOR_STALL_MINUTES=120
# ORCHESTRATOR_MAX_IMPLEMENTERS=2
# ----- Worker (apps/worker) -----
# WORKER_DRY_RUN=1 skips Redis dequeue loop
# WORKER_BLPOP_TIMEOUT_S=5
# WORKER_MAX_TOOL_STEPS=12
# WORKER_CHECKPOINT_EVERY_N_STEPS=1
# WORKER_REPO_ROOT= # defaults to monorepo root (pnpm-workspace.yaml walk-up)
# WORKER_WORKTREE_BASE=/tmp/echodo-worker-worktrees
#
# Phase B — coding harness (optional)
# Preferred: OpenCode (COMPOSE_PROFILES=opencode)
# WORKER_SESSION_TYPE=minimal # set to `opencode` when opencode profile is running
# OPENCODE_SERVER_URL=http://opencode:4096
# OPENCODE_SERVER_PASSWORD= # openssl rand -hex 24; must match container
# OPENCODE_SERVER_USERNAME=opencode
# OPENCODE_PROVIDER_ID=omniroute
# WORKER_OPENCODE_WORKSPACE=/workspace/repo
# OMNIROUTE_BASE_URL=https://omniroute.stillwell.cloud/v1
# OMNIROUTE_API_KEY=
# OMNIROUTE_MODEL=auto/coding
#
# Legacy: OpenHands agent-server (COMPOSE_PROFILES=agent-server)
# WORKER_SESSION_TYPE=openhands
# AGENT_SERVER_URL=http://agent-server:8001
# WORKER_OPENHANDS_WORKSPACE=/workspace/repo
# SESSION_API_KEY= # must match LOCAL_BACKEND_API_KEY on agent-server
# LOCAL_BACKEND_API_KEY= # generate: openssl rand -hex 32
# ----- Auth (NextAuth v5) ----- # ----- Auth (NextAuth v5) -----
# Local: http://localhost:3000 • Prod: https://echodo.stillwell.cloud # Local: http://localhost:3000 • Prod: https://echodo.stillwell.cloud
NEXTAUTH_URL="http://localhost:3000" NEXTAUTH_URL="http://localhost:3000"
@ -89,11 +52,10 @@ AUTH_AUTHENTIK_ISSUER="https://auth.stillwell.cloud/application/o/echodo/"
# ----- Realtime collab server (Hocuspocus / Yjs) ----- # ----- Realtime collab server (Hocuspocus / Yjs) -----
# Server-side reference (used by Next.js server actions, if any) # Server-side reference (used by Next.js server actions, if any)
COLLAB_SERVER_URL="ws://localhost:1234" COLLAB_SERVER_URL="ws://localhost:1234"
# Browser-facing — must be wss:// in production because the page is https. # 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.
NEXT_PUBLIC_COLLAB_SERVER_URL="ws://localhost:1234" 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. # In Umami: Settings → Websites → Add website. Copy the website UUID.
NEXT_PUBLIC_UMAMI_SCRIPT="https://analytics.stillwell.cloud/script.js" NEXT_PUBLIC_UMAMI_SCRIPT="https://analytics.stillwell.cloud/script.js"
NEXT_PUBLIC_UMAMI_WEBSITE_ID="" NEXT_PUBLIC_UMAMI_WEBSITE_ID=""
@ -102,66 +64,24 @@ NEXT_PUBLIC_UMAMI_WEBSITE_ID=""
NEXT_PUBLIC_LIBREDESK_URL="https://support.stillwell.cloud" NEXT_PUBLIC_LIBREDESK_URL="https://support.stillwell.cloud"
NEXT_PUBLIC_LIBREDESK_WIDGET_ID="" 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 # Static token scoped write-only on the `feedback` collection
NEXT_PUBLIC_DIRECTUS_URL="https://directus.stillwell.cloud" NEXT_PUBLIC_DIRECTUS_URL="https://directus.stillwell.cloud"
DIRECTUS_FEEDBACK_TOKEN="" DIRECTUS_FEEDBACK_TOKEN=""
# ----- AI Providers ----- # ----- AI Providers -----
# Registry aliases (Epic 1) read these env var NAMES from the DB — values # Use OPENAI_BASE_URL=http://192.168.68.108:11434/v1 to route through
# live here on the server/worker host, never in Postgres. # local Ollama (CT 108) instead of the OpenAI API.
#
# 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):
OPENAI_API_KEY="" OPENAI_API_KEY=""
OPENAI_BASE_URL="" OPENAI_BASE_URL=""
ANTHROPIC_API_KEY="" 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 -----
MCP_SERVER_PORT=3001 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) # 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_USER=postgres
POSTGRES_PASSWORD=postgres POSTGRES_PASSWORD=postgres
@ -171,13 +91,4 @@ POSTGRES_DB=echodo
POSTGRES_PORT=5432 POSTGRES_PORT=5432
REDIS_PORT=6379 REDIS_PORT=6379
WEB_PORT=3000 WEB_PORT=3000
# ----- Chat (durable channel messaging) -----
# Shared secret for signing short-lived subscription tickets. Must match on web and collab.
# Generate at least 32 random bytes, for example: openssl rand -hex 32
CHAT_TICKET_SECRET="CHANGE_ME_USE_A_RANDOM_32_BYTE_SECRET"
# Internal collab chat listener port (distinct from Hocuspocus/Yjs on 1234)
CHAT_PORT=1235
# Public WSS endpoint, baked into the web bundle at build time. Rebuild web after changing.
NEXT_PUBLIC_CHAT_SERVER_URL="wss://echodo-chat.stillwell.cloud"
COLLAB_PORT=1234 COLLAB_PORT=1234

View file

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

View file

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

33
.github/CODEOWNERS vendored Normal file
View file

@ -0,0 +1,33 @@
# CODEOWNERS — review routing for the agent pipeline.
# Global default — solo maintainer for the personal repo
* @rstillw
# High-risk: auth, secrets, payments (Echodo has none yet but reserved)
apps/web/auth.ts @rstillw
apps/web/lib/auth/** @rstillw
apps/web/middleware.ts @rstillw
# Deploy infra — Coolify production
docker/Dockerfile* @rstillw
docker/docker-compose.coolify.yml @rstillw
docker/next.config.docker.ts @rstillw
# CI workflows
.github/workflows/** @rstillw
# Database — Drizzle schema + migrations
packages/database/src/schema/** @rstillw
packages/database/migrations/** @rstillw
# MCP server — Echodo's agent surface; breakage cascades to every consumer
apps/mcp-server/** @rstillw
# Cursor sync contract — touching this changes app ↔ markdown behavior
config/CursorSync.md @rstillw
# Agent context — pipeline-managed surface; review every change
.cursor/agents/** @rstillw
.cursor/rules/** @rstillw
AGENTS.md @rstillw
.agent-context-manifest.yml @rstillw

44
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View file

@ -0,0 +1,44 @@
<!--
pipeline: convoy=<slug>, brief=<N>
skip: <comma-separated flags or empty>
Skip flags (Conductor sets these — do not edit by hand):
ia, ux, arch, test, review, visual, a11y, design, smoke, qa, docs, flag
Never skip: plan-approval, pr-merge, prod-promote
-->
## Summary
<!-- 2-3 bullets: what changed and why. User-facing language preferred. -->
## Convoy + Brief
- Convoy: `.convoys/<slug>.md`
- Brief: `.convoys/<slug>/brief-<N>-...md`
## Acceptance criteria
<!-- Copy from the brief; check off as you complete. -->
- [ ]
- [ ]
- [ ] No scope expansion (only files listed in the brief's `files:` were edited)
## Test plan
<!-- What was tested, how, and what wasn't tested with rationale. -->
## Pipeline gates
<!-- Filled in by CI / role-reviewer. Don't edit. -->
- [ ] CI: lint, types, build, unit tests
- [ ] Visual diff (if UI change)
- [ ] A11y audit (if UI change)
- [ ] Design-system audit (if UI change)
- [ ] Reviewer report
- [ ] Smoke on staging (after merge to develop)
## Notes for reviewer
<!-- Anything unusual, intentional trade-offs, or follow-ups. -->

View file

@ -0,0 +1,164 @@
# agent-context drift detection
#
# Weekly + on-demand check: does this repo's installed agent-pipeline
# artifacts match the latest upstream pipeline release?
#
# - Reads .agent-context-manifest.yml (committed at repo root)
# - Clones the pipeline repo at its latest tag
# - Compares each tracked artifact's installed_hash to the pipeline source hash
# - Compares manifest pipeline_version to pipeline version.txt
# - Opens (or updates) an issue titled "agent-context: N files behind v<X>"
# if drift is detected
#
# No auto-fix. The fix workflow is: a human runs `sync-agent-context` in
# Cursor and reviews per-file diffs.
name: agent-context-drift
on:
schedule:
# Mondays at 13:00 UTC. Adjust to taste.
- cron: "0 13 * * 1"
workflow_dispatch:
permissions:
contents: read
issues: write
jobs:
drift:
runs-on: ubuntu-latest
steps:
- name: Checkout consumer repo
uses: actions/checkout@v4
- name: Read manifest
id: manifest
run: |
if [ ! -f .agent-context-manifest.yml ]; then
echo "::warning::No .agent-context-manifest.yml — agent-pipeline not installed or pre-v0.3.0. Skipping drift check."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
INSTALLED=$(grep -E '^pipeline_version:' .agent-context-manifest.yml | head -1 | sed -E 's/.*"(.*)".*/\1/')
SOURCE=$(grep -E '^pipeline_source:' .agent-context-manifest.yml | head -1 | sed -E 's/.*"(.*)".*/\1/')
echo "installed_version=$INSTALLED" >> "$GITHUB_OUTPUT"
echo "pipeline_source=$SOURCE" >> "$GITHUB_OUTPUT"
echo "skip=false" >> "$GITHUB_OUTPUT"
- name: Clone pipeline at latest tag
if: steps.manifest.outputs.skip != 'true'
id: pipeline
run: |
PIPELINE_URL="${{ steps.manifest.outputs.pipeline_source }}"
# Convert HTTPS URL → clone target. Already in HTTPS form.
mkdir -p /tmp/pipeline
git clone --depth 50 "$PIPELINE_URL" /tmp/pipeline
cd /tmp/pipeline
LATEST_TAG=$(git tag --sort=-v:refname | head -1)
if [ -z "$LATEST_TAG" ]; then
echo "::warning::Pipeline repo has no tags. Comparing against main."
LATEST_TAG="main"
fi
git checkout "$LATEST_TAG"
PIPELINE_VER=$(cat version.txt | tr -d '[:space:]')
echo "tag=$LATEST_TAG" >> "$GITHUB_OUTPUT"
echo "version=$PIPELINE_VER" >> "$GITHUB_OUTPUT"
- name: Compute drift
if: steps.manifest.outputs.skip != 'true'
id: drift
run: |
INSTALLED="${{ steps.manifest.outputs.installed_version }}"
UPSTREAM="${{ steps.pipeline.outputs.version }}"
BEHIND=0
CUSTOMIZED=0
CONFLICT=0
# Walk manifest artifacts. For each, compare installed_hash to local
# current hash, and pipeline source hash to installed_hash.
# YAML parsing in bash is intentionally minimal — relies on the
# bootstrap skill emitting a predictable shape.
python3 - <<'PY' >> drift-report.md
import hashlib, sys, yaml, os
def sha(path):
if not os.path.exists(path):
return None
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
h.update(chunk)
return "sha256:" + h.hexdigest()
with open(".agent-context-manifest.yml") as f:
m = yaml.safe_load(f)
counts = {"behind": [], "customized": [], "conflict": [], "deleted": []}
for art in m.get("artifacts", []):
local_hash = sha(art["path"])
pipe_hash = sha(os.path.join("/tmp/pipeline", art["source"]))
if local_hash is None:
counts["deleted"].append(art["path"])
continue
local_matches = local_hash == art["installed_hash"]
pipe_changed = pipe_hash is not None and pipe_hash != art["installed_hash"]
if local_matches and pipe_changed:
counts["behind"].append(art["path"])
elif not local_matches and pipe_changed:
counts["conflict"].append(art["path"])
elif not local_matches:
counts["customized"].append(art["path"])
print("# agent-context drift report")
print(f"\nInstalled: `{m.get('pipeline_version')}` · Upstream: `${{ steps.pipeline.outputs.version }}` (`${{ steps.pipeline.outputs.tag }}`)")
for kind in ("behind", "conflict", "customized", "deleted"):
files = counts[kind]
if files:
print(f"\n## {kind} ({len(files)})")
for f in files:
print(f"- `{f}`")
PY
BEHIND=$(grep -c '^## behind' drift-report.md || echo 0)
CONFLICT=$(grep -c '^## conflict' drift-report.md || echo 0)
NEED_ISSUE="false"
if [ "$INSTALLED" != "$UPSTREAM" ] || [ "$BEHIND" -gt 0 ] || [ "$CONFLICT" -gt 0 ]; then
NEED_ISSUE="true"
fi
echo "need_issue=$NEED_ISSUE" >> "$GITHUB_OUTPUT"
echo "upstream_version=$UPSTREAM" >> "$GITHUB_OUTPUT"
- name: Open / update drift issue
if: steps.manifest.outputs.skip != 'true' && steps.drift.outputs.need_issue == 'true'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const body = fs.readFileSync('drift-report.md', 'utf8') +
'\n\n---\n\n_To resolve: open this repo in Cursor and ask **"Sync agent context for this repo"**. The sync skill walks the diff per file._';
const title = `agent-context: behind ${{ steps.drift.outputs.upstream_version }}`;
const existing = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
labels: 'agent-context-drift',
});
const found = existing.data.find(i => i.title.startsWith('agent-context: behind'));
if (found) {
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: found.number,
title,
body,
});
} else {
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title,
body,
labels: ['agent-context-drift'],
});
}

View file

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

87
.github/workflows/pr-health-rollup.yml vendored Normal file
View file

@ -0,0 +1,87 @@
name: PR Health rollup
# Adapted for tasks (Echodo): single-job CI (Lint, type-check, test),
# Coolify deploy (no per-PR preview), Drizzle (no Prisma schema-map drift).
#
# Coolify does NOT post a check to GitHub on deploy by default — there's no
# "build" row to read from a third-party check. The build itself happens AFTER
# merge, on Coolify's runner.
on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened, labeled, unlabeled]
workflow_run:
workflows: [CI]
types: [completed]
permissions:
pull-requests: write
issues: write
checks: read
jobs:
rollup:
name: Aggregate gate status
runs-on: ubuntu-latest
steps:
- name: Compute status + post sticky comment
uses: actions/github-script@v7
with:
script: |
const { owner, repo } = context.repo;
const pr_number = context.payload.pull_request?.number
?? context.payload.workflow_run?.pull_requests?.[0]?.number;
if (!pr_number) {
core.info('No PR context — skipping rollup.');
return;
}
const pr = (await github.rest.pulls.get({ owner, repo, pull_number: pr_number })).data;
const sha = pr.head.sha;
const checks = (await github.rest.checks.listForRef({ owner, repo, ref: sha, per_page: 100 })).data.check_runs;
const find = (name) => checks.find(c => c.name === name);
const skip = (flag) =>
new RegExp(`pipeline:.*skip[^\\n]*\\b${flag}\\b`).test(pr.body || '');
const row = (label, run, opt = false) => {
if (!run) return `| ${label} | ${opt ? '⏭ skipped or pending' : '⏳ pending'} |`;
if (run.status !== 'completed') return `| ${label} | ⏳ in progress |`;
const ok = run.conclusion === 'success';
return `| ${label} | ${ok ? '✅ pass' : '❌ ' + run.conclusion} |`;
};
const rows = [
row('CI: Lint, type-check, test', find('Lint, type-check, test')),
];
const comments = (await github.rest.issues.listComments({
owner, repo, issue_number: pr_number, per_page: 100,
})).data;
const reviewer_comment = comments.find(c => c.body?.startsWith('## Reviewer Report'));
const a11y_comment = comments.find(c => c.body?.startsWith('## A11y Audit'));
const ds_comment = comments.find(c => c.body?.startsWith('## Design System Audit'));
const role_row = (label, c, skipped) =>
skipped ? `| ${label} | ⏭ skipped |` : c ? `| ${label} | ✅ posted |` : `| ${label} | ⏳ pending |`;
const role_rows = [
role_row('Reviewer report', reviewer_comment, skip('review')),
role_row('A11y audit', a11y_comment, skip('a11y')),
role_row('Design system audit', ds_comment, skip('design')),
];
const marker = '<!-- pipeline-rollup -->';
const body = `${marker}\n## Pipeline Health\n\n### CI gates\n\n| Gate | Status |\n| --- | --- |\n${rows.join('\n')}\n\n_Build runs on Coolify post-merge; this CI runs lint + type-check + tests only. No per-PR preview URL by default._\n\n### Role reports\n\n| Role | Status |\n| --- | --- |\n${role_rows.join('\n')}\n\nSee individual comments above for details. This rollup updates automatically.`;
const existing = comments.find(c => c.body?.startsWith(marker));
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number: pr_number, body });
}

3
.gitignore vendored
View file

@ -56,5 +56,6 @@ docker/**/data/
*.pem *.pem
coverage/ coverage/
# Convoy pipeline metrics (local-only by default; see .convoys/README.md) # agent pipeline — local-only convoy runtime files
.convoys/.metrics.jsonl .convoys/.metrics.jsonl
.convoys/.pending-mcp-sync.jsonl

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -10,7 +10,7 @@ const mcp = new McpServer(
}, },
{ {
instructions: 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, auditLog,
markdownBacklogItems, markdownBacklogItems,
} from "../schema.js"; } 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 { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
import { resolveWorkflowPrompt } from "../../../../packages/database/src/markdown-backlog/resolve-prompt.ts"; import { resolveWorkflowPrompt } from "../../../../packages/database/src/markdown-backlog/resolve-prompt.ts";
import { exportBacklogItemToMarkdown } from "../../../../packages/database/src/markdown-backlog/export.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. * `agent_runs` separately.
*/ */
const claimTaskInputSchema = z const claimTaskInputSchema = z.object({
.object({
workspace: z workspace: z
.string() .string()
.min(1) .min(1)
@ -55,10 +48,7 @@ const claimTaskInputSchema = z
backlogItemId: z backlogItemId: z
.string() .string()
.uuid() .uuid()
.optional() .describe("UUID of the markdown_backlog_items row being claimed."),
.describe(
"UUID of the markdown_backlog_items row being claimed. Optional when repoPath or slug keys are provided.",
),
actorUserId: z actorUserId: z
.string() .string()
.uuid() .uuid()
@ -88,20 +78,7 @@ const claimTaskInputSchema = z
.describe( .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.", "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).",
});
}
});
/** /**
* Build the metadata object stored on agent_runs at claim time. Returns * Build the metadata object stored on agent_runs at claim time. Returns
@ -123,7 +100,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
"claim_task", "claim_task",
{ {
description: 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, inputSchema: claimTaskInputSchema,
}, },
async (args) => { async (args) => {
@ -131,42 +108,27 @@ export function registerClaimTaskTool(mcp: McpServer): void {
const input = claimTaskInputSchema.parse(args); const input = claimTaskInputSchema.parse(args);
const ws = await resolveWorkspaceHandle(input.workspace); const ws = await resolveWorkspaceHandle(input.workspace);
let item; const [item] = await db
let backlogItemId: string; .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) { if (!item) {
const found = await resolveBacklogItemById(input.backlogItemId);
if (!found) {
return toolErr(`Backlog item ${input.backlogItemId} not found`); return toolErr(`Backlog item ${input.backlogItemId} not found`);
} }
item = found;
backlogItemId = found.id;
} else {
const matches = await resolveBacklogItemsByLookup(ws.id, {
repoPath: input.repoPath,
slug: input.slug,
planSlug: input.planSlug,
epicSlug: input.epicSlug,
kind: input.kind,
});
if (matches.length === 0) {
return toolErr(BACKLOG_ITEM_NOT_FOUND_MESSAGE);
}
if (matches.length > 1) {
return toolErr(
`Ambiguous backlog lookup (${matches.length} matches). Add epicSlug/kind or use repoPath.`,
);
}
item = matches[0]!;
backlogItemId = item.id;
}
if (item.workspaceId !== ws.id) { if (item.workspaceId !== ws.id) {
// Tenancy fence: the backlog item exists but in a different // Tenancy fence: the backlog item exists but in a different
// workspace than the resolved handle. Refuse rather than 404 so // workspace than the resolved handle. Refuse rather than 404 so
// the caller can't fish for cross-tenant existence. // the caller can't fish for cross-tenant existence.
return toolErr( 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) .from(agentRuns)
.where( .where(
and( and(
eq(agentRuns.backlogItemId, backlogItemId), eq(agentRuns.backlogItemId, input.backlogItemId),
isNull(agentRuns.finishedAt), isNull(agentRuns.finishedAt),
), ),
) )
@ -228,7 +190,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
const resolved = await resolveWorkflowPrompt(db, { const resolved = await resolveWorkflowPrompt(db, {
workspaceId: ws.id, workspaceId: ws.id,
backlogItemId, backlogItemId: input.backlogItemId,
}); });
return toolOk({ return toolOk({
@ -241,7 +203,6 @@ export function registerClaimTaskTool(mcp: McpServer): void {
title: item.title, title: item.title,
bodyMarkdown: item.bodyMarkdown, bodyMarkdown: item.bodyMarkdown,
status: item.status, status: item.status,
repoPath: item.repoPath,
}, },
workspace: { id: ws.id, slug: ws.slug, name: ws.name }, workspace: { id: ws.id, slug: ws.slug, name: ws.name },
}); });
@ -258,7 +219,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
.insert(agentRuns) .insert(agentRuns)
.values({ .values({
workspaceId: ws.id, workspaceId: ws.id,
backlogItemId, backlogItemId: input.backlogItemId,
actorUserId, actorUserId,
notes: input.notes ?? null, notes: input.notes ?? null,
metadata: claimMeta, metadata: claimMeta,
@ -269,7 +230,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
await tx await tx
.update(markdownBacklogItems) .update(markdownBacklogItems)
.set({ status: "in_progress", updatedAt: new Date() }) .set({ status: "in_progress", updatedAt: new Date() })
.where(eq(markdownBacklogItems.id, backlogItemId)); .where(eq(markdownBacklogItems.id, input.backlogItemId));
} }
await tx.insert(auditLog).values({ await tx.insert(auditLog).values({
@ -279,7 +240,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
targetType: "agent_run", targetType: "agent_run",
targetId: created.id, targetId: created.id,
metadata: { metadata: {
backlog_item_id: backlogItemId, backlog_item_id: input.backlogItemId,
prior_status: priorStatus, prior_status: priorStatus,
transitioned: shouldTransition, transitioned: shouldTransition,
...(claimMeta ?? {}), ...(claimMeta ?? {}),
@ -292,7 +253,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
const resolved = await resolveWorkflowPrompt(db, { const resolved = await resolveWorkflowPrompt(db, {
workspaceId: ws.id, workspaceId: ws.id,
backlogItemId, backlogItemId: input.backlogItemId,
}); });
// Only export when the row's status actually changed. On a // Only export when the row's status actually changed. On a
@ -308,7 +269,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
try { try {
const exportResult = await exportBacklogItemToMarkdown(db, { const exportResult = await exportBacklogItemToMarkdown(db, {
workspaceId: ws.id, workspaceId: ws.id,
backlogItemId, backlogItemId: input.backlogItemId,
repoRootAbs: repoRoot, repoRootAbs: repoRoot,
}); });
if (exportResult.ok) { if (exportResult.ok) {
@ -341,7 +302,6 @@ export function registerClaimTaskTool(mcp: McpServer): void {
title: item.title, title: item.title,
bodyMarkdown: item.bodyMarkdown, bodyMarkdown: item.bodyMarkdown,
status: shouldTransition ? "in_progress" : priorStatus, status: shouldTransition ? "in_progress" : priorStatus,
repoPath: item.repoPath,
}, },
workspace: { id: ws.id, slug: ws.slug, name: ws.name }, workspace: { id: ws.id, slug: ws.slug, name: ws.name },
export: exportSummary, export: exportSummary,

View file

@ -1,6 +1,5 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod"; import { z } from "zod";
import { modelTierSchema, providerTypeSchema } from "@tasks/shared/model-routing";
import { db } from "../db.js"; import { db } from "../db.js";
import { eq } from "../drizzle.js"; import { eq } from "../drizzle.js";
import { agentRuns, auditLog, markdownBacklogItems } from "../schema.js"; import { agentRuns, auditLog, markdownBacklogItems } from "../schema.js";
@ -45,15 +44,6 @@ const completeTaskInputSchema = z.object({
.describe( .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.", "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>; type CompleteTaskInput = z.infer<typeof completeTaskInputSchema>;
@ -125,10 +115,6 @@ export function registerCompleteTaskTool(mcp: McpServer): void {
const incomingMeta: Record<string, unknown> = {}; const incomingMeta: Record<string, unknown> = {};
if (input.client) incomingMeta.client = input.client; if (input.client) incomingMeta.client = input.client;
if (input.model) incomingMeta.model = input.model; 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 hasIncomingMeta = Object.keys(incomingMeta).length > 0;
const mergedMeta = hasIncomingMeta const mergedMeta = hasIncomingMeta
? { ...(run.metadata ?? {}), ...incomingMeta } ? { ...(run.metadata ?? {}), ...incomingMeta }
@ -145,14 +131,6 @@ export function registerCompleteTaskTool(mcp: McpServer): void {
tokensTotal, tokensTotal,
notes: input.notes ?? null, notes: input.notes ?? null,
error: input.error ?? 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 } : {}), ...(hasIncomingMeta ? { metadata: mergedMeta } : {}),
}) })
.where(eq(agentRuns.id, input.runId)); .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,72 +1,119 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { eq } from "../drizzle.js";
import { z } from "zod"; import { z } from "zod";
import { briefObjectContentSchema } from "@tasks/shared/convoy-lifecycle";
import { db } from "../db.js"; import { db } from "../db.js";
import { objects } from "../schema.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"; import { toolCatch, toolErr, toolOk } from "./tool-result.js";
/**
* create_brief — Phase 2a lifecycle MCP tool.
*
* Convention (see agent-pipeline plan §7): a brief is a `task` object whose
* `parentId` is the convoy (`project`). Status starts at "ready" (the brief
* itself is ready to dispatch; the convoy's status governs the lifecycle).
*
* Local-first: role-architect writes `.convoys/<slug>/brief-N-*.md` FIRST,
* then calls this. If this fails, queues to `.pending-mcp-sync.jsonl`.
*/
const createBriefInputSchema = z.object({ const createBriefInputSchema = z.object({
workspace: z.string().min(1), convoyId: z
convoyId: z.string().uuid(), .string()
briefNumber: z.number().int().positive(), .uuid()
.describe("Convoy project id (returned by create_convoy)."),
briefNumber: z.number().int().min(1).max(99),
title: z.string().min(1).max(500), title: z.string().min(1).max(500),
files: z.array(z.string()).default([]), filesAllowlist: z
sliceDependencies: z.array(z.number().int().positive()).default([]), .array(z.string())
acceptance: z.string().optional(), .min(1)
bodyMarkdown: z.string().optional(), .describe(
"Files this brief is allowed to edit. Used by the reviewer to detect scope expansion.",
),
dependsOn: z
.array(z.number().int().min(1).max(99))
.default([])
.describe(
"Brief numbers this brief depends on. Empty = parallel-safe (can fan out via /multitask).",
),
acceptanceCriteria: z
.array(z.string().min(1))
.min(1)
.describe("Acceptance criteria, one per line."),
briefMarkdown: z
.string()
.min(1)
.describe(
"Brief body. Becomes the task description; the agent reads only this file when implementing.",
),
}); });
function initialDescription(input: z.infer<typeof createBriefInputSchema>): string {
const meta = [
`<!-- pipeline:brief -->`,
`<!-- brief: ${input.briefNumber} -->`,
`<!-- depends_on: ${input.dependsOn.length === 0 ? "[]" : input.dependsOn.join(",")} -->`,
`<!-- files: ${input.filesAllowlist.join(",")} -->`,
].join("\n");
const acceptance = [
"## Acceptance criteria",
"",
...input.acceptanceCriteria.map((c) => `- [ ] ${c}`),
].join("\n");
return `${meta}\n\n${input.briefMarkdown.trim()}\n\n${acceptance}\n`;
}
export function registerCreateBriefTool(mcp: McpServer): void { export function registerCreateBriefTool(mcp: McpServer): void {
mcp.registerTool( mcp.registerTool(
"create_brief", "create_brief",
{ {
description: description:
"Create an implementer brief as a task object child of a convoy project.", "Create a brief as a `task` child of a convoy `project`. Validates that the parent exists and is a project.",
inputSchema: createBriefInputSchema, inputSchema: createBriefInputSchema,
}, },
async (args) => { async (args) => {
try { try {
const input = createBriefInputSchema.parse(args); const input = createBriefInputSchema.parse(args);
const ws = await resolveWorkspaceHandle(input.workspace);
const convoy = await loadConvoyProject(input.convoyId, ws.id); const [parent] = await db
if (!convoy) { .select({ id: objects.id, type: objects.type, workspaceId: objects.workspaceId })
return toolErr(`Convoy ${input.convoyId} not found in workspace ${ws.slug}`); .from(objects)
.where(eq(objects.id, input.convoyId))
.limit(1);
if (!parent) {
return toolErr(`Convoy not found: ${input.convoyId}`);
}
if (parent.type !== "project") {
return toolErr(
`Object ${input.convoyId} is type '${parent.type}', expected 'project'. Briefs must be children of convoys.`,
);
} }
const content = briefObjectContentSchema.parse({
briefNumber: input.briefNumber,
files: input.files,
sliceDependencies: input.sliceDependencies,
acceptance: input.acceptance,
});
const [created] = await db const [created] = await db
.insert(objects) .insert(objects)
.values({ .values({
type: "task", type: "task",
title: input.title, title: `Brief ${input.briefNumber}: ${input.title}`,
parentId: convoy.id, parentId: parent.id,
workspaceId: ws.id, workspaceId: parent.workspaceId,
description: input.bodyMarkdown ?? input.acceptance ?? null, description: initialDescription(input),
status: "ready", status: "ready",
content,
sortOrder: input.briefNumber,
}) })
.returning(); .returning();
if (!created) { if (!created) {
return toolErr("Failed to create brief"); return toolErr("Failed to create brief task");
} }
return toolOk({ return toolOk({
workspace: { id: ws.id, slug: ws.slug, name: ws.name }, brief: {
convoyId: convoy.id, id: created.id,
brief: created, briefNumber: input.briefNumber,
title: created.title,
convoyId: parent.id,
filesAllowlist: input.filesAllowlist,
dependsOn: input.dependsOn,
},
}); });
} catch (e) { } catch (e) {
return toolCatch(e); return toolCatch(e);

View file

@ -1,42 +1,94 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod"; import { z } from "zod";
import {
convoyClassificationSchema,
convoyObjectContentSchema,
} from "@tasks/shared/convoy-lifecycle";
import { db } from "../db.js"; import { db } from "../db.js";
import { objects } from "../schema.js"; import { objects } from "../schema.js";
import { findConvoyBySlug } from "../lib/convoy-objects.js";
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js"; import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
import { toolCatch, toolErr, toolOk } from "./tool-result.js"; import { toolCatch, toolErr, toolOk } from "./tool-result.js";
/**
* create_convoy — Phase 2a lifecycle MCP tool.
*
* Convention (see agent-pipeline plan §7): a convoy is a `project` object in
* the workspace `convoys-<repo>`. Status starts at "draft". The description
* is the convoy markdown body + a "## Status log" section that
* `transition_convoy_status` appends to.
*
* Local-first: the role writes `.convoys/<slug>.md` FIRST, then calls this.
* If this call fails, the role queues to `.convoys/.pending-mcp-sync.jsonl`
* and `reconcile_from_files` replays later.
*/
const classificationEnum = z.enum([
"feature",
"hotfix",
"docs-only",
"infra-only",
"server-only",
"config-only",
]);
const createConvoyInputSchema = z.object({ const createConvoyInputSchema = z.object({
workspace: z.string().min(1), workspace: z
.string()
.min(1)
.describe(
"Workspace UUID or slug (e.g. 'convoys-tasks'). Workspace must already exist.",
),
slug: z slug: z
.string() .string()
.min(1) .min(1)
.max(120) .max(100)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "slug must be kebab-case"), .regex(/^[a-z0-9][a-z0-9-]*$/, "slug must be kebab-case")
classification: convoyClassificationSchema, .describe("Convoy slug. Matches the filename: .convoys/<slug>.md"),
skipFlags: z.array(z.string()).default([]), title: z.string().min(1).max(500),
successMetric: z.string().min(1).max(2_000), classification: classificationEnum,
ideaMarkdown: z.string().min(1), skipFlags: z
modelPolicy: z .array(z.string())
.object({ .default([])
roles: z.record(z.string()).optional(), .describe(
escalate_to: z.string().optional(), "Conductor-set skip flags (ia, ux, arch, test, review, visual, a11y, design, smoke, qa, docs, flag).",
}) ),
successMetric: z.string().min(1).describe("One-sentence success metric."),
ideaMarkdown: z
.string()
.min(1)
.describe(
"The convoy markdown body. Becomes the description, with a '## Status log' appended.",
),
stackClass: z
.enum(["nextjs-prisma", "nextjs", "node-generic", "non-node", "other"])
.optional(), .optional(),
repo: z
.string()
.min(1)
.describe(
"Consumer repo basename (e.g. 'tasks', 'zest'). Used to disambiguate cross-repo queries.",
),
}); });
function initialDescription(input: z.infer<typeof createConvoyInputSchema>): string {
const skipLine = input.skipFlags.length > 0 ? input.skipFlags.join(", ") : "—";
const meta = [
`<!-- pipeline:convoy -->`,
`<!-- repo: ${input.repo} -->`,
`<!-- classification: ${input.classification} -->`,
`<!-- skip_flags: ${skipLine} -->`,
`<!-- success_metric: ${input.successMetric.replace(/\n/g, " ")} -->`,
].join("\n");
const statusLog = [
`## Status log`,
"",
`- ${new Date().toISOString()} \`draft\` (set by role-conductor on create)`,
].join("\n");
return `${meta}\n\n${input.ideaMarkdown.trim()}\n\n${statusLog}\n`;
}
export function registerCreateConvoyTool(mcp: McpServer): void { export function registerCreateConvoyTool(mcp: McpServer): void {
mcp.registerTool( mcp.registerTool(
"create_convoy", "create_convoy",
{ {
description: description:
"Create a convoy project object (type=project, status=draft) in the workspace convoys-* tenant.", "Create a new convoy as a `project` object in the convoys-<repo> workspace. Status starts at 'draft'. Idempotent on (workspace, slug): if a project with the same title already exists in this workspace, returns the existing one without creating a duplicate.",
inputSchema: createConvoyInputSchema, inputSchema: createConvoyInputSchema,
}, },
async (args) => { async (args) => {
@ -44,38 +96,32 @@ export function registerCreateConvoyTool(mcp: McpServer): void {
const input = createConvoyInputSchema.parse(args); const input = createConvoyInputSchema.parse(args);
const ws = await resolveWorkspaceHandle(input.workspace); 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 const [created] = await db
.insert(objects) .insert(objects)
.values({ .values({
type: "project", type: "project",
title: input.slug, title: input.title,
workspaceId: ws.id, workspaceId: ws.id,
description: input.ideaMarkdown, description: initialDescription(input),
status: "draft", status: "draft",
content, parentId: null,
}) })
.returning(); .returning();
if (!created) { if (!created) {
return toolErr("Failed to create convoy"); return toolErr("Failed to create convoy project");
} }
return toolOk({ return toolOk({
workspace: { id: ws.id, slug: ws.slug, name: ws.name }, workspace: { id: ws.id, slug: ws.slug, name: ws.name },
convoy: created, convoy: {
id: created.id,
slug: input.slug,
title: created.title,
status: created.status,
classification: input.classification,
skipFlags: input.skipFlags,
},
}); });
} catch (e) { } catch (e) {
return toolCatch(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,8 +6,6 @@ import { registerSearchObjectsTool } from "./search-objects.js";
import { registerUpdateObjectTool } from "./update-object.js"; import { registerUpdateObjectTool } from "./update-object.js";
import { registerClaimTaskTool } from "./claim-task.js"; import { registerClaimTaskTool } from "./claim-task.js";
import { registerCompleteTaskTool } from "./complete-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 { registerCreateConvoyTool } from "./create-convoy.js";
import { registerCreateBriefTool } from "./create-brief.js"; import { registerCreateBriefTool } from "./create-brief.js";
import { registerTransitionConvoyStatusTool } from "./transition-convoy-status.js"; import { registerTransitionConvoyStatusTool } from "./transition-convoy-status.js";
@ -16,6 +14,7 @@ import { registerQueryManifestStatusTool } from "./query-manifest-status.js";
import { registerReconcileFromFilesTool } from "./reconcile-from-files.js"; import { registerReconcileFromFilesTool } from "./reconcile-from-files.js";
export function registerTools(mcp: McpServer): void { export function registerTools(mcp: McpServer): void {
// Object CRUD (pre-existing)
registerCreateObjectTool(mcp); registerCreateObjectTool(mcp);
registerUpdateObjectTool(mcp); registerUpdateObjectTool(mcp);
registerSearchObjectsTool(mcp); registerSearchObjectsTool(mcp);
@ -23,8 +22,8 @@ export function registerTools(mcp: McpServer): void {
registerManageObjectTool(mcp); registerManageObjectTool(mcp);
registerClaimTaskTool(mcp); registerClaimTaskTool(mcp);
registerCompleteTaskTool(mcp); registerCompleteTaskTool(mcp);
registerCreateBacklogItemTool(mcp);
registerFindBacklogItemTool(mcp); // Agent-pipeline lifecycle bridge (Phase 2a, v0.4)
registerCreateConvoyTool(mcp); registerCreateConvoyTool(mcp);
registerCreateBriefTool(mcp); registerCreateBriefTool(mcp);
registerTransitionConvoyStatusTool(mcp); registerTransitionConvoyStatusTool(mcp);
@ -40,8 +39,6 @@ export { registerListObjectsTool } from "./list-objects.js";
export { registerManageObjectTool } from "./manage-object.js"; export { registerManageObjectTool } from "./manage-object.js";
export { registerClaimTaskTool } from "./claim-task.js"; export { registerClaimTaskTool } from "./claim-task.js";
export { registerCompleteTaskTool } from "./complete-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 { registerCreateConvoyTool } from "./create-convoy.js";
export { registerCreateBriefTool } from "./create-brief.js"; export { registerCreateBriefTool } from "./create-brief.js";
export { registerTransitionConvoyStatusTool } from "./transition-convoy-status.js"; export { registerTransitionConvoyStatusTool } from "./transition-convoy-status.js";

View file

@ -1,34 +1,82 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod"; import { z } from "zod";
import { db } from "../db.js";
import { convoyEventTypeSchema } from "@tasks/shared/convoy-lifecycle"; import { convoyEvents } from "../schema.js";
import {
insertConvoyEvent,
loadConvoyProject,
} from "../lib/convoy-objects.js";
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js"; import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
import { toolCatch, toolErr, toolOk } from "./tool-result.js"; import { toolCatch, toolErr, toolOk } from "./tool-result.js";
/**
* log_convoy_event — Phase 2a lifecycle MCP tool.
*
* Inserts a row into convoy_events. Mirrors the local
* `.convoys/.metrics.jsonl` shape (schema: agent-pipeline/analytics/schemas/convoy-event.json).
*
* Replaces the JSONL-only output of `scripts/log-convoy-event.sh` when Echodo
* is reachable. The shell script will be updated in agent-pipeline to attempt
* this MCP call first, falling back to JSONL append.
*
* IMPORTANT: requires the convoy_events Drizzle migration. After the schema
* file lands, run:
* pnpm --filter @tasks/database db:generate
* pnpm --filter @tasks/database db:migrate
* Until the migration is applied, this tool will error with "relation
* convoy_events does not exist".
*/
const Role = [
"role-conductor",
"role-ia-architect",
"role-ux-reviewer",
"role-architect",
"role-implementer",
"role-reviewer",
"role-design-system-auditor",
"role-a11y-auditor",
"role-doc-writer",
] as const;
const Classification = [
"feature",
"hotfix",
"docs-only",
"infra-only",
"server-only",
"config-only",
] as const;
const StackClass = [
"nextjs-prisma",
"nextjs",
"node-generic",
"non-node",
"other",
] as const;
const Outcome = ["complete", "blocked", "abandoned"] as const;
const logConvoyEventInputSchema = z.object({ const logConvoyEventInputSchema = z.object({
workspace: z.string().min(1), workspace: z
convoyId: z.string().uuid(), .string()
role: z.string().min(1).max(64), .min(1)
eventType: convoyEventTypeSchema.default("role_complete"), .describe("Workspace UUID or slug (convoys-<repo>)."),
briefId: z.string().uuid().optional(), convoyId: z
actor: z.string().min(1).max(64).optional(), .string()
classification: z.string().optional(), .uuid()
skipFlags: z.array(z.string()).optional(), .nullable()
durationS: z.number().int().nonnegative().optional(), .optional()
outcome: z.string().optional(), .describe(
repo: z.string().optional(), "Convoy project id. Null is allowed for events emitted before create_convoy returns; use convoySlug + repo to join later.",
multitaskGroup: z.string().optional(), ),
modelAlias: z.string().optional(), convoySlug: z.string().min(1).max(200),
providerType: z.string().optional(), role: z.enum(Role),
modelId: z.string().optional(), brief: z.number().int().min(1).max(99).nullable().optional(),
modelTier: z.string().optional(), classification: z.enum(Classification).nullable().optional(),
estimatedCostUsd: z.number().nonnegative().optional(), skipFlags: z.array(z.string()).default([]),
metadata: z.record(z.unknown()).optional(), durationS: z.number().int().min(0).nullable().optional(),
stackClass: z.enum(StackClass).nullable().optional(),
repo: z.string().min(1).max(100),
outcome: z.enum(Outcome).nullable().optional(),
multitaskGroup: z.string().max(64).nullable().optional(),
}); });
export function registerLogConvoyEventTool(mcp: McpServer): void { export function registerLogConvoyEventTool(mcp: McpServer): void {
@ -36,7 +84,7 @@ export function registerLogConvoyEventTool(mcp: McpServer): void {
"log_convoy_event", "log_convoy_event",
{ {
description: description:
"Append a convoy_events row (role metrics, status notes) for analytics and mission control.", "Append an event to convoy_events. Mirrors the schema of .convoys/.metrics.jsonl. Use this when running roles inside Cursor on a repo with Echodo configured; otherwise the shell script writes locally.",
inputSchema: logConvoyEventInputSchema, inputSchema: logConvoyEventInputSchema,
}, },
async (args) => { async (args) => {
@ -44,36 +92,38 @@ export function registerLogConvoyEventTool(mcp: McpServer): void {
const input = logConvoyEventInputSchema.parse(args); const input = logConvoyEventInputSchema.parse(args);
const ws = await resolveWorkspaceHandle(input.workspace); const ws = await resolveWorkspaceHandle(input.workspace);
const convoy = await loadConvoyProject(input.convoyId, ws.id); const [inserted] = await db
if (!convoy) { .insert(convoyEvents)
return toolErr(`Convoy ${input.convoyId} not found in workspace ${ws.slug}`); .values({
workspaceId: ws.id,
convoyId: input.convoyId ?? null,
convoySlug: input.convoySlug,
role: input.role,
brief: input.brief ?? null,
classification: input.classification ?? null,
skipFlags: input.skipFlags,
durationS: input.durationS ?? null,
stackClass: input.stackClass ?? null,
repo: input.repo,
outcome: input.outcome ?? null,
multitaskGroup: input.multitaskGroup ?? null,
})
.returning();
if (!inserted) {
return toolErr("Failed to insert convoy event");
} }
const event = await insertConvoyEvent({ return toolOk({
workspaceId: ws.id, event: {
convoyId: convoy.id, id: inserted.id,
briefId: input.briefId ?? null, ts: inserted.ts,
role: input.role, workspaceId: inserted.workspaceId,
actor: input.actor ?? input.role, convoySlug: inserted.convoySlug,
eventType: input.eventType, role: inserted.role,
classification: input.classification ?? null, repo: inserted.repo,
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) { } catch (e) {
return toolCatch(e); return toolCatch(e);
} }

View file

@ -1,21 +1,35 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod"; import { z } from "zod";
import { toolCatch, toolOk } from "./tool-result.js";
import { and, desc, eq } from "../drizzle.js"; /**
import { db } from "../db.js"; * query_manifest_status — Phase 2a lifecycle MCP tool (read-side stub).
import { convoyEvents, objects } from "../schema.js"; *
import { * Returns drift state across consuming repos. Backed by `pipeline_drift_reports`
currentConvoyStatus, * rows that the Phase 4 `drift-check` worker writes (one row per repo per scan).
loadConvoyProject, *
parseConvoyContent, * v0.4.0-beta.1 status: STUB. The `pipeline_drift_reports` table doesn't exist
} from "../lib/convoy-objects.js"; * yet (lands in Phase 4 along with the worker that populates it). For now,
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js"; * this tool returns a structured response signalling that the feature is
import { toolCatch, toolErr, toolOk } from "./tool-result.js"; * pending so role-conductor / role-meta-improver can be coded against the
* final shape without errors.
*
* When Phase 4 lands:
* - Add `pipeline_drift_reports` table (Drizzle migration in Phase 4)
* - Replace the body of this tool to SELECT from that table
* - Schema (planned): {repo_slug, manifest_version, behind_count,
* conflict_count, last_checked, drift_summary_md}
*/
const queryManifestInputSchema = z.object({ const queryManifestStatusInputSchema = z.object({
workspace: z.string().min(1), repoSlug: z
convoyId: z.string().uuid(), .string()
eventLimit: z.number().int().min(1).max(100).default(20), .min(1)
.max(100)
.optional()
.describe(
"Filter by repo slug (e.g. 'tasks', 'zest'). Omit to get all repos.",
),
}); });
export function registerQueryManifestStatusTool(mcp: McpServer): void { export function registerQueryManifestStatusTool(mcp: McpServer): void {
@ -23,48 +37,18 @@ export function registerQueryManifestStatusTool(mcp: McpServer): void {
"query_manifest_status", "query_manifest_status",
{ {
description: description:
"Return convoy project status, metadata, child brief count, and recent convoy_events.", "Query drift state for one or all consuming repos. STUB in v0.4.0-beta.1 — returns empty until the Phase 4 drift-check worker lands.",
inputSchema: queryManifestInputSchema, inputSchema: queryManifestStatusInputSchema,
}, },
async (args) => { async (args) => {
try { try {
const input = queryManifestInputSchema.parse(args); const input = queryManifestStatusInputSchema.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({ return toolOk({
convoy: { stub: true,
id: convoy.id, message:
title: convoy.title, "pipeline_drift_reports table not yet created (Phase 4 dependency). Drift detection currently runs via .github/workflows/agent-context-drift.yml (weekly cron) and posts as GitHub issues, not here.",
status: currentConvoyStatus(convoy), filter: { repoSlug: input.repoSlug ?? null },
content: parseConvoyContent(convoy.content), reports: [],
updatedAt: convoy.updatedAt,
},
briefs,
recentEvents: events,
}); });
} catch (e) { } catch (e) {
return toolCatch(e); return toolCatch(e);

View file

@ -1,151 +1,189 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod"; import { z } from "zod";
import { basename } from "node:path"; import { promises as fs } from "node:fs";
import * as path 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"; import { toolCatch, toolErr, toolOk } from "./tool-result.js";
import { db } from "../db.js";
import { objects, convoyEvents } from "../schema.js";
import { eq } from "../drizzle.js";
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
/**
* reconcile_from_files — Phase 2a lifecycle MCP tool (the sixth one).
*
* Replays `.convoys/.pending-mcp-sync.jsonl` outbox lines in timestamp order.
* Each line is a JSON object: {ts, tool, args, lastError}. On success, the line
* is removed from the outbox; on failure, the lastError + retry count are
* updated in place.
*
* This is what makes the §7.6 local-first architecture work: roles write
* files first, attempt MCP second, queue to outbox on failure. Then on the
* next reconcile (manual in v0.4; scheduled in v0.5+), the outbox drains.
*
* v0.4.0-beta.1 scope: replays log_convoy_event and transition_convoy_status
* entries inline (those are the cheapest + most common queued calls).
* create_convoy / create_brief replays are stubbed — they need parent-id
* resolution that's safer with the full role context, so v0.4.0-beta.1 just
* lists them in the unreplayed report and leaves manual replay to the role.
*/
const reconcileInputSchema = z.object({ const reconcileInputSchema = z.object({
workspace: z.string().min(1), workspace: z
repoPath: z.string().optional(), .string()
.min(1)
.describe("Workspace UUID or slug (convoys-<repo>)."),
repoPath: z
.string()
.min(1)
.describe(
"Absolute path to the consumer repo (e.g. /Users/me/Documents/tasks). The .convoys directory under here is scanned.",
),
}); });
type OutboxEntry = {
ts: string;
tool: string;
args: Record<string, unknown>;
retries?: number;
lastError?: string;
};
const REPLAYABLE_TOOLS = new Set([
"log_convoy_event",
"transition_convoy_status",
]);
export function registerReconcileFromFilesTool(mcp: McpServer): void { export function registerReconcileFromFilesTool(mcp: McpServer): void {
mcp.registerTool( mcp.registerTool(
"reconcile_from_files", "reconcile_from_files",
{ {
description: description:
"Sync `.convoys/*.md` files into Echodo project objects and report pending MCP outbox entries.", "Replay the .convoys/.pending-mcp-sync.jsonl outbox. Restores Echodo state after MCP unreachability. Returns {replayed, unreplayed, removed} counts.",
inputSchema: reconcileInputSchema, inputSchema: reconcileInputSchema,
}, },
async (args) => { async (args) => {
try { try {
const input = reconcileInputSchema.parse(args); const input = reconcileInputSchema.parse(args);
const ws = await resolveWorkspaceHandle(input.workspace); const ws = await resolveWorkspaceHandle(input.workspace);
const repoRoot = input.repoPath?.trim() || resolveRepoRoot();
if (!repoRoot) { const outboxPath = path.join(
return toolErr( input.repoPath,
"Repo root not found. Set MARKDOWN_BACKLOG_REPO_ROOT or pass repoPath.", ".convoys",
".pending-mcp-sync.jsonl",
); );
let raw: string;
try {
raw = await fs.readFile(outboxPath, "utf8");
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
return toolOk({
workspace: ws.slug,
outboxPath,
replayed: 0,
unreplayed: 0,
note: "No outbox file — nothing to reconcile.",
});
}
return toolCatch(err);
} }
const files = listConvoyMarkdownFiles(repoRoot); const lines = raw.split("\n").filter((l) => l.trim().length > 0);
const pending = readPendingSync(repoRoot); const entries: OutboxEntry[] = [];
const synced: Array<{ slug: string; convoyId: string; created: boolean }> = []; for (const line of lines) {
const skipped: Array<{ slug: string; reason: string }> = []; try {
entries.push(JSON.parse(line));
} catch {
// Skip malformed lines but don't crash the reconcile.
}
}
for (const file of files) { entries.sort((a, b) => a.ts.localeCompare(b.ts));
if (!file.classification || !file.successMetric) {
skipped.push({ const replayed: Array<{ ts: string; tool: string }> = [];
slug: file.slug, const unreplayed: Array<{ ts: string; tool: string; reason: string }> = [];
reason: "missing classification or success_metric in frontmatter",
for (const entry of entries) {
if (!REPLAYABLE_TOOLS.has(entry.tool)) {
unreplayed.push({
ts: entry.ts,
tool: entry.tool,
reason: `Tool '${entry.tool}' is not replayable in v0.4.0-beta.1 (needs role context). Replay manually.`,
}); });
continue; continue;
} }
const classification = convoyClassificationSchema.safeParse(file.classification); try {
if (!classification.success) { if (entry.tool === "log_convoy_event") {
skipped.push({ slug: file.slug, reason: "invalid classification" }); const a = entry.args as Record<string, unknown>;
continue; await db.insert(convoyEvents).values({
}
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, workspaceId: ws.id,
description: file.bodyMarkdown.trim() || null, convoyId: (a.convoyId as string | null | undefined) ?? null,
status: mapFileStatusToServer(file.fileStatus ?? "open") ?? "draft", convoySlug: String(a.convoySlug ?? ""),
content, role: String(a.role ?? ""),
}) brief: (a.brief as number | null | undefined) ?? null,
.returning(); classification: (a.classification as string | null | undefined) ?? null,
if (!inserted) { skipFlags: (a.skipFlags as string[] | undefined) ?? [],
skipped.push({ slug: file.slug, reason: "insert failed" }); durationS: (a.durationS as number | null | undefined) ?? null,
stackClass: (a.stackClass as string | null | undefined) ?? null,
repo: String(a.repo ?? ""),
outcome: (a.outcome as string | null | undefined) ?? null,
multitaskGroup:
(a.multitaskGroup as string | null | undefined) ?? null,
});
replayed.push({ ts: entry.ts, tool: entry.tool });
} else if (entry.tool === "transition_convoy_status") {
const a = entry.args as Record<string, unknown>;
const convoyId = String(a.convoyId ?? "");
const toStatus = String(a.toStatus ?? "");
if (!convoyId || !toStatus) {
unreplayed.push({
ts: entry.ts,
tool: entry.tool,
reason: "Missing convoyId or toStatus in queued args.",
});
continue; continue;
} }
convoy = inserted; // Minimal replay: set status; full state-machine validation
created = true; // happens via the live transition_convoy_status tool. Use that
} else { // path when retry semantics matter.
const mappedStatus = file.fileStatus await db
? mapFileStatusToServer(file.fileStatus)
: null;
const [updated] = await db
.update(objects) .update(objects)
.set({ .set({ status: toStatus, updatedAt: new Date() })
description: file.bodyMarkdown.trim() || convoy.description, .where(eq(objects.id, convoyId));
content, replayed.push({ ts: entry.ts, tool: entry.tool });
...(mappedStatus ? { status: mappedStatus } : {}),
updatedAt: new Date(),
})
.where(eq(objects.id, convoy.id))
.returning();
convoy = updated ?? convoy;
} }
} catch (err) {
await insertConvoyEvent({ unreplayed.push({
workspaceId: ws.id, ts: entry.ts,
convoyId: convoy.id, tool: entry.tool,
role: "reconcile_from_files", reason: err instanceof Error ? err.message : String(err),
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) { // Persist unreplayed entries back; remove replayed ones.
clearPendingSync(repoRoot); const unreplayedSet = new Set(unreplayed.map((u) => u.ts + "|" + u.tool));
const remaining = entries.filter((e) =>
unreplayedSet.has(e.ts + "|" + e.tool),
);
const remainingRaw = remaining.map((r) => JSON.stringify(r)).join("\n");
if (remaining.length === 0) {
// Truncate to empty when fully drained — keeps the file present so
// the next role's outbox write doesn't need to create-then-append.
await fs.writeFile(outboxPath, "");
} else {
await fs.writeFile(outboxPath, remainingRaw + "\n");
} }
return toolOk({ return toolOk({
workspace: { id: ws.id, slug: ws.slug, name: ws.name }, workspace: ws.slug,
repoRoot, outboxPath,
synced, replayed: replayed.length,
skipped, unreplayed: unreplayed.length,
clearedPendingEntries: pending.length, replayedDetail: replayed,
pendingReplayNote: unreplayedDetail: unreplayed,
pending.length > 0
? "Pending outbox entries were cleared after reporting; replay tooling is deferred."
: null,
pending,
}); });
} catch (e) { } catch (e) {
return toolCatch(e); return toolCatch(e);

View file

@ -1,98 +1,224 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 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 { eq } from "../drizzle.js";
import { z } from "zod";
import { db } from "../db.js"; import { db } from "../db.js";
import { objects } from "../schema.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"; import { toolCatch, toolErr, toolOk } from "./tool-result.js";
/**
* transition_convoy_status — Phase 2a lifecycle MCP tool.
*
* Server-enforces the convoy state machine documented in
* agent-pipeline/.cursor/plans/pipeline_v0.4_design_+_echodo_54a3bdb7.plan.md §7.3.
*
* Three guarantees:
* 1. Only valid transitions are accepted (e.g. draft → released is rejected).
* 2. Only the right actor can perform a gate transition:
* - convoy_review → ready : ONLY human-gate-1
* - pr_review → merged : ONLY human-gate-2
* - merged → released : ONLY human-gate-3
* Roles cannot impersonate gates; gates cannot perform role-driven transitions.
* 3. Every transition appends a bullet to the convoy's "## Status log" section,
* so the convoy's description carries an immutable audit trail.
*
* Local-first: the role should ALSO mirror the status change to
* `.convoys/<slug>.md`'s "## Status log" section. If this MCP call fails,
* queue to `.pending-mcp-sync.jsonl` and proceed.
*/
const ConvoyStatus = [
"draft",
"convoy_review",
"ready",
"in_progress",
"pr_review",
"merged",
"released",
"blocked",
"cancelled",
] as const;
type ConvoyStatusT = (typeof ConvoyStatus)[number];
const VALID_TRANSITIONS: Record<ConvoyStatusT, readonly ConvoyStatusT[]> = {
draft: ["convoy_review", "blocked", "cancelled"],
convoy_review: ["ready", "draft", "blocked", "cancelled"],
ready: ["in_progress", "blocked", "cancelled"],
in_progress: ["pr_review", "blocked", "cancelled"],
pr_review: ["merged", "in_progress", "blocked", "cancelled"],
merged: ["released", "blocked"],
released: [],
blocked: [
"draft",
"convoy_review",
"ready",
"in_progress",
"pr_review",
"merged",
"cancelled",
],
cancelled: [],
};
const Actor = [
"human-gate-1",
"human-gate-2",
"human-gate-3",
"role-conductor",
"role-ia-architect",
"role-ux-reviewer",
"role-architect",
"role-implementer",
"role-reviewer",
"role-design-system-auditor",
"role-a11y-auditor",
"role-doc-writer",
"automation",
] as const;
const GATE_ONLY_TRANSITIONS: Array<{
from: ConvoyStatusT;
to: ConvoyStatusT;
actor: (typeof Actor)[number];
}> = [
{ from: "convoy_review", to: "ready", actor: "human-gate-1" },
{ from: "pr_review", to: "merged", actor: "human-gate-2" },
{ from: "merged", to: "released", actor: "human-gate-3" },
];
const transitionInputSchema = z.object({ const transitionInputSchema = z.object({
workspace: z.string().min(1),
convoyId: z.string().uuid(), convoyId: z.string().uuid(),
toStatus: convoyStatusSchema, toStatus: z.enum(ConvoyStatus),
actor: z.string().min(1).max(64), actor: z.enum(Actor),
reason: z.string().min(1).max(2_000).optional(), reason: z
.string()
.max(500)
.optional()
.describe(
"Required when transitioning TO `blocked` or `cancelled`. Otherwise optional.",
),
}); });
function appendStatusLog(
description: string | null,
fromStatus: string | null,
toStatus: ConvoyStatusT,
actor: string,
reason: string | undefined,
): string {
const ts = new Date().toISOString();
const reasonSuffix = reason ? ` — ${reason}` : "";
const line = `- ${ts} \`${fromStatus ?? "—"}\` → \`${toStatus}\` (by \`${actor}\`)${reasonSuffix}`;
if (!description || !description.includes("## Status log")) {
return `${description ?? ""}\n\n## Status log\n\n${line}\n`.trim();
}
return `${description.trimEnd()}\n${line}\n`;
}
export function registerTransitionConvoyStatusTool(mcp: McpServer): void { export function registerTransitionConvoyStatusTool(mcp: McpServer): void {
mcp.registerTool( mcp.registerTool(
"transition_convoy_status", "transition_convoy_status",
{ {
description: description:
"Transition a convoy project through the server-enforced lifecycle state machine.", "Transition a convoy to a new lifecycle status. Server-enforced state machine + actor rules. Appends an audit line to the convoy's '## Status log' section. See agent-pipeline plan §7 for the full state machine.",
inputSchema: transitionInputSchema, inputSchema: transitionInputSchema,
}, },
async (args) => { async (args) => {
try { try {
const input = transitionInputSchema.parse(args); const input = transitionInputSchema.parse(args);
const ws = await resolveWorkspaceHandle(input.workspace);
const convoy = await loadConvoyProject(input.convoyId, ws.id); const [convoy] = await db
.select({
id: objects.id,
type: objects.type,
status: objects.status,
description: objects.description,
})
.from(objects)
.where(eq(objects.id, input.convoyId))
.limit(1);
if (!convoy) { if (!convoy) {
return toolErr(`Convoy ${input.convoyId} not found in workspace ${ws.slug}`); return toolErr(`Convoy not found: ${input.convoyId}`);
} }
if (convoy.type !== "project") {
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( return toolErr(
JSON.stringify({ `Object ${input.convoyId} is type '${convoy.type}', expected 'project'.`,
code: error.code,
message: error.message,
from: fromStatus,
to: input.toStatus,
actor: input.actor,
}),
); );
} }
throw error;
const fromStatus = (convoy.status ?? "draft") as ConvoyStatusT;
if (!(fromStatus in VALID_TRANSITIONS)) {
return toolErr(
`Convoy is in unknown status '${fromStatus}'. Reset to a known status before transitioning.`,
);
} }
const allowed = VALID_TRANSITIONS[fromStatus];
if (!allowed.includes(input.toStatus)) {
return toolErr(
`Invalid transition: ${fromStatus} → ${input.toStatus}. Valid next: [${allowed.join(", ")}].`,
);
}
// Gate actor enforcement (works both ways: role cannot impersonate gate;
// gate cannot do non-gate transitions).
const gateMatch = GATE_ONLY_TRANSITIONS.find(
(g) => g.from === fromStatus && g.to === input.toStatus,
);
if (gateMatch && input.actor !== gateMatch.actor) {
return toolErr(
`Transition ${fromStatus} → ${input.toStatus} requires actor '${gateMatch.actor}', got '${input.actor}'.`,
);
}
if (
!gateMatch &&
(input.actor === "human-gate-1" ||
input.actor === "human-gate-2" ||
input.actor === "human-gate-3")
) {
return toolErr(
`Actor '${input.actor}' can only perform its specific gate transition; ${fromStatus} → ${input.toStatus} is not a gate transition.`,
);
}
if ((input.toStatus === "blocked" || input.toStatus === "cancelled") && !input.reason) {
return toolErr(
`Transitions to '${input.toStatus}' require a 'reason' string.`,
);
}
const nextDescription = appendStatusLog(
convoy.description,
fromStatus,
input.toStatus,
input.actor,
input.reason,
);
const [updated] = await db const [updated] = await db
.update(objects) .update(objects)
.set({ .set({
status: input.toStatus, status: input.toStatus,
description: nextDescription,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(objects.id, convoy.id)) .where(eq(objects.id, input.convoyId))
.returning(); .returning();
await insertConvoyEvent({ if (!updated) {
workspaceId: ws.id, return toolErr("Failed to apply transition");
convoyId: convoy.id, }
role: input.actor,
actor: input.actor,
eventType: "status_change",
metadata: {
from: fromStatus,
to: input.toStatus,
reason: input.reason ?? null,
},
});
return toolOk({ return toolOk({
convoy: updated, convoy: {
transition: { from: fromStatus, to: input.toStatus, actor: input.actor }, id: updated.id,
fromStatus,
toStatus: input.toStatus,
actor: input.actor,
ts: new Date().toISOString(),
},
}); });
} catch (e) { } catch (e) {
return toolCatch(e); return toolCatch(e);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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