diff --git a/apps/web/server/root.ts b/apps/web/server/root.ts index d82f038..f1d594e 100644 --- a/apps/web/server/root.ts +++ b/apps/web/server/root.ts @@ -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; diff --git a/apps/web/server/routers/backlog.ts b/apps/web/server/routers/backlog.ts new file mode 100644 index 0000000..fc8a1db --- /dev/null +++ b/apps/web/server/routers/backlog.ts @@ -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 }; diff --git a/docs/templates/epic-template.md b/docs/templates/epic-template.md index a5a89b2..6174894 100644 --- a/docs/templates/epic-template.md +++ b/docs/templates/epic-template.md @@ -8,6 +8,11 @@ priority: P2 tenant_id: "" cursor_epic_id: null updated_at: "" +# 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 objective diff --git a/docs/templates/plan-template.md b/docs/templates/plan-template.md index 1447ccd..7925060 100644 --- a/docs/templates/plan-template.md +++ b/docs/templates/plan-template.md @@ -7,6 +7,11 @@ priority: P2 tenant_id: "" cursor_plan_id: null updated_at: "" +# 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 overview diff --git a/docs/templates/task-template.md b/docs/templates/task-template.md index 906637f..74f624d 100644 --- a/docs/templates/task-template.md +++ b/docs/templates/task-template.md @@ -10,6 +10,14 @@ tenant_id: "" owner: "" cursor_todo_id: null updated_at: "" +# 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 diff --git a/packages/database/migrations/0008_curly_zzzax.sql b/packages/database/migrations/0008_curly_zzzax.sql new file mode 100644 index 0000000..41fdbf3 --- /dev/null +++ b/packages/database/migrations/0008_curly_zzzax.sql @@ -0,0 +1 @@ +ALTER TABLE "markdown_backlog_items" ADD COLUMN "workflow_prompt" text; \ No newline at end of file diff --git a/packages/database/migrations/meta/0008_snapshot.json b/packages/database/migrations/meta/0008_snapshot.json new file mode 100644 index 0000000..d12dd34 --- /dev/null +++ b/packages/database/migrations/meta/0008_snapshot.json @@ -0,0 +1,2878 @@ +{ + "id": "74037ec5-ba7a-4d64-a0d5-272f81dad116", + "prevId": "1f450ec4-553c-426a-9454-4c8a018a2ec1", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.workspace_invites": { + "name": "workspace_invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now() + interval '14 days'" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_invites_workspace_id_idx": { + "name": "workspace_invites_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invites_token_unique": { + "name": "workspace_invites_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invites_open_email_unique": { + "name": "workspace_invites_open_email_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_invites\".\"accepted_at\" IS NULL AND \"workspace_invites\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_invites_workspace_id_workspaces_id_fk": { + "name": "workspace_invites_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_invites", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invites_invited_by_user_id_users_id_fk": { + "name": "workspace_invites_invited_by_user_id_users_id_fk", + "tableFrom": "workspace_invites", + "tableTo": "users", + "columnsFrom": [ + "invited_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(60)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plan_tier": { + "name": "plan_tier", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_slug_unique": { + "name": "workspaces_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_owner_user_id_idx": { + "name": "workspaces_owner_user_id_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_owner_user_id_users_id_fk": { + "name": "workspaces_owner_user_id_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.object_assignees": { + "name": "object_assignees", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'assignee'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "object_assignees_object_id_user_id_unique": { + "name": "object_assignees_object_id_user_id_unique", + "columns": [ + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_assignees_object_id_idx": { + "name": "object_assignees_object_id_idx", + "columns": [ + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_assignees_user_id_idx": { + "name": "object_assignees_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "object_assignees_object_id_objects_id_fk": { + "name": "object_assignees_object_id_objects_id_fk", + "tableFrom": "object_assignees", + "tableTo": "objects", + "columnsFrom": [ + "object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "object_assignees_user_id_users_id_fk": { + "name": "object_assignees_user_id_users_id_fk", + "tableFrom": "object_assignees", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.objects": { + "name": "objects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_image": { + "name": "cover_image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "template_id": { + "name": "template_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "objects_parent_id_idx": { + "name": "objects_parent_id_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "objects_type_idx": { + "name": "objects_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "objects_workspace_id_idx": { + "name": "objects_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "objects_template_id_idx": { + "name": "objects_template_id_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "objects_created_by_idx": { + "name": "objects_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "objects_type_workspace_id_idx": { + "name": "objects_type_workspace_id_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "objects_workspace_id_workspaces_id_fk": { + "name": "objects_workspace_id_workspaces_id_fk", + "tableFrom": "objects", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "objects_created_by_users_id_fk": { + "name": "objects_created_by_users_id_fk", + "tableFrom": "objects", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "objects_parent_id_objects_id_fk": { + "name": "objects_parent_id_objects_id_fk", + "tableFrom": "objects", + "tableTo": "objects", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "objects_template_id_templates_id_fk": { + "name": "objects_template_id_templates_id_fk", + "tableFrom": "objects", + "tableTo": "templates", + "columnsFrom": [ + "template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_members": { + "name": "workspace_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_members_workspace_id_user_id_unique": { + "name": "workspace_members_workspace_id_user_id_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_members_workspace_id_idx": { + "name": "workspace_members_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_members_user_id_idx": { + "name": "workspace_members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_members_workspace_id_workspaces_id_fk": { + "name": "workspace_members_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_members", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_members_user_id_users_id_fk": { + "name": "workspace_members_user_id_users_id_fk", + "tableFrom": "workspace_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.object_type_defs": { + "name": "object_type_defs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "layout": { + "name": "layout", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'task'" + }, + "default_properties": { + "name": "default_properties", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "object_type_defs_workspace_id_idx": { + "name": "object_type_defs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_type_defs_slug_idx": { + "name": "object_type_defs_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "object_type_defs_workspace_id_workspaces_id_fk": { + "name": "object_type_defs_workspace_id_workspaces_id_fk", + "tableFrom": "object_type_defs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_definitions": { + "name": "property_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "property_definitions_workspace_id_idx": { + "name": "property_definitions_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "property_definitions_workspace_id_name_idx": { + "name": "property_definitions_workspace_id_name_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "property_definitions_workspace_id_workspaces_id_fk": { + "name": "property_definitions_workspace_id_workspaces_id_fk", + "tableFrom": "property_definitions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_values": { + "name": "property_values", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "property_def_id": { + "name": "property_def_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "property_values_object_id_property_def_id_unique": { + "name": "property_values_object_id_property_def_id_unique", + "columns": [ + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "property_def_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "property_values_object_id_idx": { + "name": "property_values_object_id_idx", + "columns": [ + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "property_values_property_def_id_idx": { + "name": "property_values_property_def_id_idx", + "columns": [ + { + "expression": "property_def_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "property_values_object_id_objects_id_fk": { + "name": "property_values_object_id_objects_id_fk", + "tableFrom": "property_values", + "tableTo": "objects", + "columnsFrom": [ + "object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "property_values_property_def_id_property_definitions_id_fk": { + "name": "property_values_property_def_id_property_definitions_id_fk", + "tableFrom": "property_values", + "tableTo": "property_definitions", + "columnsFrom": [ + "property_def_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.views": { + "name": "views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "view_type": { + "name": "view_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "views_object_id_idx": { + "name": "views_object_id_idx", + "columns": [ + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "views_object_id_objects_id_fk": { + "name": "views_object_id_objects_id_fk", + "tableFrom": "views", + "tableTo": "objects", + "columnsFrom": [ + "object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "accounts_provider_provider_account_id_unique": { + "name": "accounts_provider_provider_account_id_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_token": { + "name": "session_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_session_token_unique": { + "name": "sessions_session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "session_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_email_identities": { + "name": "user_email_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_email_identities_user_id_idx": { + "name": "user_email_identities_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_email_identities_email_idx": { + "name": "user_email_identities_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_email_identities_user_id_email_unique": { + "name": "user_email_identities_user_id_email_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_email_identities_verified_email_unique": { + "name": "user_email_identities_verified_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_email_identities\".\"verified_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_email_identities_user_id_users_id_fk": { + "name": "user_email_identities_user_id_users_id_fk", + "tableFrom": "user_email_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_lower_unique": { + "name": "users_email_lower_unique", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification_tokens": { + "name": "verification_tokens", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_tokens_identifier_token_pk": { + "name": "verification_tokens_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.object_relations": { + "name": "object_relations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "relation_type": { + "name": "relation_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "object_relations_source_id_idx": { + "name": "object_relations_source_id_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_relations_target_id_idx": { + "name": "object_relations_target_id_idx", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_relations_relation_type_idx": { + "name": "object_relations_relation_type_idx", + "columns": [ + { + "expression": "relation_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_relations_source_target_type_unique": { + "name": "object_relations_source_target_type_unique", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "relation_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "object_relations_source_id_objects_id_fk": { + "name": "object_relations_source_id_objects_id_fk", + "tableFrom": "object_relations", + "tableTo": "objects", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "object_relations_target_id_objects_id_fk": { + "name": "object_relations_target_id_objects_id_fk", + "tableFrom": "object_relations", + "tableTo": "objects", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.templates": { + "name": "templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "templates_workspace_id_idx": { + "name": "templates_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "templates_workspace_id_workspaces_id_fk": { + "name": "templates_workspace_id_workspaces_id_fk", + "tableFrom": "templates", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.form_responses": { + "name": "form_responses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "form_id": { + "name": "form_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "respondent_id": { + "name": "respondent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_object_id": { + "name": "created_object_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "submitted_at": { + "name": "submitted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "form_responses_form_id_idx": { + "name": "form_responses_form_id_idx", + "columns": [ + { + "expression": "form_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_responses_respondent_id_idx": { + "name": "form_responses_respondent_id_idx", + "columns": [ + { + "expression": "respondent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "form_responses_form_id_forms_id_fk": { + "name": "form_responses_form_id_forms_id_fk", + "tableFrom": "form_responses", + "tableTo": "forms", + "columnsFrom": [ + "form_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "form_responses_respondent_id_users_id_fk": { + "name": "form_responses_respondent_id_users_id_fk", + "tableFrom": "form_responses", + "tableTo": "users", + "columnsFrom": [ + "respondent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "form_responses_created_object_id_objects_id_fk": { + "name": "form_responses_created_object_id_objects_id_fk", + "tableFrom": "form_responses", + "tableTo": "objects", + "columnsFrom": [ + "created_object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.forms": { + "name": "forms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_image": { + "name": "cover_image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_type": { + "name": "target_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'task'" + }, + "fields": { + "name": "fields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "forms_workspace_id_idx": { + "name": "forms_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "forms_object_id_idx": { + "name": "forms_object_id_idx", + "columns": [ + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "forms_workspace_id_workspaces_id_fk": { + "name": "forms_workspace_id_workspaces_id_fk", + "tableFrom": "forms", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "forms_object_id_objects_id_fk": { + "name": "forms_object_id_objects_id_fk", + "tableFrom": "forms", + "tableTo": "objects", + "columnsFrom": [ + "object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "forms_created_by_users_id_fk": { + "name": "forms_created_by_users_id_fk", + "tableFrom": "forms", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_favorites": { + "name": "user_favorites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_favorites_user_object_idx": { + "name": "user_favorites_user_object_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_favorites_user_id_users_id_fk": { + "name": "user_favorites_user_id_users_id_fk", + "tableFrom": "user_favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_favorites_object_id_objects_id_fk": { + "name": "user_favorites_object_id_objects_id_fk", + "tableFrom": "user_favorites", + "tableTo": "objects", + "columnsFrom": [ + "object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.markdown_backlog_items": { + "name": "markdown_backlog_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "plan_slug": { + "name": "plan_slug", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "epic_slug": { + "name": "epic_slug", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repo_path": { + "name": "repo_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frontmatter": { + "name": "frontmatter", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "body_markdown": { + "name": "body_markdown", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "content_hash": { + "name": "content_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "workflow_prompt": { + "name": "workflow_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "markdown_backlog_workspace_repo_path_unique": { + "name": "markdown_backlog_workspace_repo_path_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "markdown_backlog_workspace_plan_idx": { + "name": "markdown_backlog_workspace_plan_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plan_slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "markdown_backlog_parent_id_idx": { + "name": "markdown_backlog_parent_id_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "markdown_backlog_workspace_kind_idx": { + "name": "markdown_backlog_workspace_kind_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "markdown_backlog_items_workspace_id_workspaces_id_fk": { + "name": "markdown_backlog_items_workspace_id_workspaces_id_fk", + "tableFrom": "markdown_backlog_items", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "markdown_backlog_items_parent_id_markdown_backlog_items_id_fk": { + "name": "markdown_backlog_items_parent_id_markdown_backlog_items_id_fk", + "tableFrom": "markdown_backlog_items", + "tableTo": "markdown_backlog_items", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cursor_sync_mappings": { + "name": "cursor_sync_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "backlog_item_id": { + "name": "backlog_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cursor_plan_id": { + "name": "cursor_plan_id", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "cursor_item_id": { + "name": "cursor_item_id", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "last_pulled_at": { + "name": "last_pulled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_pushed_at": { + "name": "last_pushed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sync_content_hash": { + "name": "sync_content_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cursor_sync_mappings_backlog_item_id_unique": { + "name": "cursor_sync_mappings_backlog_item_id_unique", + "columns": [ + { + "expression": "backlog_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cursor_sync_mappings_workspace_id_idx": { + "name": "cursor_sync_mappings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cursor_sync_mappings_workspace_id_workspaces_id_fk": { + "name": "cursor_sync_mappings_workspace_id_workspaces_id_fk", + "tableFrom": "cursor_sync_mappings", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cursor_sync_mappings_backlog_item_id_markdown_backlog_items_id_fk": { + "name": "cursor_sync_mappings_backlog_item_id_markdown_backlog_items_id_fk", + "tableFrom": "cursor_sync_mappings", + "tableTo": "markdown_backlog_items", + "columnsFrom": [ + "backlog_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_id_created_at_idx": { + "name": "audit_log_workspace_id_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_user_id_idx": { + "name": "audit_log_actor_user_id_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspaces_id_fk": { + "name": "audit_log_workspace_id_workspaces_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_log_actor_user_id_users_id_fk": { + "name": "audit_log_actor_user_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/database/src/markdown-backlog/index.ts b/packages/database/src/markdown-backlog/index.ts index eac501d..b47cb5d 100644 --- a/packages/database/src/markdown-backlog/index.ts +++ b/packages/database/src/markdown-backlog/index.ts @@ -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"; diff --git a/packages/database/src/markdown-backlog/parse.test.ts b/packages/database/src/markdown-backlog/parse.test.ts index 4507ab3..0b51ca3 100644 --- a/packages/database/src/markdown-backlog/parse.test.ts +++ b/packages/database/src/markdown-backlog/parse.test.ts @@ -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); diff --git a/packages/database/src/markdown-backlog/parse.ts b/packages/database/src/markdown-backlog/parse.ts index f54c071..1a622a1 100644 --- a/packages/database/src/markdown-backlog/parse.ts +++ b/packages/database/src/markdown-backlog/parse.ts @@ -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, 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, + 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"), }; } diff --git a/packages/database/src/markdown-backlog/resolve-prompt.ts b/packages/database/src/markdown-backlog/resolve-prompt.ts new file mode 100644 index 0000000..16e2060 --- /dev/null +++ b/packages/database/src/markdown-backlog/resolve-prompt.ts @@ -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 { + 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 { + 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 { + 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 { + 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" }; +} diff --git a/packages/database/src/markdown-backlog/sync.ts b/packages/database/src/markdown-backlog/sync.ts index 496b8bf..4cf8aa6 100644 --- a/packages/database/src/markdown-backlog/sync.ts +++ b/packages/database/src/markdown-backlog/sync.ts @@ -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(), }, }); diff --git a/packages/database/src/schema/markdown_backlog.ts b/packages/database/src/schema/markdown_backlog.ts index 5786391..b153f36 100644 --- a/packages/database/src/schema/markdown_backlog.ts +++ b/packages/database/src/schema/markdown_backlog.ts @@ -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({ diff --git a/plans/Plan-agent-coordination/Epic-task-as-runnable-unit/Task-add-workflow-prompt-to-backlog-items.md b/plans/Plan-agent-coordination/Epic-task-as-runnable-unit/Task-add-workflow-prompt-to-backlog-items.md index aed2167..a58c2a4 100644 --- a/plans/Plan-agent-coordination/Epic-task-as-runnable-unit/Task-add-workflow-prompt-to-backlog-items.md +++ b/plans/Plan-agent-coordination/Epic-task-as-runnable-unit/Task-add-workflow-prompt-to-backlog-items.md @@ -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 diff --git a/plans/Plan-agent-coordination/Epic-task-as-runnable-unit/Task-workflow-prompt-task-detail-ui.md b/plans/Plan-agent-coordination/Epic-task-as-runnable-unit/Task-workflow-prompt-task-detail-ui.md new file mode 100644 index 0000000..6393568 --- /dev/null +++ b/plans/Plan-agent-coordination/Epic-task-as-runnable-unit/Task-workflow-prompt-task-detail-ui.md @@ -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`