Adds server-enforced convoy transitions with gate actor guards, convoy_events logging, and six MCP tools (create, brief, transition, log, query, reconcile). Co-authored-by: Cursor <cursoragent@cursor.com>
147 lines
4.2 KiB
TypeScript
147 lines
4.2 KiB
TypeScript
// @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");
|
|
}
|
|
}
|