feat(convoys): convoy lifecycle MCP (Epic 4)

feat(convoys): convoy lifecycle MCP (Epic 4)
This commit is contained in:
varutasu 2026-07-07 14:51:30 -05:00 committed by GitHub
commit 4bb553c15f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 5218 additions and 7 deletions

View file

@ -14,6 +14,7 @@
"@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,8 +15,10 @@ 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 the Tasks project management app: objects, workspaces, views, and templates.", "Tools and resources for Echodo: objects, workspaces, backlog claim/complete, and convoy lifecycle (create_convoy, transition_convoy_status, reconcile_from_files).",
}, },
); );

View file

@ -0,0 +1,147 @@
// @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

@ -0,0 +1,94 @@
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

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

View file

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

View file

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

View file

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

View file

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

View file

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

38
docs/convoy-lifecycle.md Normal file
View file

@ -0,0 +1,38 @@
# Convoy lifecycle
Server-enforced convoy state machine for the agent pipeline (Epic 4). Convoys map to Echodo `project` objects; briefs map to child `task` objects.
## Status enum
`draft` → `convoy_review` → `ready` → `in_progress` → `pr_review` → `merged` → `released`
Terminal / exceptional: `blocked`, `cancelled`
## MCP tools
| Tool | Purpose |
|------|---------|
| `create_convoy` | Create `project` at `draft` with convoy metadata in `content` |
| `create_brief` | Create child `task` brief under a convoy |
| `transition_convoy_status` | Enforce actor + transition rules; writes `convoy_events` |
| `log_convoy_event` | Append role metrics / analytics row |
| `query_manifest_status` | Convoy status, briefs, recent events |
| `reconcile_from_files` | Import `.convoys/*.md` into Echodo; report pending outbox |
## Gate actors
- `human-gate-1` — plan approval (`convoy_review` ↔ `ready` / `draft`)
- `human-gate-2` — PR merge gate (`pr_review` ↔ `merged` / `in_progress`)
- `human-gate-3` — prod promote (`merged` → `released`)
Pipeline roles (`role-architect`, `role-implementer`, …) cannot perform gate transitions.
## Local-first
Convoy markdown lives in `.convoys/` (see `.convoys/README.md`). Failed MCP calls should append to `.convoys/.pending-mcp-sync.jsonl`; `reconcile_from_files` reports and clears the outbox after sync.
## Implementation
- State machine: `packages/shared/src/convoy-lifecycle/`
- Events table: `packages/database/src/schema/convoy_events.ts`
- MCP: `apps/mcp-server/src/tools/*convoy*`

View file

@ -0,0 +1,28 @@
CREATE TABLE "convoy_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"convoy_id" uuid NOT NULL,
"brief_id" uuid,
"role" varchar(64) NOT NULL,
"actor" varchar(64),
"event_type" varchar(32) NOT NULL,
"classification" varchar(32),
"skip_flags" jsonb,
"duration_s" integer,
"outcome" varchar(20),
"repo" varchar(120),
"multitask_group" varchar(64),
"model_alias" varchar(120),
"provider_type" varchar(40),
"model_id" varchar(200),
"model_tier" varchar(20),
"estimated_cost_usd" numeric(12, 6),
"metadata" jsonb,
"occurred_at" 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 cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "convoy_events" ADD CONSTRAINT "convoy_events_brief_id_objects_id_fk" FOREIGN KEY ("brief_id") REFERENCES "public"."objects"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "convoy_events_workspace_id_occurred_at_idx" ON "convoy_events" USING btree ("workspace_id","occurred_at" DESC NULLS LAST);--> statement-breakpoint
CREATE INDEX "convoy_events_convoy_id_occurred_at_idx" ON "convoy_events" USING btree ("convoy_id","occurred_at" DESC NULLS LAST);

File diff suppressed because it is too large Load diff

View file

@ -92,6 +92,13 @@
"when": 1783451965716, "when": 1783451965716,
"tag": "0012_uneven_captain_stacy", "tag": "0012_uneven_captain_stacy",
"breakpoints": true "breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1783453846679,
"tag": "0013_crazy_joseph",
"breakpoints": true
} }
] ]
} }

View file

