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:
Randall Stillwell 2026-06-02 22:18:18 -05:00
parent f014686412
commit 72aa2a5f0c
15 changed files with 3400 additions and 11 deletions

View file

@ -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;

View 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 };

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1 @@
ALTER TABLE "markdown_backlog_items" ADD COLUMN "workflow_prompt" text;

File diff suppressed because it is too large Load diff

View file

@ -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";

View file

@ -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);

View file

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

View 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" };
}

View file

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

View file

@ -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({

View file

@ -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

View file

@ -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`