ubiquitous-invention/apps/mcp-server/src/tools/create-convoy.ts
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

131 lines
4.1 KiB
TypeScript

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