@ -0,0 +1,57 @@
import {
index,
integer,
jsonb,
numeric,
pgTable,
text,
timestamp,
uuid,
varchar,
} from "drizzle-orm/pg-core";
import { objects } from "./objects";
import { workspaces } from "./workspaces";
/** Append-only convoy lifecycle and role metrics log (replaces local JSONL when MCP is up). */
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")
.notNull()
.references(() => objects.id, { onDelete: "cascade" }),
briefId: uuid("brief_id").references(() => objects.id, { onDelete: "set null" }),
role: varchar("role", { length: 64 }).notNull(),
actor: varchar("actor", { length: 64 }),
eventType: varchar("event_type", { length: 32 }).notNull(),
classification: varchar("classification", { length: 32 }),
skipFlags: jsonb("skip_flags").$type<string[] | null>(),
durationS: integer("duration_s"),
outcome: varchar("outcome", { length: 20 }),
repo: varchar("repo", { length: 120 }),
multitaskGroup: varchar("multitask_group", { length: 64 }),
modelAlias: varchar("model_alias", { length: 120 }),
providerType: varchar("provider_type", { length: 40 }),
modelId: varchar("model_id", { length: 200 }),
modelTier: varchar("model_tier", { length: 20 }),
estimatedCostUsd: numeric("estimated_cost_usd", { precision: 12, scale: 6 }),
metadata: jsonb("metadata").$type<Record<string, unknown> | null>(),
occurredAt: timestamp("occurred_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(table) => ({
workspaceOccurredIdx: index("convoy_events_workspace_id_occurred_at_idx").on(
table.workspaceId,
table.occurredAt.desc(),
),
convoyOccurredIdx: index("convoy_events_convoy_id_occurred_at_idx").on(
table.convoyId,
table.occurredAt.desc(),
),
}),
);

View file

@ -16,3 +16,4 @@ export * from "./agent_runs";
export * from "./model_providers"; export * from "./model_providers";
export * from "./role_model_routes"; export * from "./role_model_routes";
export * from "./connected_repos"; export * from "./connected_repos";
export * from "./convoy_events";

View file

