Adds the data layer for per-item agent prompts. Markdown frontmatter gets an `agent_prompt:` block scalar that survives the importer round-trip (newlines preserved), and `resolveWorkflowPrompt()` walks task → epic → plan → built-in default returning both the resolved string and the source level. Walk is slug-based, not parent_id-based, because the importer leaves parent_id briefly null mid-transaction. tRPC `backlog.getWorkflowPrompt` returns ownOverride + effectivePrompt so future UI can render the override box + preview without two queries. `backlog.updateWorkflowPrompt` is owner/admin-gated (prompts change downstream Cursor/Claude behavior) and audit-logged on every write. UI deferred — apps/web doesn't have a backlog-item detail panel yet; the existing object-detail panel is for the objects table. Follow-up filed at Task-workflow-prompt-task-detail-ui.md. Co-authored-by: Cursor <cursoragent@cursor.com>
129 lines
4 KiB
TypeScript
129 lines
4 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { parse as parseYaml } from "yaml";
|
|
import type { BacklogKind } from "./kinds";
|
|
import { isBacklogKind } from "./kinds";
|
|
import { epicFolderFromPath, planSlugFromPath } from "./paths";
|
|
|
|
const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
|
|
|
export type ParsedBacklogFile = {
|
|
contentHash: string;
|
|
frontmatter: Record<string, unknown>;
|
|
bodyMarkdown: string;
|
|
kind: BacklogKind;
|
|
slug: string;
|
|
planSlug: string;
|
|
epicSlug: string | null;
|
|
title: string;
|
|
status: string | null;
|
|
priority: string | null;
|
|
owner: string | null;
|
|
/**
|
|
* Optional per-item agent prompt sourced from frontmatter `agent_prompt:`.
|
|
* Used as the first-message context when an MCP `claim_task` call resolves
|
|
* the effective prompt (see `resolveWorkflowPrompt`). Null means "inherit
|
|
* from epic, then plan, then the built-in default."
|
|
*/
|
|
workflowPrompt: string | null;
|
|
};
|
|
|
|
function inferKindFromFilename(filename: string): BacklogKind | null {
|
|
if (filename.startsWith("Plan-")) return "plan";
|
|
if (filename.startsWith("Epic-")) return "epic";
|
|
if (filename.startsWith("Task-")) return "task";
|
|
return null;
|
|
}
|
|
|
|
function readString(fm: Record<string, unknown>, key: string): string | null {
|
|
const v = fm[key];
|
|
return typeof v === "string" && v.trim() ? v.trim() : null;
|
|
}
|
|
|
|
/**
|
|
* Read a multi-line string from frontmatter (typically a YAML block scalar
|
|
* written with `|`). Preserves internal newlines so prompts with structured
|
|
* formatting survive the round-trip, but trims surrounding whitespace.
|
|
* Returns null on missing / non-string / empty values.
|
|
*/
|
|
function readMultilineString(
|
|
fm: Record<string, unknown>,
|
|
key: string,
|
|
): string | null {
|
|
const v = fm[key];
|
|
if (typeof v !== "string") return null;
|
|
// Block scalars typically have a trailing newline from YAML's `|` chomping
|
|
// rule; strip leading/trailing whitespace but keep interior newlines.
|
|
const trimmed = v.replace(/^\s+/, "").replace(/\s+$/, "");
|
|
return trimmed ? trimmed : null;
|
|
}
|
|
|
|
function firstHeading(markdown: string): string | null {
|
|
const m = markdown.match(/^\s*#\s+(.+)$/m);
|
|
return m?.[1]?.trim() ?? null;
|
|
}
|
|
|
|
export function hashFileContents(raw: string): string {
|
|
return createHash("sha256").update(raw, "utf8").digest("hex");
|
|
}
|
|
|
|
/**
|
|
* Parse a markdown document with optional YAML frontmatter.
|
|
* `repoPathPosix` is used to infer plan/epic segments when frontmatter omits them.
|
|
*/
|
|
export function parseBacklogMarkdown(
|
|
raw: string,
|
|
repoPathPosix: string,
|
|
): ParsedBacklogFile {
|
|
const contentHash = hashFileContents(raw);
|
|
let frontmatter: Record<string, unknown> = {};
|
|
let bodyMarkdown = raw;
|
|
const fmMatch = raw.match(FRONTMATTER);
|
|
if (fmMatch) {
|
|
try {
|
|
const parsed = parseYaml(fmMatch[1]);
|
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
frontmatter = parsed as Record<string, unknown>;
|
|
}
|
|
} catch {
|
|
frontmatter = {};
|
|
}
|
|
bodyMarkdown = raw.slice(fmMatch[0].length);
|
|
}
|
|
|
|
const filename = repoPathPosix.split("/").pop() ?? "";
|
|
const baseName = filename.replace(/\.md$/i, "");
|
|
|
|
const fmKind = readString(frontmatter, "kind");
|
|
const inferred = inferKindFromFilename(filename);
|
|
const kind: BacklogKind =
|
|
fmKind && isBacklogKind(fmKind) ? fmKind : inferred ?? "task";
|
|
|
|
const slug =
|
|
readString(frontmatter, "slug") ??
|
|
(baseName.replace(/^(Plan|Epic|Task)-/, "") || baseName);
|
|
|
|
const planSlug =
|
|
readString(frontmatter, "plan_slug") ?? planSlugFromPath(repoPathPosix) ?? slug;
|
|
|
|
const epicSlug =
|
|
readString(frontmatter, "epic_slug") ??
|
|
(kind === "task" ? epicFolderFromPath(repoPathPosix) : null);
|
|
|
|
const title =
|
|
readString(frontmatter, "title") ?? firstHeading(bodyMarkdown) ?? baseName;
|
|
|
|
return {
|
|
contentHash,
|
|
frontmatter,
|
|
bodyMarkdown,
|
|
kind,
|
|
slug,
|
|
planSlug,
|
|
epicSlug,
|
|
title,
|
|
status: readString(frontmatter, "status"),
|
|
priority: readString(frontmatter, "priority"),
|
|
owner: readString(frontmatter, "owner"),
|
|
workflowPrompt: readMultilineString(frontmatter, "agent_prompt"),
|
|
};
|
|
}
|