[DEFERRED] feat(mcp): agent-pipeline bridge — descoped per v0.4 kill criteria #3

Closed
varutasu wants to merge 2 commits from feat/agent-pipeline-bridge into main
24 changed files with 5054 additions and 3 deletions
Showing only changes of commit dab939bcd5 - Show all commits

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"

121
.convoys/README.md Normal file
View file

@ -0,0 +1,121 @@
# Convoys
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.
## File layout
```
.convoys/
├── README.md (this file)
├── <slug>.md (the convoy file — written by role-conductor)
└── <slug>/
├── brief-1-<kebab-title>.md (written by role-architect)
├── brief-2-<kebab-title>.md
└── ...
```
## Convoy file format
Frontmatter (set by `role-conductor`, then appended-to by other roles):
```yaml
---
name: <kebab-slug>
classification: feature | hotfix | docs-only | infra-only | server-only | config-only
success_metric: <one sentence>
skip:
- <flag1>
status: open | in-progress | merged | shipped | abandoned
created: <YYYY-MM-DD>
---
```
Body sections (added in order by the pipeline roles):
1. `## Why` (Conductor)
2. `## Scope` (Conductor)
3. `## Roles invoked` (Conductor)
4. `## Todos` (Conductor → refined by Architect)
5. `## IA` (IA Architect)
6. `## UX` (UX Reviewer)
7. `## Architecture` (Architect)
After Architect, briefs live in `.convoys/<slug>/brief-N-*.md`. Implementers read only their brief, not the whole convoy.
## Skip flags
The Conductor sets `skip:` based on classification. These flags map to pipeline stages that no-op when set:
| Flag | Skips |
| --- | --- |
| `ia` | IA Architect |
| `ux` | UX Reviewer |
| `arch` | Architect |
| `test` | Component tests |
| `review` | Reviewer |
| `visual` | Visual diff |
| `a11y` | A11y auditor |
| `design` | Design-system auditor |
| `smoke` | Staging smoke |
| `qa` | Manual QA |
| `docs` | Doc Writer |
| `flag` | Flag rollout |
Never skipped (mandatory human gates): `plan-approval`, `pr-merge`, `prod-promote`.
## Status lifecycle
- `open` — Conductor created the convoy; no work started.
- `in-progress` — At least one brief has an open or merged PR.
- `merged` — All briefs merged to umbrella; release PR to develop pending.
- `shipped` — Release to main complete; flag rollout (if any) underway.
- `abandoned` — Convoy closed without shipping; reason in convoy body.
Update status by editing the convoy frontmatter as you progress.
## Adding a new convoy
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.
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
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
cd ~/code/agent-pipeline/analytics
npx tsx analyze-convoys.ts <repo-path> [<repo-path>...]
npx tsx render-dashboard.ts
open ~/agent-pipeline-data/dashboard.html
```
Schema: [`analytics/schemas/convoy-event.json`](https://github.com/varutasu/agent-pipeline/blob/main/analytics/schemas/convoy-event.json).

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,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.

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'],
});
}

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 });
}

4
.gitignore vendored
View file

@ -55,3 +55,7 @@ docker/**/data/
# misc # misc
*.pem *.pem
coverage/ coverage/
# agent pipeline — local-only convoy runtime files
.convoys/.metrics.jsonl
.convoys/.pending-mcp-sync.jsonl

View file

@ -0,0 +1,123 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { eq } from "../drizzle.js";
import { z } from "zod";
import { db } from "../db.js";
import { objects } from "../schema.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({
convoyId: z
.string()
.uuid()
.describe("Convoy project id (returned by create_convoy)."),
briefNumber: z.number().int().min(1).max(99),
title: z.string().min(1).max(500),
filesAllowlist: z
.array(z.string())
.min(1)
.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 {
mcp.registerTool(
"create_brief",
{
description:
"Create a brief as a `task` child of a convoy `project`. Validates that the parent exists and is a project.",
inputSchema: createBriefInputSchema,
},
async (args) => {
try {
const input = createBriefInputSchema.parse(args);
const [parent] = await db
.select({ id: objects.id, type: objects.type, workspaceId: objects.workspaceId })
.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 [created] = await db
.insert(objects)
.values({
type: "task",
title: `Brief ${input.briefNumber}: ${input.title}`,
parentId: parent.id,
workspaceId: parent.workspaceId,
description: initialDescription(input),
status: "ready",
})
.returning();
if (!created) {
return toolErr("Failed to create brief task");
}
return toolOk({
brief: {
id: created.id,
briefNumber: input.briefNumber,
title: created.title,
convoyId: parent.id,
filesAllowlist: input.filesAllowlist,
dependsOn: input.dependsOn,
},
});
} catch (e) {
return toolCatch(e);
}
},
);
}

View file

@ -0,0 +1,131 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { db } from "../db.js";
import { objects } from "../schema.js";
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.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({
workspace: z
.string()
.min(1)
.describe(
"Workspace UUID or slug (e.g. 'convoys-tasks'). Workspace must already exist.",
),
slug: z
.string()
.min(1)
.max(100)
.regex(/^[a-z0-9][a-z0-9-]*$/, "slug must be kebab-case")
.describe("Convoy slug. Matches the filename: .convoys/<slug>.md"),
title: z.string().min(1).max(500),
classification: classificationEnum,
skipFlags: z
.array(z.string())
.default([])
.describe(
"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(),
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 {
mcp.registerTool(
"create_convoy",
{
description:
"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,
},
async (args) => {
try {
const input = createConvoyInputSchema.parse(args);
const ws = await resolveWorkspaceHandle(input.workspace);
const [created] = await db
.insert(objects)
.values({
type: "project",
title: input.title,
workspaceId: ws.id,
description: initialDescription(input),
status: "draft",
parentId: null,
})
.returning();
if (!created) {
return toolErr("Failed to create convoy project");
}
return toolOk({
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
convoy: {
id: created.id,
slug: input.slug,
title: created.title,
status: created.status,
classification: input.classification,
skipFlags: input.skipFlags,
},
});
} catch (e) {
return toolCatch(e);
}
},
);
}

View file

@ -6,8 +6,15 @@ 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 { registerCreateConvoyTool } from "./create-convoy.js";
import { registerCreateBriefTool } from "./create-brief.js";
import { registerTransitionConvoyStatusTool } from "./transition-convoy-status.js";
import { registerLogConvoyEventTool } from "./log-convoy-event.js";
import { registerQueryManifestStatusTool } from "./query-manifest-status.js";
import { registerReconcileFromFilesTool } from "./reconcile-from-files.js";
export function registerTools(mcp: McpServer): void { export function registerTools(mcp: McpServer): void {
// Object CRUD (pre-existing)
registerCreateObjectTool(mcp); registerCreateObjectTool(mcp);
registerUpdateObjectTool(mcp); registerUpdateObjectTool(mcp);
registerSearchObjectsTool(mcp); registerSearchObjectsTool(mcp);
@ -15,6 +22,14 @@ export function registerTools(mcp: McpServer): void {
registerManageObjectTool(mcp); registerManageObjectTool(mcp);
registerClaimTaskTool(mcp); registerClaimTaskTool(mcp);
registerCompleteTaskTool(mcp); registerCompleteTaskTool(mcp);
// Agent-pipeline lifecycle bridge (Phase 2a, v0.4)
registerCreateConvoyTool(mcp);
registerCreateBriefTool(mcp);
registerTransitionConvoyStatusTool(mcp);
registerLogConvoyEventTool(mcp);
registerQueryManifestStatusTool(mcp);
registerReconcileFromFilesTool(mcp);
} }
export { registerCreateObjectTool } from "./create-object.js"; export { registerCreateObjectTool } from "./create-object.js";
@ -24,3 +39,9 @@ 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 { registerCreateConvoyTool } from "./create-convoy.js";
export { registerCreateBriefTool } from "./create-brief.js";
export { registerTransitionConvoyStatusTool } from "./transition-convoy-status.js";
export { registerLogConvoyEventTool } from "./log-convoy-event.js";
export { registerQueryManifestStatusTool } from "./query-manifest-status.js";
export { registerReconcileFromFilesTool } from "./reconcile-from-files.js";

View file

@ -0,0 +1,132 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { db } from "../db.js";
import { convoyEvents } from "../schema.js";
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.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({
workspace: z
.string()
.min(1)
.describe("Workspace UUID or slug (convoys-<repo>)."),
convoyId: z
.string()
.uuid()
.nullable()
.optional()
.describe(
"Convoy project id. Null is allowed for events emitted before create_convoy returns; use convoySlug + repo to join later.",
),
convoySlug: z.string().min(1).max(200),
role: z.enum(Role),
brief: z.number().int().min(1).max(99).nullable().optional(),
classification: z.enum(Classification).nullable().optional(),
skipFlags: z.array(z.string()).default([]),
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 {
mcp.registerTool(
"log_convoy_event",
{
description:
"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,
},
async (args) => {
try {
const input = logConvoyEventInputSchema.parse(args);
const ws = await resolveWorkspaceHandle(input.workspace);
const [inserted] = await db
.insert(convoyEvents)
.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");
}
return toolOk({
event: {
id: inserted.id,
ts: inserted.ts,
workspaceId: inserted.workspaceId,
convoySlug: inserted.convoySlug,
role: inserted.role,
repo: inserted.repo,
},
});
} catch (e) {
return toolCatch(e);
}
},
);
}

View file

@ -0,0 +1,58 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { toolCatch, toolOk } from "./tool-result.js";
/**
* query_manifest_status Phase 2a lifecycle MCP tool (read-side stub).
*
* Returns drift state across consuming repos. Backed by `pipeline_drift_reports`
* rows that the Phase 4 `drift-check` worker writes (one row per repo per scan).
*
* v0.4.0-beta.1 status: STUB. The `pipeline_drift_reports` table doesn't exist
* yet (lands in Phase 4 along with the worker that populates it). For now,
* this tool returns a structured response signalling that the feature is
* 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 queryManifestStatusInputSchema = z.object({
repoSlug: z
.string()
.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 {
mcp.registerTool(
"query_manifest_status",
{
description:
"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: queryManifestStatusInputSchema,
},
async (args) => {
try {
const input = queryManifestStatusInputSchema.parse(args);
return toolOk({
stub: true,
message:
"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.",
filter: { repoSlug: input.repoSlug ?? null },
reports: [],
});
} catch (e) {
return toolCatch(e);
}
},
);
}

View file

@ -0,0 +1,193 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { promises as fs } from "node:fs";
import * as path from "node:path";
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({
workspace: z
.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 {
mcp.registerTool(
"reconcile_from_files",
{
description:
"Replay the .convoys/.pending-mcp-sync.jsonl outbox. Restores Echodo state after MCP unreachability. Returns {replayed, unreplayed, removed} counts.",
inputSchema: reconcileInputSchema,
},
async (args) => {
try {
const input = reconcileInputSchema.parse(args);
const ws = await resolveWorkspaceHandle(input.workspace);
const outboxPath = path.join(
input.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 lines = raw.split("\n").filter((l) => l.trim().length > 0);
const entries: OutboxEntry[] = [];
for (const line of lines) {
try {
entries.push(JSON.parse(line));
} catch {
// Skip malformed lines but don't crash the reconcile.
}
}
entries.sort((a, b) => a.ts.localeCompare(b.ts));
const replayed: Array<{ ts: string; tool: string }> = [];
const unreplayed: Array<{ ts: string; tool: string; reason: string }> = [];
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;
}
try {
if (entry.tool === "log_convoy_event") {
const a = entry.args as Record<string, unknown>;
await db.insert(convoyEvents).values({
workspaceId: ws.id,
convoyId: (a.convoyId as string | null | undefined) ?? null,
convoySlug: String(a.convoySlug ?? ""),
role: String(a.role ?? ""),
brief: (a.brief as number | null | undefined) ?? null,
classification: (a.classification as string | null | undefined) ?? null,
skipFlags: (a.skipFlags as string[] | undefined) ?? [],
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;
}
// Minimal replay: set status; full state-machine validation
// happens via the live transition_convoy_status tool. Use that
// path when retry semantics matter.
await db
.update(objects)
.set({ status: toStatus, updatedAt: new Date() })
.where(eq(objects.id, convoyId));
replayed.push({ ts: entry.ts, tool: entry.tool });
}
} catch (err) {
unreplayed.push({
ts: entry.ts,
tool: entry.tool,
reason: err instanceof Error ? err.message : String(err),
});
}
}
// Persist unreplayed entries back; remove replayed ones.
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({
workspace: ws.slug,
outboxPath,
replayed: replayed.length,
unreplayed: unreplayed.length,
replayedDetail: replayed,
unreplayedDetail: unreplayed,
});
} catch (e) {
return toolCatch(e);
}
},
);
}

View file

@ -0,0 +1,228 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { eq } from "../drizzle.js";
import { z } from "zod";
import { db } from "../db.js";
import { objects } from "../schema.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({
convoyId: z.string().uuid(),
toStatus: z.enum(ConvoyStatus),
actor: z.enum(Actor),
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 {
mcp.registerTool(
"transition_convoy_status",
{
description:
"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,
},
async (args) => {
try {
const input = transitionInputSchema.parse(args);
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) {
return toolErr(`Convoy not found: ${input.convoyId}`);
}
if (convoy.type !== "project") {
return toolErr(
`Object ${input.convoyId} is type '${convoy.type}', expected 'project'.`,
);
}
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
.update(objects)
.set({
status: input.toStatus,
description: nextDescription,
updatedAt: new Date(),
})
.where(eq(objects.id, input.convoyId))
.returning();
if (!updated) {
return toolErr("Failed to apply transition");
}
return toolOk({
convoy: {
id: updated.id,
fromStatus,
toStatus: input.toStatus,
actor: input.actor,
ts: new Date().toISOString(),
},
});
} catch (e) {
return toolCatch(e);
}
},
);
}

View file

@ -0,0 +1,24 @@
CREATE TABLE "convoy_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"convoy_id" uuid,
"convoy_slug" varchar(200) NOT NULL,
"role" varchar(50) NOT NULL,
"brief" integer,
"classification" varchar(30),
"skip_flags" text[],
"duration_s" integer,
"stack_class" varchar(30),
"repo" varchar(100) NOT NULL,
"outcome" varchar(20),
"multitask_group" varchar(64),
"metadata" jsonb,
"ts" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "convoy_events" ADD CONSTRAINT "convoy_events_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "convoy_events" ADD CONSTRAINT "convoy_events_convoy_id_objects_id_fk" FOREIGN KEY ("convoy_id") REFERENCES "public"."objects"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "convoy_events_workspace_id_ts_idx" ON "convoy_events" USING btree ("workspace_id","ts" DESC NULLS LAST);--> statement-breakpoint
CREATE INDEX "convoy_events_convoy_id_ts_idx" ON "convoy_events" USING btree ("convoy_id","ts" DESC NULLS LAST);--> statement-breakpoint
CREATE INDEX "convoy_events_convoy_slug_repo_idx" ON "convoy_events" USING btree ("convoy_slug","repo");--> statement-breakpoint
CREATE INDEX "convoy_events_role_idx" ON "convoy_events" USING btree ("role");

File diff suppressed because it is too large Load diff

View file

@ -71,6 +71,13 @@
"when": 1780455865684, "when": 1780455865684,
"tag": "0009_loving_rogue", "tag": "0009_loving_rogue",
"breakpoints": true "breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1780697544944,
"tag": "0010_wandering_the_professor",
"breakpoints": true
} }
] ]
} }

View file

@ -13,9 +13,9 @@
}, },
"scripts": { "scripts": {
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "node --env-file=../../.env ./node_modules/.bin/drizzle-kit migrate",
"db:push": "drizzle-kit push", "db:push": "node --env-file=../../.env ./node_modules/.bin/drizzle-kit push",
"db:studio": "drizzle-kit studio", "db:studio": "node --env-file=../../.env ./node_modules/.bin/drizzle-kit studio",
"type-check": "tsc --noEmit", "type-check": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",

View file

@ -0,0 +1,73 @@
import {
pgTable,
uuid,
varchar,
text,
integer,
jsonb,
timestamp,
index,
} from "drizzle-orm/pg-core";
import { workspaces } from "./workspaces";
import { objects } from "./objects";
/**
* Convoy event log append-only metrics from L2 roles in the agent pipeline.
* Lands in v0.4 / Phase 2a of the agent-pipeline plan (see
* agent-pipeline/.cursor/plans/pipeline_v0.4_design_+_echodo_54a3bdb7.plan.md §7).
*
* Local mirror: each consumer repo writes the same JSON shape to
* `.convoys/.metrics.jsonl` (see analytics/schemas/convoy-event.json upstream).
* The MCP `log_convoy_event` tool inserts here when reachable; otherwise the
* row is queued in `.convoys/.pending-mcp-sync.jsonl` and replayed by
* `reconcile_from_files`. Local files are source of truth (see §7.6 local-first).
*
* Conventions:
* - `convoyId` may be NULL during the brief window between a role writing
* `.convoys/<slug>.md` and `create_convoy` returning the project id.
* Joined on `convoySlug` in that case.
* - `role` is constrained at the MCP tool layer to the 9 roles enumerated
* in convoy-event.json. Stored as varchar to allow forward-compat with
* new roles (e.g. role-artifact-quality in v0.5.1).
* - `skipFlags` is a Postgres text[] for native filtering vs JSONB.
* - `metadata` is JSONB, intentionally small (< 1 KB). Don't dump prompts here.
*/
export const convoyEvents = pgTable(
"convoy_events",
{
id: uuid("id").primaryKey().defaultRandom(),
workspaceId: uuid("workspace_id")
.notNull()
.references(() => workspaces.id, { onDelete: "cascade" }),
convoyId: uuid("convoy_id").references(() => objects.id, {
onDelete: "set null",
}),
convoySlug: varchar("convoy_slug", { length: 200 }).notNull(),
role: varchar("role", { length: 50 }).notNull(),
brief: integer("brief"),
classification: varchar("classification", { length: 30 }),
skipFlags: text("skip_flags").array(),
durationS: integer("duration_s"),
stackClass: varchar("stack_class", { length: 30 }),
repo: varchar("repo", { length: 100 }).notNull(),
outcome: varchar("outcome", { length: 20 }),
multitaskGroup: varchar("multitask_group", { length: 64 }),
metadata: jsonb("metadata").$type<Record<string, unknown> | null>(),
ts: timestamp("ts", { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
workspaceTsIdx: index("convoy_events_workspace_id_ts_idx").on(
table.workspaceId,
table.ts.desc(),
),
convoyTsIdx: index("convoy_events_convoy_id_ts_idx").on(
table.convoyId,
table.ts.desc(),
),
convoySlugRepoIdx: index("convoy_events_convoy_slug_repo_idx").on(
table.convoySlug,
table.repo,
),
roleIdx: index("convoy_events_role_idx").on(table.role),
}),
);

View file

@ -13,3 +13,4 @@ export * from "./markdown_backlog";
export * from "./cursor_sync"; export * from "./cursor_sync";
export * from "./audit"; export * from "./audit";
export * from "./agent_runs"; export * from "./agent_runs";
export * from "./convoy_events";

89
scripts/log-convoy-event.sh Executable file
View file

@ -0,0 +1,89 @@
#!/usr/bin/env bash
# log-convoy-event.sh — append one convoy event to .convoys/.metrics.jsonl.
#
# Used by L2 roles to emit lightweight metrics for self-analytics.
# Schema: github.com/varutasu/agent-pipeline/analytics/schemas/convoy-event.json
#
# Usage:
# bash scripts/log-convoy-event.sh role=role-conductor convoy=bookmark-badge \
# classification=feature 'skip_flags=visual,smoke' duration_s=42
#
# All args are key=value. Required: role, convoy.
# Optional: brief, classification, skip_flags (comma-separated), duration_s,
# stack_class, outcome, multitask_group.
#
# multitask_group: cohort id when this role ran as part of a Cursor 3.2
# /multitask fan-out (e.g. 'audit-bookmark-badge-PR123'). Events sharing
# this id should be aggregated with max(duration_s), not sum, for wall-clock.
# See docs/multitask-playbook.md.
#
# Privacy: this file is gitignored by default; events contain only metadata,
# no code or prompts. To opt-in to commit, remove `.convoys/.metrics.jsonl`
# from your `.gitignore`.
#
# Atomicity: concurrent invocations append safely because each python3
# subprocess writes one short JSON line via O_APPEND. POSIX guarantees
# writes <= PIPE_BUF are atomic on regular files opened with O_APPEND.
# Typical line size is 200-400 bytes; PIPE_BUF is 4096 on Linux and
# 512+ on macOS. Larger custom fields could break this — keep
# multitask_group <= 64 chars (matches the JSON schema).
#
# Portable across macOS bash 3.2 and Linux bash 4+; uses python3 (always
# present on macOS + most Linux) for safe JSON encoding.
set -euo pipefail
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
REPO_NAME="$(basename "$REPO_ROOT")"
METRICS_FILE="$REPO_ROOT/.convoys/.metrics.jsonl"
mkdir -p "$REPO_ROOT/.convoys"
# Pull values out of args without using associative arrays (bash 3.2 compat)
ROLE=""; CONVOY=""; BRIEF=""; CLASSIFICATION=""
SKIP_FLAGS=""; DURATION_S=""; STACK_CLASS=""; OUTCOME=""; MULTITASK_GROUP=""
for arg in "$@"; do
k="${arg%%=*}"
v="${arg#*=}"
case "$k" in
role) ROLE="$v" ;;
convoy) CONVOY="$v" ;;
brief) BRIEF="$v" ;;
classification) CLASSIFICATION="$v" ;;
skip_flags) SKIP_FLAGS="$v" ;;
duration_s) DURATION_S="$v" ;;
stack_class) STACK_CLASS="$v" ;;
outcome) OUTCOME="$v" ;;
multitask_group) MULTITASK_GROUP="$v" ;;
*) echo "log-convoy-event: ignoring unknown arg '$k'" >&2 ;;
esac
done
if [ -z "$ROLE" ] || [ -z "$CONVOY" ]; then
echo "log-convoy-event: role and convoy are required" >&2
echo "Usage: $0 role=<role> convoy=<slug> [classification=...] [skip_flags=a,b] [duration_s=N] [brief=N] [stack_class=...] [outcome=...]" >&2
exit 1
fi
ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
# Build the event with python3 — handles all string escaping and array encoding
python3 - <<PY >> "$METRICS_FILE"
import json, sys
ev = {
"ts": "$ts",
"role": $(printf '%s' "$ROLE" | python3 -c 'import json,sys;print(json.dumps(sys.stdin.read()))'),
"convoy": $(printf '%s' "$CONVOY" | python3 -c 'import json,sys;print(json.dumps(sys.stdin.read()))'),
"repo": $(printf '%s' "$REPO_NAME" | python3 -c 'import json,sys;print(json.dumps(sys.stdin.read()))'),
"skip_flags": [s for s in "$SKIP_FLAGS".split(",") if s],
}
if "$BRIEF": ev["brief"] = int("$BRIEF")
if "$CLASSIFICATION": ev["classification"] = "$CLASSIFICATION"
if "$DURATION_S": ev["duration_s"] = int("$DURATION_S")
if "$STACK_CLASS": ev["stack_class"] = "$STACK_CLASS"
if "$OUTCOME": ev["outcome"] = "$OUTCOME"
if "$MULTITASK_GROUP": ev["multitask_group"] = "$MULTITASK_GROUP"
print(json.dumps(ev))
PY
echo "Logged: role=$ROLE convoy=$CONVOY → .convoys/.metrics.jsonl"

37
scripts/wt.sh Executable file
View file

@ -0,0 +1,37 @@
#!/usr/bin/env bash
# wt.sh — DEPRECATED in Cursor 3.2+.
#
# Cursor 3.2 (Apr 24, 2026) added native worktree management to the
# Agents Window with one-click foregrounding. Use that instead:
# https://cursor.com/docs/configuration/worktrees
#
# This stub is kept for two reasons:
# 1. Pre-3.2 users who haven't upgraded yet.
# 2. Scripted / CI worktree creation outside the IDE.
#
# To create a worktree manually:
# git worktree add -b brief/<convoy>/<N>-<title> \
# "$(dirname "$(git rev-parse --show-toplevel)")/$(basename "$(git rev-parse --show-toplevel)")-worktrees/brief-<N>-<title>" \
# develop
#
# See docs/multitask-playbook.md for when to spin up worktrees vs.
# running implementers in the same checkout.
set -euo pipefail
cat <<'EOF' >&2
wt.sh: deprecated. In Cursor 3.2+ use the Agents Window worktree UI.
Why this is deprecated:
- Cursor 3.2 worktrees integrate with subagent runs and one-click foreground.
- The legacy script duplicates that feature without the integration.
What to do instead:
- In Cursor: open Agents Window → "New worktree" → pick brief branch.
- For CI / scripted use: run `git worktree add` directly.
Reference: docs/multitask-playbook.md (worktrees section)
https://cursor.com/changelog/04-24-26
EOF
exit 0