@ -22,6 +22,7 @@ import { agentRuns } from "./agent_runs";
import { modelProviders } from "./model_providers"; import { modelProviders } from "./model_providers";
import { roleModelRoutes } from "./role_model_routes"; import { roleModelRoutes } from "./role_model_routes";
import { connectedRepos } from "./connected_repos"; import { connectedRepos } from "./connected_repos";
import { convoyEvents } from "./convoy_events";
export const objectRelations = pgTable( export const objectRelations = pgTable(
"object_relations", "object_relations",
@ -122,6 +123,7 @@ export const workspacesRelations = relations(workspaces, ({ one, many }) => ({
modelProviders: many(modelProviders), modelProviders: many(modelProviders),
roleModelRoutes: many(roleModelRoutes), roleModelRoutes: many(roleModelRoutes),
connectedRepos: many(connectedRepos), connectedRepos: many(connectedRepos),
convoyEvents: many(convoyEvents),
})); }));
export const objectsRelations = relations(objects, ({ one, many }) => ({ export const objectsRelations = relations(objects, ({ one, many }) => ({
@ -279,6 +281,23 @@ export const modelProvidersRelations = relations(modelProviders, ({ one, many })
roleRoutes: many(roleModelRoutes), roleRoutes: many(roleModelRoutes),
})); }));
export const convoyEventsRelations = relations(convoyEvents, ({ one }) => ({
workspace: one(workspaces, {
fields: [convoyEvents.workspaceId],
references: [workspaces.id],
}),
convoy: one(objects, {
fields: [convoyEvents.convoyId],
references: [objects.id],
relationName: "convoyEventConvoy",
}),
brief: one(objects, {
fields: [convoyEvents.briefId],
references: [objects.id],
relationName: "convoyEventBrief",
}),
}));
export const connectedReposRelations = relations(connectedRepos, ({ one }) => ({ export const connectedReposRelations = relations(connectedRepos, ({ one }) => ({
workspace: one(workspaces, { workspace: one(workspaces, {
fields: [connectedRepos.workspaceId], fields: [connectedRepos.workspaceId],

View file

@ -8,6 +8,7 @@
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./connected-repos": "./src/connected-repos/index.ts", "./connected-repos": "./src/connected-repos/index.ts",
"./convoy-lifecycle": "./src/convoy-lifecycle/index.ts",
"./model-routing": "./src/model-routing/index.ts", "./model-routing": "./src/model-routing/index.ts",
"./types": "./src/types/index.ts", "./types": "./src/types/index.ts",
"./utils": "./src/utils/index.ts" "./utils": "./src/utils/index.ts"

View file

@ -0,0 +1,28 @@
export {
briefObjectContentSchema,
convoyActorSchema,
convoyClassificationSchema,
convoyEventTypeSchema,
convoyFileStatusSchema,
convoyObjectContentSchema,
convoyStatusSchema,
convoyTransitionErrorCodeSchema,
transitionConvoyInputSchema,
type BriefObjectContent,
type ConvoyClassification,
type ConvoyEventType,
type ConvoyFileStatus,
type ConvoyObjectContent,
type ConvoyStatus,
type ConvoyTransitionErrorCode,
type TransitionConvoyInput,
} from "./schemas";
export {
ALLOWED_TRANSITIONS,
ConvoyTransitionError,
FILE_STATUS_TO_SERVER,
VALID_TRANSITIONS,
mapFileStatusToServer,
validateConvoyTransition,
} from "./state-machine";

View file

@ -0,0 +1,85 @@
import { z } from "zod";
import { convoyModelPolicySchema } from "../model-routing/schemas";
export const convoyStatusSchema = z.enum([
"draft",
"convoy_review",
"ready",
"in_progress",
"pr_review",
"merged",
"released",
"blocked",
"cancelled",
]);
export type ConvoyStatus = z.infer<typeof convoyStatusSchema>;
export const convoyClassificationSchema = z.enum([
"feature",
"hotfix",
"docs-only",
"infra-only",
"server-only",
"config-only",
]);
export type ConvoyClassification = z.infer<typeof convoyClassificationSchema>;
export const convoyFileStatusSchema = z.enum([
"open",
"in-progress",
"merged",
"shipped",
"abandoned",
]);
export type ConvoyFileStatus = z.infer<typeof convoyFileStatusSchema>;
export const convoyActorSchema = z.string().min(1).max(64);
export const convoyEventTypeSchema = z.enum([
"role_complete",
"status_change",
"brief_created",
"reconcile",
]);
export type ConvoyEventType = z.infer<typeof convoyEventTypeSchema>;
export const convoyObjectContentSchema = z.object({
convoySlug: z.string().min(1).max(120),
classification: convoyClassificationSchema,
skipFlags: z.array(z.string()).default([]),
successMetric: z.string().min(1),
modelPolicy: convoyModelPolicySchema.optional(),
});
export type ConvoyObjectContent = z.infer<typeof convoyObjectContentSchema>;
export const briefObjectContentSchema = z.object({
briefNumber: z.number().int().positive(),
files: z.array(z.string()).default([]),
sliceDependencies: z.array(z.number().int().positive()).default([]),
acceptance: z.string().optional(),
});
export type BriefObjectContent = z.infer<typeof briefObjectContentSchema>;
export const transitionConvoyInputSchema = z.object({
convoyId: z.string().uuid(),
toStatus: convoyStatusSchema,
actor: convoyActorSchema,
reason: z.string().min(1).max(2_000).optional(),
});
export type TransitionConvoyInput = z.infer<typeof transitionConvoyInputSchema>;
export const convoyTransitionErrorCodeSchema = z.enum([
"INVALID_TRANSITION",
"FORBIDDEN_ACTOR",
"REASON_REQUIRED",
]);
export type ConvoyTransitionErrorCode = z.infer<typeof convoyTransitionErrorCodeSchema>;

View file

@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import { ConvoyTransitionError, validateConvoyTransition } from "./state-machine";
describe("validateConvoyTransition", () => {
it("rejects draft → released", () => {
expect(() =>
validateConvoyTransition({
from: "draft",
to: "released",
actor: "human-gate-1",
}),
).toThrow(ConvoyTransitionError);
try {
validateConvoyTransition({
from: "draft",
to: "released",
actor: "human-gate-1",
});
} catch (error) {
expect(error).toBeInstanceOf(ConvoyTransitionError);
expect((error as ConvoyTransitionError).code).toBe("INVALID_TRANSITION");
}
});
it("rejects role-architect on human-gate-1 transition", () => {
expect(() =>
validateConvoyTransition({
from: "convoy_review",
to: "ready",
actor: "role-architect",
}),
).toThrow(ConvoyTransitionError);
try {
validateConvoyTransition({
from: "convoy_review",
to: "ready",
actor: "role-architect",
});
} catch (error) {
expect((error as ConvoyTransitionError).code).toBe("FORBIDDEN_ACTOR");
}
});
it("allows human-gate-1 to approve plan", () => {
expect(() =>
validateConvoyTransition({
from: "convoy_review",
to: "ready",
actor: "human-gate-1",
}),
).not.toThrow();
});
it("allows role-architect to submit plan for review", () => {
expect(() =>
validateConvoyTransition({
from: "draft",
to: "convoy_review",
actor: "role-architect",
}),
).not.toThrow();
});
it("requires reason for blocked transitions", () => {
try {
validateConvoyTransition({
from: "ready",
to: "blocked",
actor: "role-implementer",
});
} catch (error) {
expect((error as ConvoyTransitionError).code).toBe("REASON_REQUIRED");
}
});
});

View file

@ -0,0 +1,211 @@
import type { ConvoyStatus, ConvoyTransitionErrorCode } from "./schemas";
export const VALID_TRANSITIONS: Record<ConvoyStatus, ConvoyStatus[]> = {
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: [],
};
type TransitionRule = {
from: ConvoyStatus;
to: ConvoyStatus;
actors: readonly string[];
requiresReason?: boolean;
};
/** Explicit (from, to, actor) allow-list — gate actors are never implied by pipeline roles. */
export const ALLOWED_TRANSITIONS: readonly TransitionRule[] = [
{ from: "draft", to: "convoy_review", actors: ["role-architect"] },
{
from: "draft",
to: "blocked",
actors: ["role-architect", "role-conductor", "human-gate-1", "automation"],
requiresReason: true,
},
{
from: "draft",
to: "cancelled",
actors: ["human-gate-1", "role-conductor"],
requiresReason: true,
},
{ from: "convoy_review", to: "ready", actors: ["human-gate-1"] },
{ from: "convoy_review", to: "draft", actors: ["human-gate-1"] },
{
from: "convoy_review",
to: "blocked",
actors: ["human-gate-1", "role-architect", "automation"],
requiresReason: true,
},
{
from: "convoy_review",
to: "cancelled",
actors: ["human-gate-1"],
requiresReason: true,
},
{ from: "ready", to: "in_progress", actors: ["role-implementer"] },
{
from: "ready",
to: "blocked",
actors: ["role-implementer", "human-gate-1", "automation"],
requiresReason: true,
},
{
from: "ready",
to: "cancelled",
actors: ["human-gate-1", "role-conductor"],
requiresReason: true,
},
{ from: "in_progress", to: "pr_review", actors: ["role-implementer", "automation"] },
{
from: "in_progress",
to: "blocked",
actors: ["role-implementer", "human-gate-2", "automation"],
requiresReason: true,
},
{
from: "in_progress",
to: "cancelled",
actors: ["human-gate-1", "role-conductor"],
requiresReason: true,
},
{ from: "pr_review", to: "merged", actors: ["human-gate-2"] },
{ from: "pr_review", to: "in_progress", actors: ["human-gate-2", "role-implementer"] },
{
from: "pr_review",
to: "blocked",
actors: ["human-gate-2", "automation"],
requiresReason: true,
},
{
from: "pr_review",
to: "cancelled",
actors: ["human-gate-1", "human-gate-2"],
requiresReason: true,
},
{ from: "merged", to: "released", actors: ["human-gate-3"] },
{
from: "merged",
to: "blocked",
actors: ["human-gate-3", "automation"],
requiresReason: true,
},
{
from: "blocked",
to: "draft",
actors: ["human-gate-1", "role-conductor", "automation"],
requiresReason: true,
},
{
from: "blocked",
to: "convoy_review",
actors: ["human-gate-1", "role-conductor", "automation"],
requiresReason: true,
},
{
from: "blocked",
to: "ready",
actors: ["human-gate-1", "role-conductor", "automation"],
requiresReason: true,
},
{
from: "blocked",
to: "in_progress",
actors: ["human-gate-2", "role-conductor", "automation"],
requiresReason: true,
},
{
from: "blocked",
to: "pr_review",
actors: ["human-gate-2", "role-conductor", "automation"],
requiresReason: true,
},
{
from: "blocked",
to: "merged",
actors: ["human-gate-2", "role-conductor", "automation"],
requiresReason: true,
},
{
from: "blocked",
to: "cancelled",
actors: ["human-gate-1", "human-gate-2", "human-gate-3", "role-conductor"],
requiresReason: true,
},
];
export class ConvoyTransitionError extends Error {
readonly code: ConvoyTransitionErrorCode;
constructor(message: string, code: ConvoyTransitionErrorCode) {
super(message);
this.name = "ConvoyTransitionError";
this.code = code;
}
}
export function validateConvoyTransition(input: {
from: ConvoyStatus;
to: ConvoyStatus;
actor: string;
reason?: string;
}): void {
const allowedTargets = VALID_TRANSITIONS[input.from];
if (!allowedTargets.includes(input.to)) {
throw new ConvoyTransitionError(
`Invalid transition ${input.from} → ${input.to}`,
"INVALID_TRANSITION",
);
}
const rule = ALLOWED_TRANSITIONS.find(
(candidate) => candidate.from === input.from && candidate.to === input.to,
);
if (!rule) {
throw new ConvoyTransitionError(
`No actor may perform transition ${input.from} → ${input.to}`,
"FORBIDDEN_ACTOR",
);
}
if (!rule.actors.includes(input.actor)) {
throw new ConvoyTransitionError(
`Actor ${input.actor} cannot perform transition ${input.from} → ${input.to}`,
"FORBIDDEN_ACTOR",
);
}
if (rule.requiresReason && !input.reason?.trim()) {
throw new ConvoyTransitionError(
`Transition ${input.from} → ${input.to} requires a reason`,
"REASON_REQUIRED",
);
}
}
/** Map local `.convoys/*.md` frontmatter status to server enum for reconcile. */
export const FILE_STATUS_TO_SERVER: Record<string, ConvoyStatus> = {
open: "draft",
"in-progress": "in_progress",
merged: "merged",
shipped: "released",
abandoned: "cancelled",
};
export function mapFileStatusToServer(fileStatus: string): ConvoyStatus | null {
return FILE_STATUS_TO_SERVER[fileStatus] ?? null;
}

View file

@ -1,4 +1,5 @@
export * from "./connected-repos"; export * from "./connected-repos";
export * from "./convoy-lifecycle";
export * from "./model-routing"; export * from "./model-routing";
export * from "./types"; export * from "./types";
export * from "./utils"; export * from "./utils";

View file

@ -3,7 +3,7 @@ kind: epic
slug: convoy-lifecycle-mcp slug: convoy-lifecycle-mcp
title: Convoy lifecycle MCP — state machine and convoy_events title: Convoy lifecycle MCP — state machine and convoy_events
plan_slug: automation-foundation plan_slug: automation-foundation
status: draft status: done
priority: P1 priority: P1
tenant_id: global tenant_id: global
cursor_epic_id: null cursor_epic_id: null
@ -43,10 +43,12 @@ Reference: agent-pipeline v0.4 plan §7; local-first `.convoys/` with `.pending-
## Acceptance criteria ## Acceptance criteria
- [ ] Invalid transition `draft → released` rejected with structured error. - [x] Invalid transition `draft → released` rejected with structured error.
- [ ] `role-architect` cannot call `human-gate-1` transition. - [x] `role-architect` cannot call `human-gate-1` transition.
- [ ] Offline convoy edit reconciles via `reconcile_from_files`. - [x] Offline convoy edit reconciles via `reconcile_from_files`.
## Tasks ## Tasks
_To be decomposed when epic graduates to `ready`._ - [x] [Task-shared-convoy-state-machine](./Task-shared-convoy-state-machine.md)
- [x] [Task-db-convoy-events-migration](./Task-db-convoy-events-migration.md)
- [x] [Task-mcp-convoy-lifecycle-tools](./Task-mcp-convoy-lifecycle-tools.md)

View file

@ -0,0 +1,22 @@
---
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-07-07"
---
# Task summary
Append-only `convoy_events` with workspace + convoy indexes and ADR model fields.
## Acceptance criteria
- [x] Migration `0013` generated via `pnpm db:generate`
- [x] FK to `objects` for convoy and optional brief

View file

@ -0,0 +1,22 @@
---
kind: task
slug: mcp-convoy-lifecycle-tools
title: MCP tools for convoy create, transition, log, query, reconcile
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-07-07"
---
# Task summary
Six MCP tools in `apps/mcp-server` with zod-validated args and workspace scoping.
## Acceptance criteria
- [x] `create_convoy`, `create_brief`, `transition_convoy_status`, `log_convoy_event`, `query_manifest_status`, `reconcile_from_files`
- [x] `docs/convoy-lifecycle.md` documents status enum and gate actors

View file

@ -0,0 +1,22 @@
---
kind: task
slug: shared-convoy-state-machine
title: Shared convoy status enum, VALID_TRANSITIONS, and actor guards
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-07-07"
---
# Task summary
Pure `validateConvoyTransition()` with gate actor enforcement and vitest coverage.
## Acceptance criteria
- [x] `draft → released` rejected (`INVALID_TRANSITION`)
- [x] `role-architect` cannot perform `convoy_review → ready` (`FORBIDDEN_ACTOR`)

View file

@ -165,7 +165,7 @@ flowchart LR
| 1 | [Model provider registry](./Epic-model-provider-registry/Epic-model-provider-registry.md) | **ready** (6 tasks) | `resolveModel()`, Settings AI pages, run model logging | — | | 1 | [Model provider registry](./Epic-model-provider-registry/Epic-model-provider-registry.md) | **ready** (6 tasks) | `resolveModel()`, Settings AI pages, run model logging | — |
| 2 | [Pipeline convoy scaffolding](./Epic-pipeline-convoy-scaffolding/Epic-pipeline-convoy-scaffolding.md) | done | `.convoys/`, `log-convoy-event.sh`, `convoy-planning.mdc` — dogfood metrics | — (parallel with 1) | | 2 | [Pipeline convoy scaffolding](./Epic-pipeline-convoy-scaffolding/Epic-pipeline-convoy-scaffolding.md) | done | `.convoys/`, `log-convoy-event.sh`, `convoy-planning.mdc` — dogfood metrics | — (parallel with 1) |
| 3 | [Connected repos](./Epic-connected-repos/Epic-connected-repos.md) | done | `repo_links` table, “paste remote URL”, forge adapter interface | — | | 3 | [Connected repos](./Epic-connected-repos/Epic-connected-repos.md) | done | `repo_links` table, “paste remote URL”, forge adapter interface | — |
| 4 | [Convoy lifecycle MCP](./Epic-convoy-lifecycle-mcp/Epic-convoy-lifecycle-mcp.md) | draft | `create_convoy`, `transition_convoy_status`, state machine §7, `convoy_events` | 2, 3 | | 4 | [Convoy lifecycle MCP](./Epic-convoy-lifecycle-mcp/Epic-convoy-lifecycle-mcp.md) | done | `create_convoy`, `transition_convoy_status`, state machine §7, `convoy_events` | 2, 3 |
| 5 | [Mission control gates UI](./Epic-mission-control-gates-ui/Epic-mission-control-gates-ui.md) | draft | Convoy kanban, gate inbox, approve/deny/revise, gate co-pilot chat | 4 | | 5 | [Mission control gates UI](./Epic-mission-control-gates-ui/Epic-mission-control-gates-ui.md) | draft | Convoy kanban, gate inbox, approve/deny/revise, gate co-pilot chat | 4 |
| 6 | [Orchestrator](./Epic-apps-orchestrator/Epic-apps-orchestrator.md) | draft | `apps/orchestrator`, Redis queue, role dispatch, pause at gates | 1, 3, 4 | | 6 | [Orchestrator](./Epic-apps-orchestrator/Epic-apps-orchestrator.md) | draft | `apps/orchestrator`, Redis queue, role dispatch, pause at gates | 1, 3, 4 |
| 7 | [Worker v1](./Epic-apps-worker-v1/Epic-apps-worker-v1.md) | draft | `apps/worker` minimal tool loop, worktree-per-brief, MCP claim/complete | 1, 3, 6 | | 7 | [Worker v1](./Epic-apps-worker-v1/Epic-apps-worker-v1.md) | draft | `apps/worker` minimal tool loop, worktree-per-brief, MCP claim/complete | 1, 3, 6 |

View file

@ -75,6 +75,9 @@ importers:
'@tasks/shared': '@tasks/shared':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/shared version: link:../../packages/shared
yaml:
specifier: ^2.8.3
version: 2.8.3
zod: zod:
specifier: ^3.24.0 specifier: ^3.24.0
version: 3.25.76 version: 3.25.76