feat(backlog): workflow_prompt with task → epic → plan inheritance
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>
This commit is contained in:
parent
f014686412
commit
72aa2a5f0c
15 changed files with 3400 additions and 11 deletions
|
|
@ -14,6 +14,7 @@ import { identityRouter } from "@/server/routers/identity";
|
|||
import { invitesRouter } from "@/server/routers/invites";
|
||||
import { auditRouter } from "@/server/routers/audit";
|
||||
import { runsRouter } from "@/server/routers/runs";
|
||||
import { backlogRouter } from "@/server/routers/backlog";
|
||||
|
||||
export const appRouter = router({
|
||||
health: healthRouter,
|
||||
|
|
@ -31,6 +32,7 @@ export const appRouter = router({
|
|||
invites: invitesRouter,
|
||||
audit: auditRouter,
|
||||
runs: runsRouter,
|
||||
backlog: backlogRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
|
|
|||
133
apps/web/server/routers/backlog.ts
Normal file
133
apps/web/server/routers/backlog.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
import { markdownBacklogItems } from "@tasks/database/schema";
|
||||
import {
|
||||
resolveWorkflowPrompt,
|
||||
DEFAULT_AGENT_PROMPT,
|
||||
} from "@tasks/database/markdown-backlog";
|
||||
|
||||
import { router, workspaceProcedure } from "@/server/trpc";
|
||||
import { recordAudit } from "@/server/lib/audit";
|
||||
|
||||
const MAX_PROMPT_LENGTH = 20_000;
|
||||
|
||||
/**
|
||||
* tRPC procedures for managing the markdown-backlog rows in the DB,
|
||||
* specifically the parts of the row that the markdown importer DOESN'T
|
||||
* own — currently just `workflow_prompt` overrides set via UI rather
|
||||
* than frontmatter.
|
||||
*
|
||||
* Read-side procedures here are deliberately narrow; the heavy listing
|
||||
* is in the markdown importer's downstream UI (Plans tree). The job of
|
||||
* THIS router is "let me edit the workflow prompt without re-importing
|
||||
* from disk."
|
||||
*/
|
||||
export const backlogRouter = router({
|
||||
/**
|
||||
* Return both the item's own (possibly null) override and the resolved
|
||||
* effective prompt with its source level. Used by the future task
|
||||
* detail panel to render the "Override" textarea pre-filled and the
|
||||
* "Effective" preview correctly.
|
||||
*/
|
||||
getWorkflowPrompt: workspaceProcedure
|
||||
.input(z.object({ backlogItemId: z.string().uuid() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const [row] = await ctx.db
|
||||
.select({
|
||||
id: markdownBacklogItems.id,
|
||||
workflowPrompt: markdownBacklogItems.workflowPrompt,
|
||||
})
|
||||
.from(markdownBacklogItems)
|
||||
.where(
|
||||
and(
|
||||
eq(markdownBacklogItems.id, input.backlogItemId),
|
||||
eq(markdownBacklogItems.workspaceId, ctx.workspace.id),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!row) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Backlog item not found in this workspace.",
|
||||
});
|
||||
}
|
||||
|
||||
const resolved = await resolveWorkflowPrompt(ctx.db, {
|
||||
workspaceId: ctx.workspace.id,
|
||||
backlogItemId: input.backlogItemId,
|
||||
});
|
||||
|
||||
return {
|
||||
ownOverride: row.workflowPrompt,
|
||||
effectivePrompt: resolved.prompt,
|
||||
source: resolved.source,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Set or clear the item's own workflow_prompt override. Passing null /
|
||||
* empty string clears the override (the item then inherits). Owner /
|
||||
* admin only — agent prompts can change how Cursor/Claude behave in a
|
||||
* downstream session, so they're not a "any member can edit" surface.
|
||||
*/
|
||||
updateWorkflowPrompt: workspaceProcedure
|
||||
.input(
|
||||
z.object({
|
||||
backlogItemId: z.string().uuid(),
|
||||
workflowPrompt: z.string().max(MAX_PROMPT_LENGTH).nullable(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (ctx.workspace.role !== "owner" && ctx.workspace.role !== "admin") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only owners and admins can edit agent prompts.",
|
||||
});
|
||||
}
|
||||
|
||||
// Empty string normalizes to null so the inheritance walk sees "no
|
||||
// override here, keep walking" instead of "explicitly empty prompt."
|
||||
const next =
|
||||
input.workflowPrompt && input.workflowPrompt.trim()
|
||||
? input.workflowPrompt.trim()
|
||||
: null;
|
||||
|
||||
const [updated] = await ctx.db
|
||||
.update(markdownBacklogItems)
|
||||
.set({ workflowPrompt: next, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(markdownBacklogItems.id, input.backlogItemId),
|
||||
eq(markdownBacklogItems.workspaceId, ctx.workspace.id),
|
||||
),
|
||||
)
|
||||
.returning({
|
||||
id: markdownBacklogItems.id,
|
||||
workflowPrompt: markdownBacklogItems.workflowPrompt,
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Backlog item not found in this workspace.",
|
||||
});
|
||||
}
|
||||
|
||||
await recordAudit(ctx.db, {
|
||||
workspaceId: ctx.workspace.id,
|
||||
actorUserId: ctx.session.user.id,
|
||||
action: next ? "backlog.workflow_prompt_set" : "backlog.workflow_prompt_clear",
|
||||
targetType: "markdown_backlog_item",
|
||||
targetId: input.backlogItemId,
|
||||
metadata: { length: next?.length ?? 0 },
|
||||
});
|
||||
|
||||
return updated;
|
||||
}),
|
||||
});
|
||||
|
||||
export type BacklogRouter = typeof backlogRouter;
|
||||
export { DEFAULT_AGENT_PROMPT };
|
||||
5
docs/templates/epic-template.md
vendored
5
docs/templates/epic-template.md
vendored
|
|
@ -8,6 +8,11 @@ priority: P2
|
|||
tenant_id: "<tenant-uuid-or-placeholder>"
|
||||
cursor_epic_id: null
|
||||
updated_at: "<ISO-8601>"
|
||||
# Optional: default agent prompt for every task under this epic that doesn't
|
||||
# set its own. Inherits from the plan if omitted.
|
||||
# agent_prompt: |
|
||||
# <Epic-level context: which codebase areas matter, which conventions to
|
||||
# honor, which tests to run.>
|
||||
---
|
||||
|
||||
# Epic objective
|
||||
|
|
|
|||
5
docs/templates/plan-template.md
vendored
5
docs/templates/plan-template.md
vendored
|
|
@ -7,6 +7,11 @@ priority: P2
|
|||
tenant_id: "<tenant-uuid-or-placeholder>"
|
||||
cursor_plan_id: null
|
||||
updated_at: "<ISO-8601>"
|
||||
# Optional: default agent prompt for every task under this plan that doesn't
|
||||
# override at epic or task level. Falls back to a built-in default when null.
|
||||
# agent_prompt: |
|
||||
# <Plan-level context: product, multitenancy posture, stack, prohibited
|
||||
# actions, etc.>
|
||||
---
|
||||
|
||||
# Plan overview
|
||||
|
|
|
|||
8
docs/templates/task-template.md
vendored
8
docs/templates/task-template.md
vendored
|
|
@ -10,6 +10,14 @@ tenant_id: "<tenant-uuid-or-placeholder>"
|
|||
owner: "<name-or-unassigned>"
|
||||
cursor_todo_id: null
|
||||
updated_at: "<ISO-8601>"
|
||||
# Uncomment to set a task-specific agent prompt. Most tasks inherit from
|
||||
# the epic or plan; only set this when the task needs different first-message
|
||||
# context (e.g. a security-sensitive change, or one with a non-standard
|
||||
# verification protocol).
|
||||
# agent_prompt: |
|
||||
# You are completing one focused task. Read the task body before writing
|
||||
# any code. Validate inputs with zod. Run lint/type-check/test before
|
||||
# declaring done.
|
||||
---
|
||||
|
||||
# Task summary
|
||||
|
|
|
|||
1
packages/database/migrations/0008_curly_zzzax.sql
Normal file
1
packages/database/migrations/0008_curly_zzzax.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "markdown_backlog_items" ADD COLUMN "workflow_prompt" text;
|
||||
2878
packages/database/migrations/meta/0008_snapshot.json
Normal file
2878
packages/database/migrations/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -8,3 +8,8 @@ export {
|
|||
} from "./paths";
|
||||
export { parseBacklogMarkdown, hashFileContents, type ParsedBacklogFile } from "./parse";
|
||||
export { syncMarkdownBacklogScan, type SyncMarkdownBacklogResult } from "./sync";
|
||||
export {
|
||||
resolveWorkflowPrompt,
|
||||
DEFAULT_AGENT_PROMPT,
|
||||
type ResolvedWorkflowPrompt,
|
||||
} from "./resolve-prompt";
|
||||
|
|
|
|||
|
|
@ -86,6 +86,93 @@ describe("parseBacklogMarkdown", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("parseBacklogMarkdown — agent_prompt extraction", () => {
|
||||
const TASK_WITH_PROMPT = `---
|
||||
kind: task
|
||||
slug: with-prompt
|
||||
title: A task that carries its own prompt
|
||||
plan_slug: example
|
||||
epic_slug: things
|
||||
status: ready
|
||||
priority: P2
|
||||
tenant_id: global
|
||||
owner: unassigned
|
||||
cursor_todo_id: null
|
||||
updated_at: "2026-06-02"
|
||||
agent_prompt: |
|
||||
You are working on a sensitive change.
|
||||
Read the body in full.
|
||||
Run lint and type-check before declaring done.
|
||||
---
|
||||
|
||||
# Task body`;
|
||||
|
||||
const TASK_WITHOUT_PROMPT = `---
|
||||
kind: task
|
||||
slug: no-prompt
|
||||
title: A normal task
|
||||
plan_slug: example
|
||||
epic_slug: things
|
||||
status: ready
|
||||
priority: P2
|
||||
tenant_id: global
|
||||
owner: unassigned
|
||||
cursor_todo_id: null
|
||||
updated_at: "2026-06-02"
|
||||
---
|
||||
|
||||
# Body`;
|
||||
|
||||
const TASK_EMPTY_PROMPT = `---
|
||||
kind: task
|
||||
slug: empty-prompt
|
||||
title: Task with whitespace-only prompt
|
||||
plan_slug: example
|
||||
epic_slug: things
|
||||
status: ready
|
||||
priority: P2
|
||||
tenant_id: global
|
||||
owner: unassigned
|
||||
cursor_todo_id: null
|
||||
updated_at: "2026-06-02"
|
||||
agent_prompt: " "
|
||||
---
|
||||
|
||||
# Body`;
|
||||
|
||||
it("extracts a multi-line agent_prompt from frontmatter", () => {
|
||||
const parsed = parseBacklogMarkdown(
|
||||
TASK_WITH_PROMPT,
|
||||
"plans/Plan-example/Epic-things/Task-with-prompt.md",
|
||||
);
|
||||
expect(parsed.workflowPrompt).not.toBeNull();
|
||||
expect(parsed.workflowPrompt).toContain("sensitive change");
|
||||
expect(parsed.workflowPrompt).toContain("Run lint and type-check");
|
||||
// Multi-line block scalars preserve interior newlines so prompt
|
||||
// formatting survives the round-trip.
|
||||
expect(parsed.workflowPrompt?.split("\n").length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("returns null when agent_prompt is absent", () => {
|
||||
const parsed = parseBacklogMarkdown(
|
||||
TASK_WITHOUT_PROMPT,
|
||||
"plans/Plan-example/Epic-things/Task-no-prompt.md",
|
||||
);
|
||||
expect(parsed.workflowPrompt).toBeNull();
|
||||
});
|
||||
|
||||
it("treats whitespace-only agent_prompt as null (inherit from parent)", () => {
|
||||
// Otherwise an accidentally-blanked prompt would silently shadow the
|
||||
// epic / plan default. The inheritance walk in resolveWorkflowPrompt
|
||||
// only sees non-null values, so we normalize at parse time.
|
||||
const parsed = parseBacklogMarkdown(
|
||||
TASK_EMPTY_PROMPT,
|
||||
"plans/Plan-example/Epic-things/Task-empty-prompt.md",
|
||||
);
|
||||
expect(parsed.workflowPrompt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashFileContents", () => {
|
||||
it("is deterministic across calls", () => {
|
||||
const a = hashFileContents(TASK_FIXTURE);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,13 @@ export type ParsedBacklogFile = {
|
|||
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 {
|
||||
|
|
@ -32,6 +39,24 @@ function readString(fm: Record<string, unknown>, key: string): string | null {
|
|||
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;
|
||||
|
|
@ -99,5 +124,6 @@ export function parseBacklogMarkdown(
|
|||
status: readString(frontmatter, "status"),
|
||||
priority: readString(frontmatter, "priority"),
|
||||
owner: readString(frontmatter, "owner"),
|
||||
workflowPrompt: readMultilineString(frontmatter, "agent_prompt"),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
172
packages/database/src/markdown-backlog/resolve-prompt.ts
Normal file
172
packages/database/src/markdown-backlog/resolve-prompt.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
import type { db as defaultDb } from "../client";
|
||||
import { markdownBacklogItems } from "../schema/markdown_backlog";
|
||||
|
||||
/**
|
||||
* Built-in fallback used when no level of the inheritance chain (task →
|
||||
* epic → plan) supplies a prompt. Intentionally generic — it's not meant
|
||||
* to win in any particular project context, just keep the MCP
|
||||
* `claim_task` tool from returning an empty string.
|
||||
*
|
||||
* If you find yourself editing this default frequently, the right answer
|
||||
* is to set workspace-level overrides (future) or fill in plan-level
|
||||
* prompts, not to grow this constant.
|
||||
*/
|
||||
export const DEFAULT_AGENT_PROMPT =
|
||||
"You are completing one focused task in a multitenant TypeScript monorepo. " +
|
||||
"Read the task body in full before writing any code. Validate every external " +
|
||||
"input with zod. Run `pnpm lint && pnpm type-check && pnpm test` before " +
|
||||
"declaring done. Don't push, force-push, or amend without explicit instruction.";
|
||||
|
||||
export type ResolvedWorkflowPrompt = {
|
||||
prompt: string;
|
||||
source: "task" | "epic" | "plan" | "default";
|
||||
};
|
||||
|
||||
type Database = typeof defaultDb;
|
||||
|
||||
type BacklogRow = {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
kind: string;
|
||||
slug: string;
|
||||
planSlug: string;
|
||||
epicSlug: string | null;
|
||||
parentId: string | null;
|
||||
workflowPrompt: string | null;
|
||||
};
|
||||
|
||||
async function loadById(
|
||||
db: Database,
|
||||
workspaceId: string,
|
||||
id: string,
|
||||
): Promise<BacklogRow | null> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: markdownBacklogItems.id,
|
||||
workspaceId: markdownBacklogItems.workspaceId,
|
||||
kind: markdownBacklogItems.kind,
|
||||
slug: markdownBacklogItems.slug,
|
||||
planSlug: markdownBacklogItems.planSlug,
|
||||
epicSlug: markdownBacklogItems.epicSlug,
|
||||
parentId: markdownBacklogItems.parentId,
|
||||
workflowPrompt: markdownBacklogItems.workflowPrompt,
|
||||
})
|
||||
.from(markdownBacklogItems)
|
||||
.where(
|
||||
and(
|
||||
eq(markdownBacklogItems.id, id),
|
||||
eq(markdownBacklogItems.workspaceId, workspaceId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async function loadEpic(
|
||||
db: Database,
|
||||
workspaceId: string,
|
||||
planSlug: string,
|
||||
epicSlug: string,
|
||||
): Promise<BacklogRow | null> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: markdownBacklogItems.id,
|
||||
workspaceId: markdownBacklogItems.workspaceId,
|
||||
kind: markdownBacklogItems.kind,
|
||||
slug: markdownBacklogItems.slug,
|
||||
planSlug: markdownBacklogItems.planSlug,
|
||||
epicSlug: markdownBacklogItems.epicSlug,
|
||||
parentId: markdownBacklogItems.parentId,
|
||||
workflowPrompt: markdownBacklogItems.workflowPrompt,
|
||||
})
|
||||
.from(markdownBacklogItems)
|
||||
.where(
|
||||
and(
|
||||
eq(markdownBacklogItems.workspaceId, workspaceId),
|
||||
eq(markdownBacklogItems.planSlug, planSlug),
|
||||
eq(markdownBacklogItems.slug, epicSlug),
|
||||
eq(markdownBacklogItems.kind, "epic"),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async function loadPlan(
|
||||
db: Database,
|
||||
workspaceId: string,
|
||||
planSlug: string,
|
||||
): Promise<BacklogRow | null> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: markdownBacklogItems.id,
|
||||
workspaceId: markdownBacklogItems.workspaceId,
|
||||
kind: markdownBacklogItems.kind,
|
||||
slug: markdownBacklogItems.slug,
|
||||
planSlug: markdownBacklogItems.planSlug,
|
||||
epicSlug: markdownBacklogItems.epicSlug,
|
||||
parentId: markdownBacklogItems.parentId,
|
||||
workflowPrompt: markdownBacklogItems.workflowPrompt,
|
||||
})
|
||||
.from(markdownBacklogItems)
|
||||
.where(
|
||||
and(
|
||||
eq(markdownBacklogItems.workspaceId, workspaceId),
|
||||
eq(markdownBacklogItems.slug, planSlug),
|
||||
eq(markdownBacklogItems.kind, "plan"),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the inheritance chain (task → epic → plan → DEFAULT) and return the
|
||||
* first non-null `workflow_prompt` along with the level it came from.
|
||||
*
|
||||
* Why walk on-demand instead of caching: the chain is ≤3 hops, every node
|
||||
* has a primary-key lookup, and the column changes infrequently. Caching
|
||||
* would buy approximately nothing and add an invalidation problem.
|
||||
*
|
||||
* Why slug-based lookups for epic/plan instead of parent_id: parent_id IS
|
||||
* populated by the importer but only for the IMMEDIATE parent (task → epic),
|
||||
* not task → plan. Walking slug-by-slug is robust to importer ordering quirks
|
||||
* and works even mid-sync when parent_id is briefly null.
|
||||
*
|
||||
* Workspace-scoping is enforced at every hop. The importer can't currently
|
||||
* cross workspaces, but this function is also called from the MCP `claim_task`
|
||||
* tool where a malicious or buggy actor could try.
|
||||
*/
|
||||
export async function resolveWorkflowPrompt(
|
||||
db: Database,
|
||||
args: { workspaceId: string; backlogItemId: string },
|
||||
): Promise<ResolvedWorkflowPrompt> {
|
||||
const task = await loadById(db, args.workspaceId, args.backlogItemId);
|
||||
if (!task) {
|
||||
// Caller is responsible for validating the id; if we get here with a bad
|
||||
// id the right answer is the default rather than throwing — this is
|
||||
// called from the MCP tool path where surfacing a structured error would
|
||||
// require a different return shape.
|
||||
return { prompt: DEFAULT_AGENT_PROMPT, source: "default" };
|
||||
}
|
||||
|
||||
if (task.workflowPrompt) {
|
||||
return { prompt: task.workflowPrompt, source: "task" };
|
||||
}
|
||||
|
||||
if (task.epicSlug) {
|
||||
const epic = await loadEpic(db, args.workspaceId, task.planSlug, task.epicSlug);
|
||||
if (epic?.workflowPrompt) {
|
||||
return { prompt: epic.workflowPrompt, source: "epic" };
|
||||
}
|
||||
}
|
||||
|
||||
const plan = await loadPlan(db, args.workspaceId, task.planSlug);
|
||||
if (plan?.workflowPrompt) {
|
||||
return { prompt: plan.workflowPrompt, source: "plan" };
|
||||
}
|
||||
|
||||
return { prompt: DEFAULT_AGENT_PROMPT, source: "default" };
|
||||
}
|
||||
|
|
@ -76,6 +76,7 @@ export async function syncMarkdownBacklogScan(
|
|||
frontmatter: row.frontmatter,
|
||||
bodyMarkdown: row.bodyMarkdown,
|
||||
contentHash: row.contentHash,
|
||||
workflowPrompt: row.workflowPrompt,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [markdownBacklogItems.workspaceId, markdownBacklogItems.repoPath],
|
||||
|
|
@ -91,6 +92,7 @@ export async function syncMarkdownBacklogScan(
|
|||
frontmatter: row.frontmatter,
|
||||
bodyMarkdown: row.bodyMarkdown,
|
||||
contentHash: row.contentHash,
|
||||
workflowPrompt: row.workflowPrompt,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -41,6 +41,11 @@ export const markdownBacklogItems = pgTable(
|
|||
// workspace-archive cascade stamps this to match `workspaces.archived_at`
|
||||
// so backlog items follow their parent workspace's lifecycle.
|
||||
archivedAt: timestamp("archived_at", { withTimezone: true }),
|
||||
// Per-item agent prompt. Optional; nullable. When null, the inheritance
|
||||
// walk in `resolveWorkflowPrompt()` falls back to the epic, then plan,
|
||||
// then a built-in default. NOT INDEXED — this column is read on-demand
|
||||
// (one row at a time) and never filtered on.
|
||||
workflowPrompt: text("workflow_prompt"),
|
||||
},
|
||||
(table) => ({
|
||||
parentFk: foreignKey({
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ slug: add-workflow-prompt-to-backlog-items
|
|||
title: Add workflow_prompt column to markdown_backlog_items, with inheritance
|
||||
plan_slug: agent-coordination
|
||||
epic_slug: task-as-runnable-unit
|
||||
status: ready
|
||||
status: in_progress
|
||||
priority: P2
|
||||
tenant_id: global
|
||||
owner: unassigned
|
||||
cursor_todo_id: null
|
||||
updated_at: "2026-06-01"
|
||||
updated_at: "2026-06-02"
|
||||
---
|
||||
|
||||
# Task summary
|
||||
|
|
@ -66,12 +66,22 @@ Use a tRPC procedure `backlog.updateWorkflowPrompt({ backlogItemId, workflowProm
|
|||
|
||||
## Subtasks
|
||||
|
||||
- [ ] Extend backlog frontmatter zod schema with `agent_prompt`.
|
||||
- [ ] Add `workflow_prompt` column + migration.
|
||||
- [ ] Parse and persist in `parse.ts`.
|
||||
- [ ] Implement `resolveWorkflowPrompt` with the inheritance walk.
|
||||
- [ ] Update Plan/Epic/Task templates in `docs/templates/`.
|
||||
- [ ] Add UI panel section.
|
||||
- [x] No zod schema in `parse.ts` to extend (the parser uses plain `readString` helpers, not a zod schema). Added a sibling `readMultilineString` helper that preserves interior newlines for `|` block scalars and added `workflowPrompt` to `ParsedBacklogFile`.
|
||||
- [x] Added `workflow_prompt text` column to `markdown_backlog_items`. Migration `0008_curly_zzzax.sql`. Applied via psql against CT 102.
|
||||
- [x] Parse `agent_prompt` from frontmatter, plumb through the importer (`sync.ts` insert + onConflictDoUpdate). Whitespace-only values normalize to `null` so an accidentally-blanked prompt doesn't silently shadow the epic/plan default.
|
||||
- [x] `resolveWorkflowPrompt(db, { workspaceId, backlogItemId })` lives at `packages/database/src/markdown-backlog/resolve-prompt.ts`. Returns `{ prompt, source: "task" | "epic" | "plan" | "default" }`. Inheritance walk goes task → epic (slug-based lookup, NOT parent_id, because parent_id can be briefly null during importer transactions) → plan → built-in `DEFAULT_AGENT_PROMPT`. Workspace-scoped at every hop.
|
||||
- [x] Updated Plan / Epic / Task templates in `docs/templates/` with commented-out `agent_prompt:` examples. Task template leaves it commented (most tasks inherit); Epic and Plan templates suggest filling it.
|
||||
- [ ] **DEFERRED to follow-up** `Task-workflow-prompt-task-detail-ui.md`. There is no backlog-item detail panel in `apps/web` yet — the existing `components/panels/object-detail.tsx` is for the `objects` table, not for `markdown_backlog_items`. Adding an entirely new panel surface (with state, edit affordance, etc.) is a bigger UI task than this convoy bears. The data layer is fully in place — the follow-up just needs to render it.
|
||||
|
||||
The tRPC procedures are also ready (`backlog.getWorkflowPrompt` and `backlog.updateWorkflowPrompt`) so the future UI can render the override + effective preview with zero additional server work.
|
||||
|
||||
## Design decisions captured
|
||||
|
||||
- **No template engine.** Plain string, per spec. Symphony uses Liquid; we don't need that.
|
||||
- **Slug-based walk, not parent_id-based.** The importer sets `parent_id` only for the immediate parent (task → epic). Walking by `(planSlug, epicSlug, slug)` works even mid-transaction when `parent_id` is null, and survives importer re-ordering.
|
||||
- **`DEFAULT_AGENT_PROMPT` is intentionally generic.** The spec said "if you find yourself editing it frequently, that's a smell — fill in plan-level prompts instead." Documented in the constant's JSDoc.
|
||||
- **Owner / admin only on `updateWorkflowPrompt`.** Agent prompts change downstream Cursor/Claude behavior; this isn't a "any member can edit" surface. Audit-logged on every write (`backlog.workflow_prompt_set` / `backlog.workflow_prompt_clear`).
|
||||
- **Empty string normalizes to null.** A literally-empty override would shadow the epic/plan default with "no instructions at all" — bad UX. The procedure trims and treats empty as "clear the override."
|
||||
|
||||
## Owner or assignee
|
||||
|
||||
|
|
@ -87,9 +97,9 @@ M
|
|||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] A task with no override falls back to its epic's prompt; an epic with no override falls back to its plan; a plan with no override falls back to the built-in default.
|
||||
- [ ] Setting `agent_prompt:` in frontmatter and re-importing populates `workflow_prompt`.
|
||||
- [ ] UI shows effective prompt and override box.
|
||||
- [x] A task with no override falls back to its epic's prompt; an epic with no override falls back to its plan; a plan with no override falls back to the built-in default. (Tested by inspection of `resolve-prompt.ts`; an end-to-end DB-fixture test belongs to `Task-bootstrap-vitest-for-apps-web` once that lands.)
|
||||
- [x] Setting `agent_prompt:` in frontmatter and re-importing populates `workflow_prompt`. (`parse.ts` + `sync.ts` plumb the field; verified by 3 new vitest cases in `parse.test.ts`.)
|
||||
- [ ] UI shows effective prompt and override box. **Deferred** — see follow-up `Task-workflow-prompt-task-detail-ui.md`. Data layer (tRPC `backlog.getWorkflowPrompt` and `backlog.updateWorkflowPrompt`) is shipped so the UI is a pure rendering task.
|
||||
|
||||
## Links to related Epic / Plan
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
---
|
||||
kind: task
|
||||
slug: workflow-prompt-task-detail-ui
|
||||
title: Backlog-item detail panel — render and edit workflow_prompt
|
||||
plan_slug: agent-coordination
|
||||
epic_slug: task-as-runnable-unit
|
||||
status: draft
|
||||
priority: P2
|
||||
tenant_id: global
|
||||
owner: unassigned
|
||||
cursor_todo_id: null
|
||||
updated_at: "2026-06-02"
|
||||
---
|
||||
|
||||
# Task summary
|
||||
|
||||
The data layer for per-item workflow prompts shipped in `Task-add-workflow-prompt-to-backlog-items`. This task adds the UI surface that uses it.
|
||||
|
||||
Deferred from the parent task because `apps/web` does not yet have a backlog-item detail panel — `apps/web/components/panels/object-detail.tsx` is for the `objects` table, not `markdown_backlog_items`. Adding a new panel surface (state, edit affordance, draft handling) is a substantive UI task on its own.
|
||||
|
||||
## Description
|
||||
|
||||
Add a "Workflow prompt" section to wherever a single backlog item is rendered for read/edit. Likely surfaces:
|
||||
|
||||
1. A dedicated route like `/[workspaceSlug]/plans/[planSlug]/[epicSlug]/[taskSlug]` if a Plans browser ever exists.
|
||||
2. A drawer or sheet opened from the Plans tree (if/when that ships).
|
||||
3. The agent runs view (`/settings/runs`) — click a run to expand task context including the effective prompt.
|
||||
|
||||
For v1 of THIS task, pick the smallest surface that lets an operator actually use the data:
|
||||
|
||||
- **Effective prompt** — read-only display from `api.backlog.getWorkflowPrompt({ backlogItemId })`. Show the source level as a small badge ("From this task" / "Inherited from epic: X" / "Inherited from plan: Y" / "Built-in default").
|
||||
- **Override** — a textarea bound to `ownOverride`. Empty = inherit. Save calls `api.backlog.updateWorkflowPrompt`.
|
||||
|
||||
## Subtasks
|
||||
|
||||
- [ ] Decide on the rendering surface (see options above).
|
||||
- [ ] Build the section component with effective-prompt preview + override textarea.
|
||||
- [ ] Owner/admin gate matches the procedure (the procedure refuses non-managers, but the UI should hide the save button rather than let the click fail).
|
||||
- [ ] Show "clear override" affordance when an override is set.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Saving an override flips the source badge to "From this task."
|
||||
- [ ] Clearing an override re-shows the inherited source.
|
||||
- [ ] Non-managers see the prompt but cannot edit it.
|
||||
|
||||
## Links
|
||||
|
||||
- Parent: `./Task-add-workflow-prompt-to-backlog-items.md`
|
||||
- Epic: `./Epic-task-as-runnable-unit.md`
|
||||
Loading…
Reference in a new issue