diff --git a/.cascade/setup.sh b/.cascade/setup.sh index 12a6e254..3f9dedb5 100755 --- a/.cascade/setup.sh +++ b/.cascade/setup.sh @@ -245,7 +245,22 @@ if pg_isready -q 2>/dev/null; then fi # ============================================================================= -# 4. Run migrations +# 4. Create .env for local database (npm scripts use --env-file=.env) +# ============================================================================= +if pg_isready -q 2>/dev/null && [ ! -f .env ]; then + echo "" + echo "--- Creating .env for local database ---" + if [ "$OS" = "linux" ]; then + echo "DATABASE_URL=postgresql://postgres:postgres@localhost:5432/cascade" > .env + else + echo "DATABASE_URL=postgresql://localhost:5432/cascade" > .env + fi + echo "DATABASE_SSL=false" >> .env + log_info "Created .env with local DATABASE_URL" +fi + +# ============================================================================= +# 5. Run migrations # ============================================================================= echo "" echo "--- Database Migrations ---" diff --git a/Dockerfile.worker b/Dockerfile.worker index b0176a3a..100f4595 100644 --- a/Dockerfile.worker +++ b/Dockerfile.worker @@ -23,6 +23,7 @@ RUN npm install -g pnpm @zbigniewsobiecki/squint --force # (niu-browser-base already has: git, curl, ca-certificates, gnupg, postgresql-client) RUN apt-get update && apt-get install -y \ fd-find \ + jq \ ripgrep \ ed \ unzip \ diff --git a/drizzle.config.ts b/drizzle.config.ts index a3ebf870..cddc5c12 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ './src/db/schema/agentConfigs.ts', './src/db/schema/integrations.ts', './src/db/schema/runs.ts', + './src/db/schema/promptPartials.ts', ], out: './src/db/migrations', dialect: 'postgresql', diff --git a/package.json b/package.json index 316b930c..004e521f 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "db:push": "node --env-file=.env ./node_modules/.bin/drizzle-kit push", "db:studio": "node --env-file=.env ./node_modules/.bin/drizzle-kit studio", "db:seed": "node --env-file=.env --import tsx tools/seed-config-from-json.ts", - "db:bootstrap-journal": "node --env-file=.env --import tsx tools/db-bootstrap-journal.ts" + "db:bootstrap-journal": "node --env-file=.env --import tsx tools/db-bootstrap-journal.ts", + "db:seed-prompts": "node --env-file=.env --import tsx tools/seed-prompts.ts" }, "keywords": [ "trello", diff --git a/src/agents/base.ts b/src/agents/base.ts index 02420172..c0d73819 100644 --- a/src/agents/base.ts +++ b/src/agents/base.ts @@ -3,6 +3,7 @@ import { WriteFile } from '../gadgets/WriteFile.js'; import { type ProgressMonitor, createProgressMonitor } from '../backends/progress.js'; import { CUSTOM_MODELS } from '../config/customModels.js'; +import { loadPartials } from '../db/repositories/partialsRepository.js'; import { AstGrep } from '../gadgets/AstGrep.js'; import { FileMultiEdit } from '../gadgets/FileMultiEdit.js'; import { FileSearchAndReplace } from '../gadgets/FileSearchAndReplace.js'; @@ -96,13 +97,18 @@ export async function fetchImplementationSteps(cardId: string): Promise | undefined> { + try { + return await loadPartials(orgId); + } catch { + // DB not available — fall back to disk-only partials + return undefined; + } +} + +function buildPromptContext( cardId: string | undefined, - repoDir: string, project: ProjectConfig, - config: CascadeConfig, - log: { info: (msg: string, ctx?: Record) => void }, triggerType?: string, prContext?: { prNumber: number; prBranch: string; repoFullName: string; headSha: string }, debugContext?: { @@ -112,13 +118,10 @@ async function buildAgentContext( originalCardUrl: string; detectedAgentType: string; }, - modelOverride?: string, - commentContext?: { text: string; author: string }, -): Promise { - // Build prompt context for template rendering +): PromptContext { const pmProvider = getPMProvider(); const isJira = pmProvider.type === 'jira'; - const promptContext: PromptContext = { + return { cardId, cardUrl: cardId ? pmProvider.getWorkItemUrl(cardId) : undefined, projectId: project.id, @@ -147,6 +150,48 @@ async function buildAgentContext( debugListId: project.trello?.lists?.debug, }), }; +} + +function selectPrompt( + cardId: string | undefined, + commentContext?: { text: string; author: string }, + prContext?: { prNumber: number; prBranch: string; repoFullName: string; headSha: string }, + debugContext?: { + logDir: string; + originalCardName: string; + originalCardUrl: string; + detectedAgentType: string; + }, +): string { + if (commentContext) { + return buildCommentResponsePrompt(cardId ?? '', commentContext.text, commentContext.author); + } + if (prContext) return buildCheckFailurePrompt(prContext); + if (debugContext) return buildDebugPrompt(debugContext); + return buildPrompt(cardId ?? ''); +} + +async function buildAgentContext( + agentType: string, + cardId: string | undefined, + repoDir: string, + project: ProjectConfig, + config: CascadeConfig, + log: { info: (msg: string, ctx?: Record) => void }, + triggerType?: string, + prContext?: { prNumber: number; prBranch: string; repoFullName: string; headSha: string }, + debugContext?: { + logDir: string; + originalCardId: string; + originalCardName: string; + originalCardUrl: string; + detectedAgentType: string; + }, + modelOverride?: string, + commentContext?: { text: string; author: string }, +): Promise { + const promptContext = buildPromptContext(cardId, project, triggerType, prContext, debugContext); + const dbPartials = await loadDbPartials(project.orgId); // Some agents share model/iteration config with another agent type const configKeyOverrides: Record = { @@ -161,6 +206,7 @@ async function buildAgentContext( modelOverride, promptContext, configKey: configKeyOverrides[agentType], + dbPartials, }); // Pre-fetch work item data for synthetic gadget call (only if cardId exists and not debug flow) @@ -176,17 +222,7 @@ async function buildAgentContext( implementationSteps = await fetchImplementationSteps(cardId); } - // Build different prompt based on flow - let prompt: string; - if (commentContext) { - prompt = buildCommentResponsePrompt(cardId ?? '', commentContext.text, commentContext.author); - } else if (prContext) { - prompt = buildCheckFailurePrompt(prContext); - } else if (debugContext) { - prompt = buildDebugPrompt(debugContext); - } else { - prompt = buildPrompt(cardId ?? ''); - } + const prompt = selectPrompt(cardId, commentContext, prContext, debugContext); return { systemPrompt, diff --git a/src/agents/prompts/index.ts b/src/agents/prompts/index.ts index 2086400c..4ab77cb9 100644 --- a/src/agents/prompts/index.ts +++ b/src/agents/prompts/index.ts @@ -1,4 +1,4 @@ -import { readFileSync } from 'node:fs'; +import { readFileSync, readdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Eta } from 'eta'; @@ -9,6 +9,19 @@ const templatesDir = join(__dirname, 'templates'); // Initialize Eta with the templates directory const eta = new Eta({ views: templatesDir, autoEscape: false }); +// Valid agent types +const validTypes = [ + 'briefing', + 'planning', + 'implementation', + 'debug', + 'respond-to-review', + 'respond-to-ci', + 'respond-to-pr-comment', + 'respond-to-planning-comment', + 'review', +]; + // Template context interface export interface PromptContext { // Common @@ -63,26 +76,137 @@ function loadTemplate(agentType: string): string { return template; } -export function getSystemPrompt(agentType: string, context: PromptContext = {}): string { - const validTypes = [ - 'briefing', - 'planning', - 'implementation', - 'debug', - 'respond-to-review', - 'respond-to-ci', - 'respond-to-pr-comment', - 'respond-to-planning-comment', - 'review', - ]; +/** + * Resolve `<%~ include("partials/...") %>` directives by looking up DB partials first, + * falling back to disk. This pre-processing happens before Eta variable interpolation. + */ +export function resolveIncludes(template: string, dbPartials: Map): string { + return template.replace( + /<%~\s*include\(\s*"partials\/([^"]+)"\s*\)\s*%>/g, + (_match, name: string) => { + const dbContent = dbPartials.get(name); + if (dbContent !== undefined) return dbContent; + // Fall back to disk + const diskPath = join(templatesDir, 'partials', `${name}.eta`); + try { + return readFileSync(diskPath, 'utf-8'); + } catch { + throw new Error(`Partial not found: partials/${name}`); + } + }, + ); +} + +/** + * Render a DB-stored template with include resolution + Eta variable interpolation. + */ +export function renderCustomPrompt( + templateSource: string, + context: PromptContext = {}, + dbPartials?: Map, +): string { + const expanded = resolveIncludes(templateSource, dbPartials ?? new Map()); + return eta.renderString(expanded, context); +} + +/** + * Validate a template string for correct Eta syntax and resolvable includes. + */ +export function validateTemplate( + templateSource: string, + dbPartials?: Map, +): { valid: true } | { valid: false; error: string } { + try { + const expanded = resolveIncludes(templateSource, dbPartials ?? new Map()); + eta.renderString(expanded, {}); + return { valid: true }; + } catch (err) { + return { valid: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +export function getSystemPrompt( + agentType: string, + context: PromptContext = {}, + dbPartials?: Map, +): string { if (!validTypes.includes(agentType)) { throw new Error(`Unknown agent type: ${agentType}`); } const template = loadTemplate(agentType); + if (dbPartials && dbPartials.size > 0) { + const expanded = resolveIncludes(template, dbPartials); + return eta.renderString(expanded, context); + } return eta.renderString(template, context); } +/** Returns the raw .eta template source from disk (before rendering). */ +export function getRawTemplate(agentType: string): string { + if (!validTypes.includes(agentType)) { + throw new Error(`Unknown agent type: ${agentType}`); + } + return loadTemplate(agentType); +} + +/** Returns the raw partial source from disk. */ +export function getRawPartial(name: string): string { + const diskPath = join(templatesDir, 'partials', `${name}.eta`); + return readFileSync(diskPath, 'utf-8'); +} + +/** Returns the list of valid agent types. */ +export function getValidAgentTypes(): string[] { + return [...validTypes]; +} + +/** Returns the list of available disk-based partial names. */ +export function getAvailablePartialNames(): string[] { + try { + const entries = readdirSync(join(templatesDir, 'partials')); + return entries + .filter((f) => f.endsWith('.eta')) + .map((f) => f.replace(/\.eta$/, '')) + .sort(); + } catch { + return []; + } +} + +/** Returns template variable info for documentation/reference. */ +export function getTemplateVariables(): Array<{ + name: string; + group: string; + description: string; +}> { + return [ + { name: 'cardId', group: 'Common', description: 'Work item ID' }, + { name: 'cardUrl', group: 'Common', description: 'Work item URL' }, + { name: 'projectId', group: 'Common', description: 'Project identifier' }, + { name: 'baseBranch', group: 'Common', description: 'Base branch name (e.g. main)' }, + { name: 'pmType', group: 'PM', description: 'PM type: trello or jira' }, + { name: 'workItemNoun', group: 'PM', description: 'card or issue' }, + { name: 'workItemNounPlural', group: 'PM', description: 'cards or issues' }, + { name: 'workItemNounCap', group: 'PM', description: 'Card or Issue' }, + { name: 'workItemNounPluralCap', group: 'PM', description: 'Cards or Issues' }, + { name: 'pmName', group: 'PM', description: 'Trello or JIRA' }, + { name: 'storiesListId', group: 'Briefing', description: 'Trello stories list ID' }, + { name: 'processedLabelId', group: 'Briefing', description: 'Trello processed label ID' }, + { name: 'prNumber', group: 'CI', description: 'Pull request number' }, + { name: 'prBranch', group: 'CI', description: 'Pull request branch name' }, + { name: 'repoFullName', group: 'CI', description: 'Repository full name (owner/repo)' }, + { name: 'headSha', group: 'CI', description: 'HEAD commit SHA' }, + { name: 'triggerType', group: 'CI', description: 'Trigger type identifier' }, + { name: 'logDir', group: 'Debug', description: 'Debug log directory path' }, + { name: 'originalCardId', group: 'Debug', description: 'Original card ID being debugged' }, + { name: 'originalCardName', group: 'Debug', description: 'Original card name' }, + { name: 'originalCardUrl', group: 'Debug', description: 'Original card URL' }, + { name: 'detectedAgentType', group: 'Debug', description: 'Agent type from session log' }, + { name: 'debugListId', group: 'Debug', description: 'Debug list ID for output cards' }, + ]; +} + // Export individual prompts for backwards compatibility (rendered without context) export const BRIEFING_SYSTEM_PROMPT = loadTemplate('briefing'); export const PLANNING_SYSTEM_PROMPT = loadTemplate('planning'); diff --git a/src/agents/shared/modelResolution.ts b/src/agents/shared/modelResolution.ts index 8463151d..30760c97 100644 --- a/src/agents/shared/modelResolution.ts +++ b/src/agents/shared/modelResolution.ts @@ -1,7 +1,7 @@ import type { CascadeConfig, ProjectConfig } from '../../types/index.js'; import { type ContextFile, readContextFiles } from '../utils/setup.js'; -import { type PromptContext, getSystemPrompt } from '../prompts/index.js'; +import { type PromptContext, getSystemPrompt, renderCustomPrompt } from '../prompts/index.js'; export interface ModelConfig { systemPrompt: string; @@ -19,14 +19,23 @@ export interface ResolveModelConfigOptions { promptContext?: PromptContext; /** Optional key override for model/iteration config lookup (e.g., respond-to-review uses 'review') */ configKey?: string; + /** DB partials for template include resolution */ + dbPartials?: Map; } export async function resolveModelConfig(options: ResolveModelConfigOptions): Promise { - const { agentType, project, config, repoDir, modelOverride, promptContext } = options; + const { agentType, project, config, repoDir, modelOverride, promptContext, dbPartials } = options; const configKey = options.configKey ?? agentType; - const systemPrompt = - project.prompts?.[agentType] || getSystemPrompt(agentType, promptContext ?? {}); + // Resolution chain: project prompt → defaults prompt → .eta file + const customPromptSource = project.prompts?.[agentType] ?? config.defaults.prompts?.[agentType]; + + let systemPrompt: string; + if (customPromptSource) { + systemPrompt = renderCustomPrompt(customPromptSource, promptContext ?? {}, dbPartials); + } else { + systemPrompt = getSystemPrompt(agentType, promptContext ?? {}, dbPartials); + } const model = modelOverride || diff --git a/src/api/router.ts b/src/api/router.ts index 0bb7e9d4..58326c62 100644 --- a/src/api/router.ts +++ b/src/api/router.ts @@ -4,6 +4,7 @@ import { credentialsRouter } from './routers/credentials.js'; import { defaultsRouter } from './routers/defaults.js'; import { organizationRouter } from './routers/organization.js'; import { projectsRouter } from './routers/projects.js'; +import { promptsRouter } from './routers/prompts.js'; import { runsRouter } from './routers/runs.js'; import { webhooksRouter } from './routers/webhooks.js'; import { router } from './trpc.js'; @@ -16,6 +17,7 @@ export const appRouter = router({ defaults: defaultsRouter, credentials: credentialsRouter, agentConfigs: agentConfigsRouter, + prompts: promptsRouter, webhooks: webhooksRouter, }); diff --git a/src/api/routers/agentConfigs.ts b/src/api/routers/agentConfigs.ts index 4b3c8112..ad1eda92 100644 --- a/src/api/routers/agentConfigs.ts +++ b/src/api/routers/agentConfigs.ts @@ -1,7 +1,9 @@ import { TRPCError } from '@trpc/server'; import { eq } from 'drizzle-orm'; import { z } from 'zod'; +import { validateTemplate } from '../../agents/prompts/index.js'; import { getDb } from '../../db/client.js'; +import { loadPartials } from '../../db/repositories/partialsRepository.js'; import { createAgentConfig, deleteAgentConfig, @@ -11,6 +13,18 @@ import { import { agentConfigs, projects } from '../../db/schema/index.js'; import { protectedProcedure, router } from '../trpc.js'; +async function validatePromptIfPresent(prompt: string | null | undefined) { + if (!prompt) return; + const dbPartials = await loadPartials(); + const result = validateTemplate(prompt, dbPartials); + if (!result.valid) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Invalid prompt template: ${result.error}`, + }); + } +} + export const agentConfigsRouter = router({ list: protectedProcedure .input(z.object({ projectId: z.string().optional() }).optional()) @@ -54,6 +68,7 @@ export const agentConfigsRouter = router({ throw new TRPCError({ code: 'NOT_FOUND' }); } } + await validatePromptIfPresent(input.prompt); return createAgentConfig({ orgId: input.orgId ?? ctx.user.orgId, projectId: input.projectId, @@ -102,6 +117,7 @@ export const agentConfigsRouter = router({ } const { id, ...updates } = input; + await validatePromptIfPresent(updates.prompt); await updateAgentConfig(id, updates); }), diff --git a/src/api/routers/prompts.ts b/src/api/routers/prompts.ts new file mode 100644 index 00000000..0e478fd5 --- /dev/null +++ b/src/api/routers/prompts.ts @@ -0,0 +1,169 @@ +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; +import { + getAvailablePartialNames, + getRawPartial, + getRawTemplate, + getTemplateVariables, + getValidAgentTypes, + validateTemplate, +} from '../../agents/prompts/index.js'; +import { + deletePartial, + getPartial, + listPartials, + loadPartials, + upsertPartial, +} from '../../db/repositories/partialsRepository.js'; +import { protectedProcedure, router } from '../trpc.js'; + +export const promptsRouter = router({ + // ======================================================================== + // Template introspection (read-only) + // ======================================================================== + + agentTypes: protectedProcedure.query(() => { + return getValidAgentTypes(); + }), + + getDefault: protectedProcedure + .input(z.object({ agentType: z.string().min(1) })) + .query(({ input }) => { + try { + return { content: getRawTemplate(input.agentType) }; + } catch { + throw new TRPCError({ + code: 'NOT_FOUND', + message: `Unknown agent type: ${input.agentType}`, + }); + } + }), + + variables: protectedProcedure.query(() => { + return getTemplateVariables(); + }), + + validate: protectedProcedure + .input(z.object({ template: z.string() })) + .mutation(async ({ input }) => { + const dbPartials = await loadPartials(); + return validateTemplate(input.template, dbPartials); + }), + + // ======================================================================== + // Partial CRUD + // ======================================================================== + + listPartials: protectedProcedure.query(async () => { + const dbRows = await listPartials(); + const diskNames = getAvailablePartialNames(); + + // Merge: DB content takes priority, disk names fill gaps + const dbByName = new Map(dbRows.map((r) => [r.name, r])); + const result: Array<{ + name: string; + source: 'db' | 'disk'; + lines: number; + id?: number; + }> = []; + + // Add all disk partials with DB override info + for (const name of diskNames) { + const dbRow = dbByName.get(name); + if (dbRow) { + result.push({ + name, + source: 'db', + lines: dbRow.content.split('\n').length, + id: dbRow.id, + }); + dbByName.delete(name); + } else { + try { + const content = getRawPartial(name); + result.push({ + name, + source: 'disk', + lines: content.split('\n').length, + }); + } catch { + result.push({ name, source: 'disk', lines: 0 }); + } + } + } + + // Add DB-only partials (custom ones not on disk) + for (const [name, row] of dbByName) { + result.push({ + name, + source: 'db', + lines: row.content.split('\n').length, + id: row.id, + }); + } + + return result.sort((a, b) => a.name.localeCompare(b.name)); + }), + + getPartial: protectedProcedure + .input(z.object({ name: z.string().min(1) })) + .query(async ({ input }) => { + // Check DB first + const dbRow = await getPartial(input.name); + if (dbRow) { + return { name: input.name, content: dbRow.content, source: 'db' as const, id: dbRow.id }; + } + // Fall back to disk + try { + const content = getRawPartial(input.name); + return { name: input.name, content, source: 'disk' as const }; + } catch { + throw new TRPCError({ + code: 'NOT_FOUND', + message: `Partial not found: ${input.name}`, + }); + } + }), + + getDefaultPartial: protectedProcedure + .input(z.object({ name: z.string().min(1) })) + .query(({ input }) => { + try { + return { content: getRawPartial(input.name) }; + } catch { + throw new TRPCError({ + code: 'NOT_FOUND', + message: `No disk partial: ${input.name}`, + }); + } + }), + + upsertPartial: protectedProcedure + .input( + z.object({ + name: z.string().min(1), + content: z.string().min(1), + }), + ) + .mutation(async ({ input }) => { + // Validate content doesn't break templates + const dbPartials = await loadPartials(); + dbPartials.set(input.name, input.content); + // Simple check: content itself shouldn't have broken Eta syntax + const testResult = validateTemplate(`Test: ${input.content}`, dbPartials); + if (!testResult.valid) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Invalid partial content: ${testResult.error}`, + }); + } + + return upsertPartial({ name: input.name, content: input.content }); + }), + + deletePartial: protectedProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ input }) => { + await deletePartial(input.id); + }), +}); diff --git a/src/backends/adapter.ts b/src/backends/adapter.ts index 02c9425e..c5ee4792 100644 --- a/src/backends/adapter.ts +++ b/src/backends/adapter.ts @@ -7,6 +7,7 @@ import { setupRepository } from '../agents/shared/repository.js'; import { createAgentLogger } from '../agents/utils/logging.js'; import { CUSTOM_MODELS } from '../config/customModels.js'; import { getAgentCredential, getProjectSecrets } from '../config/provider.js'; +import { loadPartials } from '../db/repositories/partialsRepository.js'; import { type CompleteRunInput, completeRun, @@ -37,7 +38,7 @@ function getToolManifests(): ToolManifest[] { cliCommand: 'cascade-tools pm read-work-item', parameters: { workItemId: { type: 'string', required: true }, - includeComments: { type: 'boolean', default: true }, + 'include-comments': { type: 'boolean', default: true }, }, }, { @@ -90,8 +91,8 @@ function getToolManifests(): ToolManifest[] { cliCommand: 'cascade-tools pm update-checklist-item', parameters: { workItemId: { type: 'string', required: true }, - checkItemId: { type: 'string', required: true }, - complete: { type: 'boolean' }, + 'check-item-id': { type: 'string', required: true }, + state: { type: 'string', required: true, description: 'complete or incomplete' }, }, }, { @@ -273,12 +274,21 @@ async function buildBackendInput( pmType, }; + // Load DB partials for template include resolution + let dbPartials: Map | undefined; + try { + dbPartials = await loadPartials(project.orgId); + } catch { + // DB not available — fall back to disk-only partials + } + const { systemPrompt, model, maxIterations, contextFiles } = await resolveModelConfig({ agentType, project, config, repoDir, promptContext, + dbPartials, }); const profile = getAgentProfile(agentType); diff --git a/src/cli/dashboard/agents/create.ts b/src/cli/dashboard/agents/create.ts index 195651d7..a21627d6 100644 --- a/src/cli/dashboard/agents/create.ts +++ b/src/cli/dashboard/agents/create.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs'; import { Flags } from '@oclif/core'; import { DashboardCommand } from '../_shared/base.js'; @@ -14,20 +15,31 @@ export default class AgentsCreate extends DashboardCommand { model: Flags.string({ description: 'Model override' }), 'max-iterations': Flags.integer({ description: 'Max iterations override' }), backend: Flags.string({ description: 'Agent backend override' }), - prompt: Flags.string({ description: 'Custom prompt override' }), + prompt: Flags.string({ description: 'Custom prompt override (inline)' }), + 'prompt-file': Flags.string({ + description: 'Read prompt from file (use - for stdin)', + }), }; async run(): Promise { const { flags } = await this.parse(AgentsCreate); try { + let prompt = flags.prompt ?? null; + if (flags['prompt-file']) { + prompt = + flags['prompt-file'] === '-' + ? readFileSync(0, 'utf-8') + : readFileSync(flags['prompt-file'], 'utf-8'); + } + const result = await this.client.agentConfigs.create.mutate({ agentType: flags['agent-type'], projectId: flags['project-id'], model: flags.model, maxIterations: flags['max-iterations'], agentBackend: flags.backend, - prompt: flags.prompt, + prompt, }); if (flags.json) { diff --git a/src/cli/dashboard/agents/list.ts b/src/cli/dashboard/agents/list.ts index 06999ff7..b17987b5 100644 --- a/src/cli/dashboard/agents/list.ts +++ b/src/cli/dashboard/agents/list.ts @@ -29,6 +29,7 @@ export default class AgentsList extends DashboardCommand { { key: 'model', header: 'Model' }, { key: 'maxIterations', header: 'Max Iter' }, { key: 'agentBackend', header: 'Backend' }, + { key: 'prompt', header: 'Prompt', format: (v) => (v ? 'custom' : '-') }, ]); } catch (err) { this.handleError(err); diff --git a/src/cli/dashboard/agents/update.ts b/src/cli/dashboard/agents/update.ts index 0453fb48..599fa019 100644 --- a/src/cli/dashboard/agents/update.ts +++ b/src/cli/dashboard/agents/update.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs'; import { Args, Flags } from '@oclif/core'; import { DashboardCommand } from '../_shared/base.js'; @@ -14,20 +15,31 @@ export default class AgentsUpdate extends DashboardCommand { model: Flags.string({ description: 'Model override' }), 'max-iterations': Flags.integer({ description: 'Max iterations override' }), backend: Flags.string({ description: 'Agent backend override' }), - prompt: Flags.string({ description: 'Custom prompt override' }), + prompt: Flags.string({ description: 'Custom prompt override (inline)' }), + 'prompt-file': Flags.string({ + description: 'Read prompt from file (use - for stdin)', + }), }; async run(): Promise { const { args, flags } = await this.parse(AgentsUpdate); try { + let prompt: string | null | undefined = flags.prompt; + if (flags['prompt-file']) { + prompt = + flags['prompt-file'] === '-' + ? readFileSync(0, 'utf-8') + : readFileSync(flags['prompt-file'], 'utf-8'); + } + await this.client.agentConfigs.update.mutate({ id: args.id, agentType: flags['agent-type'], model: flags.model, maxIterations: flags['max-iterations'], agentBackend: flags.backend, - prompt: flags.prompt, + prompt: prompt ?? null, }); if (flags.json) { diff --git a/src/cli/dashboard/prompts/default-partial.ts b/src/cli/dashboard/prompts/default-partial.ts new file mode 100644 index 00000000..445eee9a --- /dev/null +++ b/src/cli/dashboard/prompts/default-partial.ts @@ -0,0 +1,27 @@ +import { Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class PromptsDefaultPartial extends DashboardCommand { + static override description = 'Print the disk-based default partial content.'; + + static override flags = { + ...DashboardCommand.baseFlags, + name: Flags.string({ + description: 'Partial name (e.g. git, tmux, test-protocol)', + required: true, + }), + }; + + async run(): Promise { + const { flags } = await this.parse(PromptsDefaultPartial); + + try { + const result = await this.client.prompts.getDefaultPartial.query({ + name: flags.name, + }); + process.stdout.write(result.content); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/prompts/default.ts b/src/cli/dashboard/prompts/default.ts new file mode 100644 index 00000000..7b4bd905 --- /dev/null +++ b/src/cli/dashboard/prompts/default.ts @@ -0,0 +1,29 @@ +import { Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class PromptsDefault extends DashboardCommand { + static override description = 'Print the default .eta template for an agent type.'; + + static override flags = { + ...DashboardCommand.baseFlags, + 'agent-type': Flags.string({ + description: 'Agent type (e.g. implementation, review)', + required: true, + }), + }; + + async run(): Promise { + const { flags } = await this.parse(PromptsDefault); + + try { + const result = await this.client.prompts.getDefault.query({ + agentType: flags['agent-type'], + }); + + // Print raw template to stdout (for piping) + process.stdout.write(result.content); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/prompts/get-partial.ts b/src/cli/dashboard/prompts/get-partial.ts new file mode 100644 index 00000000..fbe1adc2 --- /dev/null +++ b/src/cli/dashboard/prompts/get-partial.ts @@ -0,0 +1,25 @@ +import { Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class PromptsGetPartial extends DashboardCommand { + static override description = 'Print a partial (DB content or disk fallback).'; + + static override flags = { + ...DashboardCommand.baseFlags, + name: Flags.string({ + description: 'Partial name (e.g. git, tmux, test-protocol)', + required: true, + }), + }; + + async run(): Promise { + const { flags } = await this.parse(PromptsGetPartial); + + try { + const result = await this.client.prompts.getPartial.query({ name: flags.name }); + process.stdout.write(result.content); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/prompts/list-partials.ts b/src/cli/dashboard/prompts/list-partials.ts new file mode 100644 index 00000000..1f73dac2 --- /dev/null +++ b/src/cli/dashboard/prompts/list-partials.ts @@ -0,0 +1,30 @@ +import { DashboardCommand } from '../_shared/base.js'; + +export default class PromptsListPartials extends DashboardCommand { + static override description = 'List all prompt partials (DB and disk).'; + + static override flags = { + ...DashboardCommand.baseFlags, + }; + + async run(): Promise { + const { flags } = await this.parse(PromptsListPartials); + + try { + const partials = await this.client.prompts.listPartials.query(); + + if (flags.json) { + this.outputJson(partials); + return; + } + + this.outputTable(partials as unknown as Record[], [ + { key: 'name', header: 'Name' }, + { key: 'source', header: 'Source' }, + { key: 'lines', header: 'Lines' }, + ]); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/prompts/reset-partial.ts b/src/cli/dashboard/prompts/reset-partial.ts new file mode 100644 index 00000000..45764509 --- /dev/null +++ b/src/cli/dashboard/prompts/reset-partial.ts @@ -0,0 +1,43 @@ +import { Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class PromptsResetPartial extends DashboardCommand { + static override description = 'Delete a DB partial (revert to disk default).'; + + static override flags = { + ...DashboardCommand.baseFlags, + name: Flags.string({ + description: 'Partial name to reset', + required: true, + }), + }; + + async run(): Promise { + const { flags } = await this.parse(PromptsResetPartial); + + try { + // Get the partial to find its ID + const partial = await this.client.prompts.getPartial.query({ name: flags.name }); + + if (partial.source === 'disk') { + this.log(`Partial "${flags.name}" is already using disk default.`); + return; + } + + if (partial.id == null) { + this.error(`Cannot determine partial ID for "${flags.name}".`); + } + + await this.client.prompts.deletePartial.mutate({ id: partial.id }); + + if (flags.json) { + this.outputJson({ ok: true }); + return; + } + + this.log(`Partial "${flags.name}" reset to disk default.`); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/prompts/set-partial.ts b/src/cli/dashboard/prompts/set-partial.ts new file mode 100644 index 00000000..5d8fe73d --- /dev/null +++ b/src/cli/dashboard/prompts/set-partial.ts @@ -0,0 +1,42 @@ +import { readFileSync } from 'node:fs'; +import { Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class PromptsSetPartial extends DashboardCommand { + static override description = 'Create or update a partial from a file.'; + + static override flags = { + ...DashboardCommand.baseFlags, + name: Flags.string({ + description: 'Partial name (e.g. git, tmux, test-protocol)', + required: true, + }), + file: Flags.string({ + description: 'Path to partial file (use - for stdin)', + required: true, + }), + }; + + async run(): Promise { + const { flags } = await this.parse(PromptsSetPartial); + + try { + const content = + flags.file === '-' ? readFileSync(0, 'utf-8') : readFileSync(flags.file, 'utf-8'); + + const result = await this.client.prompts.upsertPartial.mutate({ + name: flags.name, + content, + }); + + if (flags.json) { + this.outputJson(result); + return; + } + + this.log(`Partial "${flags.name}" saved (id: ${result.id}).`); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/prompts/validate.ts b/src/cli/dashboard/prompts/validate.ts new file mode 100644 index 00000000..7ef4d653 --- /dev/null +++ b/src/cli/dashboard/prompts/validate.ts @@ -0,0 +1,39 @@ +import { readFileSync } from 'node:fs'; +import { Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class PromptsValidate extends DashboardCommand { + static override description = 'Validate a prompt template file.'; + + static override flags = { + ...DashboardCommand.baseFlags, + file: Flags.string({ + description: 'Path to template file (use - for stdin)', + required: true, + }), + }; + + async run(): Promise { + const { flags } = await this.parse(PromptsValidate); + + try { + const template = + flags.file === '-' ? readFileSync(0, 'utf-8') : readFileSync(flags.file, 'utf-8'); + + const result = await this.client.prompts.validate.mutate({ template }); + + if (flags.json) { + this.outputJson(result); + return; + } + + if (result.valid) { + this.log('Template is valid.'); + } else { + this.error(`Template invalid: ${result.error}`); + } + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/prompts/variables.ts b/src/cli/dashboard/prompts/variables.ts new file mode 100644 index 00000000..e876ecbd --- /dev/null +++ b/src/cli/dashboard/prompts/variables.ts @@ -0,0 +1,30 @@ +import { DashboardCommand } from '../_shared/base.js'; + +export default class PromptsVariables extends DashboardCommand { + static override description = 'List available template variables.'; + + static override flags = { + ...DashboardCommand.baseFlags, + }; + + async run(): Promise { + const { flags } = await this.parse(PromptsVariables); + + try { + const variables = await this.client.prompts.variables.query(); + + if (flags.json) { + this.outputJson(variables); + return; + } + + this.outputTable(variables as unknown as Record[], [ + { key: 'name', header: 'Variable' }, + { key: 'group', header: 'Group' }, + { key: 'description', header: 'Description' }, + ]); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/github/create-pr-review.ts b/src/cli/github/create-pr-review.ts index be5e4789..692a2a24 100644 --- a/src/cli/github/create-pr-review.ts +++ b/src/cli/github/create-pr-review.ts @@ -1,17 +1,14 @@ -import { Args, Flags } from '@oclif/core'; +import { Flags } from '@oclif/core'; import { createPRReview } from '../../gadgets/github/core/createPRReview.js'; import { CredentialScopedCommand } from '../base.js'; export default class CreatePRReviewCommand extends CredentialScopedCommand { static override description = 'Submit a code review on a GitHub pull request.'; - static override args = { - prNumber: Args.integer({ description: 'The pull request number', required: true }), - }; - static override flags = { owner: Flags.string({ description: 'Repository owner', required: true }), repo: Flags.string({ description: 'Repository name', required: true }), + prNumber: Flags.integer({ description: 'The pull request number', required: true }), event: Flags.string({ description: 'Review action', required: true, @@ -24,7 +21,7 @@ export default class CreatePRReviewCommand extends CredentialScopedCommand { }; async execute(): Promise { - const { args, flags } = await this.parse(CreatePRReviewCommand); + const { flags } = await this.parse(CreatePRReviewCommand); let comments: Array<{ path: string; line?: number; body: string }> | undefined; if (flags.comments) { @@ -38,7 +35,7 @@ export default class CreatePRReviewCommand extends CredentialScopedCommand { const result = await createPRReview({ owner: flags.owner, repo: flags.repo, - prNumber: args.prNumber, + prNumber: flags.prNumber, event: flags.event as 'APPROVE' | 'REQUEST_CHANGES' | 'COMMENT', body: flags.body, comments, diff --git a/src/cli/github/get-pr-checks.ts b/src/cli/github/get-pr-checks.ts index 6e49f9ab..82181478 100644 --- a/src/cli/github/get-pr-checks.ts +++ b/src/cli/github/get-pr-checks.ts @@ -1,22 +1,19 @@ -import { Args, Flags } from '@oclif/core'; +import { Flags } from '@oclif/core'; import { getPRChecks } from '../../gadgets/github/core/getPRChecks.js'; import { CredentialScopedCommand } from '../base.js'; export default class GetPRChecks extends CredentialScopedCommand { static override description = 'Get the CI check status for a GitHub pull request.'; - static override args = { - prNumber: Args.integer({ description: 'The pull request number', required: true }), - }; - static override flags = { owner: Flags.string({ description: 'Repository owner', required: true }), repo: Flags.string({ description: 'Repository name', required: true }), + prNumber: Flags.integer({ description: 'The pull request number', required: true }), }; async execute(): Promise { - const { args, flags } = await this.parse(GetPRChecks); - const result = await getPRChecks(flags.owner, flags.repo, args.prNumber); + const { flags } = await this.parse(GetPRChecks); + const result = await getPRChecks(flags.owner, flags.repo, flags.prNumber); this.log(JSON.stringify({ success: true, data: result })); } } diff --git a/src/cli/github/get-pr-comments.ts b/src/cli/github/get-pr-comments.ts index 9d3c7b73..bc195d5a 100644 --- a/src/cli/github/get-pr-comments.ts +++ b/src/cli/github/get-pr-comments.ts @@ -1,22 +1,19 @@ -import { Args, Flags } from '@oclif/core'; +import { Flags } from '@oclif/core'; import { getPRComments } from '../../gadgets/github/core/getPRComments.js'; import { CredentialScopedCommand } from '../base.js'; export default class GetPRComments extends CredentialScopedCommand { static override description = 'Get all review comments on a GitHub pull request.'; - static override args = { - prNumber: Args.integer({ description: 'The pull request number', required: true }), - }; - static override flags = { owner: Flags.string({ description: 'Repository owner', required: true }), repo: Flags.string({ description: 'Repository name', required: true }), + prNumber: Flags.integer({ description: 'The pull request number', required: true }), }; async execute(): Promise { - const { args, flags } = await this.parse(GetPRComments); - const result = await getPRComments(flags.owner, flags.repo, args.prNumber); + const { flags } = await this.parse(GetPRComments); + const result = await getPRComments(flags.owner, flags.repo, flags.prNumber); this.log(JSON.stringify({ success: true, data: result })); } } diff --git a/src/cli/github/get-pr-details.ts b/src/cli/github/get-pr-details.ts index 622c63f5..aec572e1 100644 --- a/src/cli/github/get-pr-details.ts +++ b/src/cli/github/get-pr-details.ts @@ -1,22 +1,19 @@ -import { Args, Flags } from '@oclif/core'; +import { Flags } from '@oclif/core'; import { getPRDetails } from '../../gadgets/github/core/getPRDetails.js'; import { CredentialScopedCommand } from '../base.js'; export default class GetPRDetails extends CredentialScopedCommand { static override description = 'Get details about a GitHub pull request.'; - static override args = { - prNumber: Args.integer({ description: 'The pull request number', required: true }), - }; - static override flags = { owner: Flags.string({ description: 'Repository owner', required: true }), repo: Flags.string({ description: 'Repository name', required: true }), + prNumber: Flags.integer({ description: 'The pull request number', required: true }), }; async execute(): Promise { - const { args, flags } = await this.parse(GetPRDetails); - const result = await getPRDetails(flags.owner, flags.repo, args.prNumber); + const { flags } = await this.parse(GetPRDetails); + const result = await getPRDetails(flags.owner, flags.repo, flags.prNumber); this.log(JSON.stringify({ success: true, data: result })); } } diff --git a/src/cli/github/get-pr-diff.ts b/src/cli/github/get-pr-diff.ts index 2d287f53..b311fb6d 100644 --- a/src/cli/github/get-pr-diff.ts +++ b/src/cli/github/get-pr-diff.ts @@ -1,22 +1,19 @@ -import { Args, Flags } from '@oclif/core'; +import { Flags } from '@oclif/core'; import { getPRDiff } from '../../gadgets/github/core/getPRDiff.js'; import { CredentialScopedCommand } from '../base.js'; export default class GetPRDiff extends CredentialScopedCommand { static override description = 'Get the unified diff of all file changes in a GitHub PR.'; - static override args = { - prNumber: Args.integer({ description: 'The pull request number', required: true }), - }; - static override flags = { owner: Flags.string({ description: 'Repository owner', required: true }), repo: Flags.string({ description: 'Repository name', required: true }), + prNumber: Flags.integer({ description: 'The pull request number', required: true }), }; async execute(): Promise { - const { args, flags } = await this.parse(GetPRDiff); - const result = await getPRDiff(flags.owner, flags.repo, args.prNumber); + const { flags } = await this.parse(GetPRDiff); + const result = await getPRDiff(flags.owner, flags.repo, flags.prNumber); this.log(JSON.stringify({ success: true, data: result })); } } diff --git a/src/cli/github/post-pr-comment.ts b/src/cli/github/post-pr-comment.ts index e5e7064b..cbb15950 100644 --- a/src/cli/github/post-pr-comment.ts +++ b/src/cli/github/post-pr-comment.ts @@ -1,23 +1,20 @@ -import { Args, Flags } from '@oclif/core'; +import { Flags } from '@oclif/core'; import { postPRComment } from '../../gadgets/github/core/postPRComment.js'; import { CredentialScopedCommand } from '../base.js'; export default class PostPRComment extends CredentialScopedCommand { static override description = 'Post a comment on a GitHub pull request.'; - static override args = { - prNumber: Args.integer({ description: 'The pull request number', required: true }), - }; - static override flags = { owner: Flags.string({ description: 'Repository owner', required: true }), repo: Flags.string({ description: 'Repository name', required: true }), + prNumber: Flags.integer({ description: 'The pull request number', required: true }), body: Flags.string({ description: 'Comment body (markdown supported)', required: true }), }; async execute(): Promise { - const { args, flags } = await this.parse(PostPRComment); - const result = await postPRComment(flags.owner, flags.repo, args.prNumber, flags.body); + const { flags } = await this.parse(PostPRComment); + const result = await postPRComment(flags.owner, flags.repo, flags.prNumber, flags.body); this.log(JSON.stringify({ success: true, data: result })); } } diff --git a/src/cli/github/reply-to-review-comment.ts b/src/cli/github/reply-to-review-comment.ts index 26473162..3b8f6e26 100644 --- a/src/cli/github/reply-to-review-comment.ts +++ b/src/cli/github/reply-to-review-comment.ts @@ -1,27 +1,24 @@ -import { Args, Flags } from '@oclif/core'; +import { Flags } from '@oclif/core'; import { replyToReviewComment } from '../../gadgets/github/core/replyToReviewComment.js'; import { CredentialScopedCommand } from '../base.js'; export default class ReplyToReviewComment extends CredentialScopedCommand { static override description = 'Reply to a specific review comment on a GitHub pull request.'; - static override args = { - prNumber: Args.integer({ description: 'The pull request number', required: true }), - }; - static override flags = { owner: Flags.string({ description: 'Repository owner', required: true }), repo: Flags.string({ description: 'Repository name', required: true }), + prNumber: Flags.integer({ description: 'The pull request number', required: true }), 'comment-id': Flags.integer({ description: 'The comment ID to reply to', required: true }), body: Flags.string({ description: 'Reply message (markdown supported)', required: true }), }; async execute(): Promise { - const { args, flags } = await this.parse(ReplyToReviewComment); + const { flags } = await this.parse(ReplyToReviewComment); const result = await replyToReviewComment( flags.owner, flags.repo, - args.prNumber, + flags.prNumber, flags['comment-id'], flags.body, ); diff --git a/src/cli/github/update-pr-comment.ts b/src/cli/github/update-pr-comment.ts index 4c47bb4c..70f6a1d4 100644 --- a/src/cli/github/update-pr-comment.ts +++ b/src/cli/github/update-pr-comment.ts @@ -1,23 +1,20 @@ -import { Args, Flags } from '@oclif/core'; +import { Flags } from '@oclif/core'; import { updatePRComment } from '../../gadgets/github/core/updatePRComment.js'; import { CredentialScopedCommand } from '../base.js'; export default class UpdatePRComment extends CredentialScopedCommand { static override description = 'Update an existing comment on a GitHub pull request.'; - static override args = { - commentId: Args.integer({ description: 'The comment ID', required: true }), - }; - static override flags = { owner: Flags.string({ description: 'Repository owner', required: true }), repo: Flags.string({ description: 'Repository name', required: true }), + commentId: Flags.integer({ description: 'The comment ID', required: true }), body: Flags.string({ description: 'New comment body (markdown supported)', required: true }), }; async execute(): Promise { - const { args, flags } = await this.parse(UpdatePRComment); - const result = await updatePRComment(flags.owner, flags.repo, args.commentId, flags.body); + const { flags } = await this.parse(UpdatePRComment); + const result = await updatePRComment(flags.owner, flags.repo, flags.commentId, flags.body); this.log(JSON.stringify({ success: true, data: result })); } } diff --git a/src/cli/pm/add-checklist.ts b/src/cli/pm/add-checklist.ts index 2dbfe743..aa271d2e 100644 --- a/src/cli/pm/add-checklist.ts +++ b/src/cli/pm/add-checklist.ts @@ -1,15 +1,12 @@ -import { Args, Flags } from '@oclif/core'; +import { Flags } from '@oclif/core'; import { addChecklist } from '../../gadgets/pm/core/addChecklist.js'; import { CredentialScopedCommand } from '../base.js'; export default class AddChecklist extends CredentialScopedCommand { static override description = 'Add a checklist with items to a work item.'; - static override args = { - workItemId: Args.string({ description: 'The work item ID', required: true }), - }; - static override flags = { + workItemId: Flags.string({ description: 'The work item ID', required: true }), name: Flags.string({ description: 'Checklist name', required: true }), items: Flags.string({ description: 'Checklist items (can be specified multiple times)', @@ -19,9 +16,9 @@ export default class AddChecklist extends CredentialScopedCommand { }; async execute(): Promise { - const { args, flags } = await this.parse(AddChecklist); + const { flags } = await this.parse(AddChecklist); const result = await addChecklist({ - workItemId: args.workItemId, + workItemId: flags.workItemId, checklistName: flags.name, items: flags.items, }); diff --git a/src/cli/pm/create-work-item.ts b/src/cli/pm/create-work-item.ts index d2390911..e29c23b4 100644 --- a/src/cli/pm/create-work-item.ts +++ b/src/cli/pm/create-work-item.ts @@ -1,23 +1,23 @@ -import { Args, Flags } from '@oclif/core'; +import { Flags } from '@oclif/core'; import { createWorkItem } from '../../gadgets/pm/core/createWorkItem.js'; import { CredentialScopedCommand } from '../base.js'; export default class CreateWorkItem extends CredentialScopedCommand { static override description = 'Create a new work item in a container (list/project).'; - static override args = { - containerId: Args.string({ description: 'The container ID (list or project)', required: true }), - }; - static override flags = { + containerId: Flags.string({ + description: 'The container ID (list or project)', + required: true, + }), title: Flags.string({ description: 'Work item title', required: true }), description: Flags.string({ description: 'Work item description (markdown supported)' }), }; async execute(): Promise { - const { args, flags } = await this.parse(CreateWorkItem); + const { flags } = await this.parse(CreateWorkItem); const result = await createWorkItem({ - containerId: args.containerId, + containerId: flags.containerId, title: flags.title, description: flags.description, }); diff --git a/src/cli/pm/list-work-items.ts b/src/cli/pm/list-work-items.ts index 65b7ce40..c22667f2 100644 --- a/src/cli/pm/list-work-items.ts +++ b/src/cli/pm/list-work-items.ts @@ -1,17 +1,20 @@ -import { Args } from '@oclif/core'; +import { Flags } from '@oclif/core'; import { listWorkItems } from '../../gadgets/pm/core/listWorkItems.js'; import { CredentialScopedCommand } from '../base.js'; export default class ListWorkItems extends CredentialScopedCommand { static override description = 'List all work items in a container (list/project).'; - static override args = { - containerId: Args.string({ description: 'The container ID (list or project)', required: true }), + static override flags = { + containerId: Flags.string({ + description: 'The container ID (list or project)', + required: true, + }), }; async execute(): Promise { - const { args } = await this.parse(ListWorkItems); - const result = await listWorkItems(args.containerId); + const { flags } = await this.parse(ListWorkItems); + const result = await listWorkItems(flags.containerId); this.log(JSON.stringify({ success: true, data: result })); } } diff --git a/src/cli/pm/post-comment.ts b/src/cli/pm/post-comment.ts index 757e9a4f..aa50f0a8 100644 --- a/src/cli/pm/post-comment.ts +++ b/src/cli/pm/post-comment.ts @@ -1,21 +1,18 @@ -import { Args, Flags } from '@oclif/core'; +import { Flags } from '@oclif/core'; import { postComment } from '../../gadgets/pm/core/postComment.js'; import { CredentialScopedCommand } from '../base.js'; export default class PostComment extends CredentialScopedCommand { static override description = 'Post a comment to a work item.'; - static override args = { - workItemId: Args.string({ description: 'The work item ID', required: true }), - }; - static override flags = { + workItemId: Flags.string({ description: 'The work item ID', required: true }), text: Flags.string({ description: 'The comment text (supports markdown)', required: true }), }; async execute(): Promise { - const { args, flags } = await this.parse(PostComment); - const result = await postComment(args.workItemId, flags.text); + const { flags } = await this.parse(PostComment); + const result = await postComment(flags.workItemId, flags.text); this.log(JSON.stringify({ success: true, data: result })); } } diff --git a/src/cli/pm/read-work-item.ts b/src/cli/pm/read-work-item.ts index 47503ec1..7273db48 100644 --- a/src/cli/pm/read-work-item.ts +++ b/src/cli/pm/read-work-item.ts @@ -1,4 +1,4 @@ -import { Args, Flags } from '@oclif/core'; +import { Flags } from '@oclif/core'; import { readWorkItem } from '../../gadgets/pm/core/readWorkItem.js'; import { CredentialScopedCommand } from '../base.js'; @@ -6,11 +6,8 @@ export default class ReadWorkItem extends CredentialScopedCommand { static override description = 'Read a work item with its title, description, comments, checklists, and attachments.'; - static override args = { - workItemId: Args.string({ description: 'The work item ID', required: true }), - }; - static override flags = { + workItemId: Flags.string({ description: 'The work item ID', required: true }), 'include-comments': Flags.boolean({ description: 'Include comments in the response', default: true, @@ -19,8 +16,8 @@ export default class ReadWorkItem extends CredentialScopedCommand { }; async execute(): Promise { - const { args, flags } = await this.parse(ReadWorkItem); - const result = await readWorkItem(args.workItemId, flags['include-comments']); + const { flags } = await this.parse(ReadWorkItem); + const result = await readWorkItem(flags.workItemId, flags['include-comments']); this.log(JSON.stringify({ success: true, data: result })); } } diff --git a/src/cli/pm/update-checklist-item.ts b/src/cli/pm/update-checklist-item.ts index d49901cb..600169b5 100644 --- a/src/cli/pm/update-checklist-item.ts +++ b/src/cli/pm/update-checklist-item.ts @@ -1,15 +1,12 @@ -import { Args, Flags } from '@oclif/core'; +import { Flags } from '@oclif/core'; import { updateChecklistItem } from '../../gadgets/pm/core/updateChecklistItem.js'; import { CredentialScopedCommand } from '../base.js'; export default class UpdateChecklistItem extends CredentialScopedCommand { static override description = 'Update a checklist item state on a work item.'; - static override args = { - workItemId: Args.string({ description: 'The work item ID', required: true }), - }; - static override flags = { + workItemId: Flags.string({ description: 'The work item ID', required: true }), 'check-item-id': Flags.string({ description: 'The checklist item ID', required: true }), state: Flags.string({ description: 'The new state', @@ -19,9 +16,9 @@ export default class UpdateChecklistItem extends CredentialScopedCommand { }; async execute(): Promise { - const { args, flags } = await this.parse(UpdateChecklistItem); + const { flags } = await this.parse(UpdateChecklistItem); const result = await updateChecklistItem( - args.workItemId, + flags.workItemId, flags['check-item-id'], flags.state === 'complete', ); diff --git a/src/cli/pm/update-work-item.ts b/src/cli/pm/update-work-item.ts index 4bfc5547..f704471c 100644 --- a/src/cli/pm/update-work-item.ts +++ b/src/cli/pm/update-work-item.ts @@ -1,15 +1,12 @@ -import { Args, Flags } from '@oclif/core'; +import { Flags } from '@oclif/core'; import { updateWorkItem } from '../../gadgets/pm/core/updateWorkItem.js'; import { CredentialScopedCommand } from '../base.js'; export default class UpdateWorkItem extends CredentialScopedCommand { static override description = 'Update a work item title, description, or labels.'; - static override args = { - workItemId: Args.string({ description: 'The work item ID', required: true }), - }; - static override flags = { + workItemId: Flags.string({ description: 'The work item ID', required: true }), title: Flags.string({ description: 'New title' }), description: Flags.string({ description: 'New description (markdown supported)' }), 'add-label-ids': Flags.string({ @@ -18,9 +15,9 @@ export default class UpdateWorkItem extends CredentialScopedCommand { }; async execute(): Promise { - const { args, flags } = await this.parse(UpdateWorkItem); + const { flags } = await this.parse(UpdateWorkItem); const result = await updateWorkItem({ - workItemId: args.workItemId, + workItemId: flags.workItemId, title: flags.title, description: flags.description, addLabelIds: flags['add-label-ids']?.split(','), diff --git a/src/config/schema.ts b/src/config/schema.ts index 0efe1142..a40103d6 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -70,6 +70,7 @@ export const CascadeConfigSchema = z.object({ agentBackend: z.string().default('llmist'), progressModel: z.string().default('openrouter:google/gemini-2.5-flash-lite'), progressIntervalMinutes: z.number().positive().default(5), + prompts: z.record(z.string()).default({}), }) .default({}), projects: z.array(ProjectConfigSchema).min(1), diff --git a/src/db/migrations/0008_prompt_partials.sql b/src/db/migrations/0008_prompt_partials.sql new file mode 100644 index 00000000..d49be9db --- /dev/null +++ b/src/db/migrations/0008_prompt_partials.sql @@ -0,0 +1,21 @@ +-- Prompt partials: store reusable template partials in the database +-- These override the on-disk .eta partial files in src/agents/prompts/templates/partials/ + +CREATE TABLE prompt_partials ( + id SERIAL PRIMARY KEY, + org_id TEXT REFERENCES organizations(id) ON DELETE CASCADE, + name TEXT NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP DEFAULT now(), + updated_at TIMESTAMP DEFAULT now() +); + +-- Global partials (org_id IS NULL): one per name +CREATE UNIQUE INDEX uq_prompt_partials_global + ON prompt_partials (name) + WHERE org_id IS NULL; + +-- Org-scoped partials: one per (org_id, name) +CREATE UNIQUE INDEX uq_prompt_partials_org + ON prompt_partials (org_id, name) + WHERE org_id IS NOT NULL; diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 1eee21ee..79648f25 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1742000000000, "tag": "0007_remove_flyio_columns", "breakpoints": false + }, + { + "idx": 7, + "version": "7", + "when": 1743000000000, + "tag": "0008_prompt_partials", + "breakpoints": false } ] } diff --git a/src/db/repositories/configRepository.ts b/src/db/repositories/configRepository.ts index c2837844..b8d38c8c 100644 --- a/src/db/repositories/configRepository.ts +++ b/src/db/repositories/configRepository.ts @@ -58,7 +58,7 @@ function orUndefined>(obj: T): T | undefined { } function mapDefaultsRow(row: DefaultsRow | undefined, globalAgentConfigs: AgentConfigRow[]) { - const { models, iterations } = buildAgentMaps(globalAgentConfigs); + const { models, iterations, prompts } = buildAgentMaps(globalAgentConfigs); return { model: row?.model ?? undefined, @@ -72,6 +72,7 @@ function mapDefaultsRow(row: DefaultsRow | undefined, globalAgentConfigs: AgentC progressIntervalMinutes: row?.progressIntervalMinutes ? Number(row.progressIntervalMinutes) : undefined, + prompts: orUndefined(prompts), }; } diff --git a/src/db/repositories/partialsRepository.ts b/src/db/repositories/partialsRepository.ts new file mode 100644 index 00000000..3212c858 --- /dev/null +++ b/src/db/repositories/partialsRepository.ts @@ -0,0 +1,119 @@ +import { and, eq, isNull } from 'drizzle-orm'; +import { getDb } from '../client.js'; +import { promptPartials } from '../schema/index.js'; + +export type PartialRow = typeof promptPartials.$inferSelect; + +/** Returns true if the error indicates the prompt_partials table doesn't exist yet. */ +function isTableMissing(err: unknown): boolean { + return err instanceof Error && err.message.includes('prompt_partials'); +} + +export async function loadPartials(orgId?: string): Promise> { + try { + const db = getDb(); + // Load global partials (org_id IS NULL) + const globalRows = await db.select().from(promptPartials).where(isNull(promptPartials.orgId)); + + const result = new Map(); + for (const row of globalRows) { + result.set(row.name, row.content); + } + + // If org-scoped, overlay org partials on top of globals + if (orgId) { + const orgRows = await db.select().from(promptPartials).where(eq(promptPartials.orgId, orgId)); + for (const row of orgRows) { + result.set(row.name, row.content); + } + } + + return result; + } catch (err) { + if (isTableMissing(err)) return new Map(); + throw err; + } +} + +export async function listPartials(orgId?: string): Promise { + try { + const db = getDb(); + if (orgId) { + // Return both global and org-scoped + return await db + .select() + .from(promptPartials) + .where(isNull(promptPartials.orgId)) + .then(async (globals) => { + const orgRows = await db + .select() + .from(promptPartials) + .where(eq(promptPartials.orgId, orgId)); + return [...globals, ...orgRows]; + }); + } + return await db.select().from(promptPartials).where(isNull(promptPartials.orgId)); + } catch (err) { + if (isTableMissing(err)) return []; + throw err; + } +} + +export async function getPartial(name: string, orgId?: string): Promise { + try { + const db = getDb(); + // Try org-scoped first, then global + if (orgId) { + const [orgRow] = await db + .select() + .from(promptPartials) + .where(and(eq(promptPartials.orgId, orgId), eq(promptPartials.name, name))); + if (orgRow) return orgRow; + } + const [globalRow] = await db + .select() + .from(promptPartials) + .where(and(isNull(promptPartials.orgId), eq(promptPartials.name, name))); + return globalRow ?? null; + } catch (err) { + if (isTableMissing(err)) return null; + throw err; + } +} + +export async function upsertPartial(data: { + orgId?: string | null; + name: string; + content: string; +}): Promise { + const db = getDb(); + const whereCondition = data.orgId + ? and(eq(promptPartials.orgId, data.orgId), eq(promptPartials.name, data.name)) + : and(isNull(promptPartials.orgId), eq(promptPartials.name, data.name)); + + const [existing] = await db.select().from(promptPartials).where(whereCondition); + + if (existing) { + const [updated] = await db + .update(promptPartials) + .set({ content: data.content, updatedAt: new Date() }) + .where(eq(promptPartials.id, existing.id)) + .returning(); + return updated; + } + + const [inserted] = await db + .insert(promptPartials) + .values({ + orgId: data.orgId ?? null, + name: data.name, + content: data.content, + }) + .returning(); + return inserted; +} + +export async function deletePartial(id: number): Promise { + const db = getDb(); + await db.delete(promptPartials).where(eq(promptPartials.id, id)); +} diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index f00ac855..401fa00b 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -5,4 +5,5 @@ export { agentConfigs } from './agentConfigs.js'; export { projectIntegrations } from './integrations.js'; export { projects } from './projects.js'; export { agentRunLlmCalls, agentRunLogs, agentRuns, debugAnalyses } from './runs.js'; +export { promptPartials } from './promptPartials.js'; export { sessions, users } from './users.js'; diff --git a/src/db/schema/promptPartials.ts b/src/db/schema/promptPartials.ts new file mode 100644 index 00000000..60b49e8c --- /dev/null +++ b/src/db/schema/promptPartials.ts @@ -0,0 +1,13 @@ +import { pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core'; +import { organizations } from './organizations.js'; + +export const promptPartials = pgTable('prompt_partials', { + id: serial('id').primaryKey(), + orgId: text('org_id').references(() => organizations.id, { onDelete: 'cascade' }), + name: text('name').notNull(), + content: text('content').notNull(), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at') + .defaultNow() + .$onUpdate(() => new Date()), +}); diff --git a/src/gadgets/pm/core/readWorkItem.ts b/src/gadgets/pm/core/readWorkItem.ts index 2ddb8f46..37dfeb0a 100644 --- a/src/gadgets/pm/core/readWorkItem.ts +++ b/src/gadgets/pm/core/readWorkItem.ts @@ -1,5 +1,78 @@ import { getPMProvider } from '../../../pm/index.js'; +interface Label { + name: string; + color?: string; +} + +interface ChecklistItem { + id: string; + name: string; + complete: boolean; +} + +interface Checklist { + id: string; + name: string; + items: ChecklistItem[]; +} + +interface Attachment { + name: string; + url: string; + date?: string; +} + +interface Comment { + author: { name: string }; + date: string; + text: string; +} + +function formatLabels(labels: Label[]): string { + if (labels.length === 0) return ''; + const items = labels.map((l) => `- ${l.name}${l.color ? ` (${l.color})` : ''}`).join('\n'); + return `## Labels\n\n${items}\n\n`; +} + +function formatChecklists(checklists: Checklist[]): string { + if (checklists.length === 0) return ''; + let result = '## Checklists\n\n'; + for (const checklist of checklists) { + result += `### ${checklist.name} [checklistId: ${checklist.id}]\n\n`; + for (const item of checklist.items) { + const checkbox = item.complete ? '[x]' : '[ ]'; + result += `- ${checkbox} ${item.name} [checkItemId: ${item.id}]\n`; + } + result += '\n'; + } + return result; +} + +function formatAttachments(attachments: Attachment[]): string { + if (attachments.length === 0) return ''; + let result = '## Attachments\n\n'; + for (const att of attachments) { + result += `- [${att.name}](${att.url})`; + if (att.date) { + result += ` (${new Date(att.date).toISOString()})`; + } + result += '\n'; + } + return `${result}\n`; +} + +function formatComments(comments: Comment[]): string { + if (comments.length === 0) return '## Comments\n\n(No comments)\n\n'; + let result = `## Comments (${comments.length})\n\n`; + for (const comment of comments.slice().reverse()) { + const date = new Date(comment.date).toISOString(); + result += `### ${comment.author.name} (${date})\n\n`; + result += `${comment.text}\n\n`; + } + return result; +} + export async function readWorkItem(workItemId: string, includeComments = true): Promise { try { const provider = getPMProvider(); @@ -10,47 +83,13 @@ export async function readWorkItem(workItemId: string, includeComments = true): ]); let result = `# ${item.title}\n\n**URL:** ${item.url}\n\n## Description\n\n${item.description || '(No description)'}\n\n`; - - if (item.labels.length > 0) { - result += `## Labels\n\n${item.labels.map((l) => `- ${l.name}${l.color ? ` (${l.color})` : ''}`).join('\n')}\n\n`; - } - - if (checklists.length > 0) { - result += '## Checklists\n\n'; - for (const checklist of checklists) { - result += `### ${checklist.name} [checklistId: ${checklist.id}]\n\n`; - for (const item of checklist.items) { - const checkbox = item.complete ? '[x]' : '[ ]'; - result += `- ${checkbox} ${item.name} [checkItemId: ${item.id}]\n`; - } - result += '\n'; - } - } - - if (attachments.length > 0) { - result += '## Attachments\n\n'; - for (const att of attachments) { - result += `- [${att.name}](${att.url})`; - if (att.date) { - result += ` (${new Date(att.date).toISOString()})`; - } - result += '\n'; - } - result += '\n'; - } + result += formatLabels(item.labels); + result += formatChecklists(checklists); + result += formatAttachments(attachments); if (includeComments) { const comments = await provider.getWorkItemComments(workItemId); - if (comments.length === 0) { - result += '## Comments\n\n(No comments)\n\n'; - } else { - result += `## Comments (${comments.length})\n\n`; - for (const comment of comments.slice().reverse()) { - const date = new Date(comment.date).toISOString(); - result += `### ${comment.author.name} (${date})\n\n`; - result += `${comment.text}\n\n`; - } - } + result += formatComments(comments); } return result; diff --git a/src/pm/jira/adf.ts b/src/pm/jira/adf.ts index 8f8107a4..7ff76901 100644 --- a/src/pm/jira/adf.ts +++ b/src/pm/jira/adf.ts @@ -9,6 +9,41 @@ * Convert a simple markdown string to ADF document. * Handles paragraphs, headings, bullet lists, bold, inline code, and code blocks. */ +function parseCodeBlock(lines: string[], startIndex: number): { node: unknown; nextIndex: number } { + const lang = lines[startIndex].slice(3).trim(); + const codeLines: string[] = []; + let i = startIndex + 1; + while (i < lines.length && !lines[i].startsWith('```')) { + codeLines.push(lines[i]); + i++; + } + return { + node: { + type: 'codeBlock', + attrs: lang ? { language: lang } : {}, + content: [{ type: 'text', text: codeLines.join('\n') }], + }, + nextIndex: i + 1, // skip closing ``` + }; +} + +function parseBulletList( + lines: string[], + startIndex: number, +): { node: unknown; nextIndex: number } { + const items: unknown[] = []; + let i = startIndex; + while (i < lines.length && lines[i].match(/^[-*]\s+/)) { + const itemText = lines[i].replace(/^[-*]\s+/, ''); + items.push({ + type: 'listItem', + content: [{ type: 'paragraph', content: inlineToAdf(itemText) }], + }); + i++; + } + return { node: { type: 'bulletList', content: items }, nextIndex: i }; +} + export function markdownToAdf(markdown: string): unknown { const lines = markdown.split('\n'); const content: unknown[] = []; @@ -17,25 +52,13 @@ export function markdownToAdf(markdown: string): unknown { while (i < lines.length) { const line = lines[i]; - // Code block if (line.startsWith('```')) { - const lang = line.slice(3).trim(); - const codeLines: string[] = []; - i++; - while (i < lines.length && !lines[i].startsWith('```')) { - codeLines.push(lines[i]); - i++; - } - i++; // skip closing ``` - content.push({ - type: 'codeBlock', - attrs: lang ? { language: lang } : {}, - content: [{ type: 'text', text: codeLines.join('\n') }], - }); + const result = parseCodeBlock(lines, i); + content.push(result.node); + i = result.nextIndex; continue; } - // Heading const headingMatch = line.match(/^(#{1,6})\s+(.+)/); if (headingMatch) { content.push({ @@ -47,28 +70,18 @@ export function markdownToAdf(markdown: string): unknown { continue; } - // Bullet list if (line.match(/^[-*]\s+/)) { - const items: unknown[] = []; - while (i < lines.length && lines[i].match(/^[-*]\s+/)) { - const itemText = lines[i].replace(/^[-*]\s+/, ''); - items.push({ - type: 'listItem', - content: [{ type: 'paragraph', content: inlineToAdf(itemText) }], - }); - i++; - } - content.push({ type: 'bulletList', content: items }); + const result = parseBulletList(lines, i); + content.push(result.node); + i = result.nextIndex; continue; } - // Empty line if (line.trim() === '') { i++; continue; } - // Regular paragraph content.push({ type: 'paragraph', content: inlineToAdf(line), @@ -128,63 +141,45 @@ function inlineToAdf(text: string): unknown[] { * Convert ADF document to plain text. * Used when reading JIRA issue descriptions/comments. */ +interface AdfNode { + type?: string; + content?: unknown[]; + text?: string; + attrs?: Record; +} + +function convertAdfNode(n: AdfNode): string[] { + switch (n.type) { + case 'paragraph': + return [adfToPlainText(n), '']; + case 'heading': { + const level = (n.attrs?.level as number) ?? 1; + return [`${'#'.repeat(level)} ${adfToPlainText(n)}`, '']; + } + case 'bulletList': + return [...(n.content ?? []).map((item) => `- ${adfToPlainText(item)}`), '']; + case 'listItem': + return [adfToPlainText(n)]; + case 'codeBlock': + return ['```', adfToPlainText(n), '```', '']; + case 'text': + return [n.text ?? '']; + default: + return [adfToPlainText(n)]; + } +} + export function adfToPlainText(adf: unknown): string { if (!adf || typeof adf !== 'object') return ''; - const doc = adf as { type?: string; content?: unknown[]; text?: string }; - - if (doc.type === 'text') { - return doc.text ?? ''; - } + const doc = adf as AdfNode; - if (!doc.content || !Array.isArray(doc.content)) { - return doc.text ?? ''; - } + if (doc.type === 'text') return doc.text ?? ''; + if (!doc.content || !Array.isArray(doc.content)) return doc.text ?? ''; const parts: string[] = []; for (const node of doc.content) { - const n = node as { - type?: string; - content?: unknown[]; - text?: string; - attrs?: Record; - }; - - switch (n.type) { - case 'paragraph': - parts.push(adfToPlainText(n)); - parts.push(''); - break; - case 'heading': { - const level = (n.attrs?.level as number) ?? 1; - parts.push(`${'#'.repeat(level)} ${adfToPlainText(n)}`); - parts.push(''); - break; - } - case 'bulletList': - if (n.content) { - for (const item of n.content) { - parts.push(`- ${adfToPlainText(item)}`); - } - } - parts.push(''); - break; - case 'listItem': - parts.push(adfToPlainText(n)); - break; - case 'codeBlock': - parts.push('```'); - parts.push(adfToPlainText(n)); - parts.push('```'); - parts.push(''); - break; - case 'text': - parts.push(n.text ?? ''); - break; - default: - parts.push(adfToPlainText(n)); - break; - } + parts.push(...convertAdfNode(node as AdfNode)); } return parts diff --git a/src/triggers/shared/debug-runner.ts b/src/triggers/shared/debug-runner.ts index 575456ef..07e5a84e 100644 --- a/src/triggers/shared/debug-runner.ts +++ b/src/triggers/shared/debug-runner.ts @@ -104,6 +104,35 @@ function parseDebugOutput(output: string): { * 5. Post summary comment on original Trello card * 6. Cleanup temp directory */ +function resolveCardUrl(cardId: string): string { + try { + const provider = getPMProvider(); + return provider.getWorkItemUrl(cardId); + } catch { + return `https://trello.com/c/${cardId}`; + } +} + +async function postDebugComment( + cardId: string, + analyzedRunId: string, + parsed: ReturnType, +): Promise { + try { + const provider = getPMProvider(); + const rootCauseText = parsed.rootCause + ? `**Root Cause:** ${parsed.rootCause.slice(0, 200)}\n\n` + : ''; + const comment = `🔍 **Debug Analysis** (run: ${analyzedRunId.slice(0, 8)})\n\n${parsed.summary}\n\n${rootCauseText}_Full analysis stored in database._`; + await provider.addComment(cardId, comment); + } catch (err) { + logger.warn('Failed to post debug summary comment', { + cardId, + error: String(err), + }); + } +} + export async function triggerDebugAnalysis( analyzedRunId: string, project: ProjectConfig, @@ -126,22 +155,11 @@ export async function triggerDebugAnalysis( try { logDir = await extractLogsToTempDir(analyzedRunId); - const originalCardName = cardId ? `Card ${cardId}` : 'Unknown card'; - let originalCardUrl = ''; - if (cardId) { - try { - const provider = getPMProvider(); - originalCardUrl = provider.getWorkItemUrl(cardId); - } catch { - originalCardUrl = `https://trello.com/c/${cardId}`; - } - } - const agentResult: AgentResult = await runAgent('debug', { logDir, originalCardId: cardId, - originalCardName, - originalCardUrl, + originalCardName: cardId ? `Card ${cardId}` : 'Unknown card', + originalCardUrl: cardId ? resolveCardUrl(cardId) : '', detectedAgentType: run.agentType, project, config, @@ -160,21 +178,8 @@ export async function triggerDebugAnalysis( severity: run.status === 'timed_out' ? 'timeout' : 'failure', }); - // Post summary comment on original work item if (cardId && parsed.summary) { - try { - const provider = getPMProvider(); - const rootCauseText = parsed.rootCause - ? `**Root Cause:** ${parsed.rootCause.slice(0, 200)}\n\n` - : ''; - const comment = `🔍 **Debug Analysis** (run: ${analyzedRunId.slice(0, 8)})\n\n${parsed.summary}\n\n${rootCauseText}_Full analysis stored in database._`; - await provider.addComment(cardId, comment); - } catch (err) { - logger.warn('Failed to post debug summary comment', { - cardId, - error: String(err), - }); - } + await postDebugComment(cardId, analyzedRunId, parsed); } logger.info('Debug analysis completed', { diff --git a/src/triggers/trello/webhook-handler.ts b/src/triggers/trello/webhook-handler.ts index 0d15b25d..8742ff11 100644 --- a/src/triggers/trello/webhook-handler.ts +++ b/src/triggers/trello/webhook-handler.ts @@ -179,6 +179,50 @@ function tryQueueWebhook(payload: TrelloWebhookPayload): boolean { return true; } +async function handleMatchedTrigger( + registry: TriggerRegistry, + payload: TrelloWebhookPayload, + actionType: string | undefined, + project: ProjectConfig, + config: CascadeConfig, + pmProvider: ReturnType, +): Promise { + const ctx: TriggerContext = { project, source: 'trello', payload }; + const result = await registry.dispatch(ctx); + if (!result) { + logger.info('No trigger matched for webhook', { actionType }); + return; + } + + const cardId = result.cardId ?? result.workItemId; + if (cardId && isCardActive(cardId)) { + logger.info('Card already being processed, skipping', { cardId }); + return; + } + + logger.info('Trigger matched', { agentType: result.agentType, cardId }); + setProcessing(true); + startWatchdog(config.defaults.watchdogTimeoutMs); + + const pmConfig = resolveProjectPMConfig(project); + const lifecycle = new PMLifecycleManager(pmProvider, pmConfig); + + try { + await executeAgent(result, project, config); + } catch (err) { + logger.error('Failed to process webhook', { error: String(err) }); + if (cardId) { + await lifecycle.handleError(cardId, String(err)); + } + } finally { + if (cardId) { + clearCardActive(cardId); + } + setProcessing(false); + processNextQueuedWebhook(registry); + } +} + export async function processTrelloWebhook( payload: unknown, registry: TriggerRegistry, @@ -214,41 +258,8 @@ export async function processTrelloWebhook( const pmProvider = createPMProvider(project); await withTrelloCredentials({ apiKey: trelloApiKey, token: trelloToken }, () => - withPMProvider(pmProvider, async () => { - const ctx: TriggerContext = { project, source: 'trello', payload }; - const result = await registry.dispatch(ctx); - if (!result) { - logger.info('No trigger matched for webhook', { actionType }); - return; - } - - const cardId = result.cardId ?? result.workItemId; - if (cardId && isCardActive(cardId)) { - logger.info('Card already being processed, skipping', { cardId }); - return; - } - - logger.info('Trigger matched', { agentType: result.agentType, cardId }); - setProcessing(true); - startWatchdog(config.defaults.watchdogTimeoutMs); - - const pmConfig = resolveProjectPMConfig(project); - const lifecycle = new PMLifecycleManager(pmProvider, pmConfig); - - try { - await executeAgent(result, project, config); - } catch (err) { - logger.error('Failed to process webhook', { error: String(err) }); - if (cardId) { - await lifecycle.handleError(cardId, String(err)); - } - } finally { - if (cardId) { - clearCardActive(cardId); - } - setProcessing(false); - processNextQueuedWebhook(registry); - } - }), + withPMProvider(pmProvider, () => + handleMatchedTrigger(registry, payload, actionType, project, config, pmProvider), + ), ); } diff --git a/tests/unit/agents/prompts.test.ts b/tests/unit/agents/prompts.test.ts index 195156b9..b029897e 100644 --- a/tests/unit/agents/prompts.test.ts +++ b/tests/unit/agents/prompts.test.ts @@ -1,5 +1,15 @@ import { describe, expect, it } from 'vitest'; -import { getSystemPrompt } from '../../../src/agents/prompts/index.js'; +import { + getAvailablePartialNames, + getRawPartial, + getRawTemplate, + getSystemPrompt, + getTemplateVariables, + getValidAgentTypes, + renderCustomPrompt, + resolveIncludes, + validateTemplate, +} from '../../../src/agents/prompts/index.js'; describe('getSystemPrompt', () => { it('returns briefing prompt for briefing agent', () => { @@ -38,6 +48,14 @@ describe('getSystemPrompt', () => { expect(prompt).toContain('STORIES_LIST_ID: NOT_CONFIGURED'); expect(prompt).toContain('PROCESSED_LABEL_ID: NOT_CONFIGURED'); }); + + it('applies DB partials when provided', () => { + const partials = new Map([['git', '## Custom Git Instructions\nUse rebase workflow.']]); + const prompt = getSystemPrompt('implementation', {}, partials); + // The custom partial content should be present instead of disk default + expect(prompt).toContain('Custom Git Instructions'); + expect(prompt).toContain('Use rebase workflow'); + }); }); describe('system prompts content', () => { @@ -60,3 +78,195 @@ describe('system prompts content', () => { expect(prompt).toContain('conventional commits'); }); }); + +describe('resolveIncludes', () => { + it('resolves include from DB partials', () => { + const template = 'Before <%~ include("partials/git") %> After'; + const dbPartials = new Map([['git', 'DB GIT CONTENT']]); + const result = resolveIncludes(template, dbPartials); + expect(result).toBe('Before DB GIT CONTENT After'); + }); + + it('falls back to disk when partial not in DB', () => { + const template = '<%~ include("partials/git") %>'; + const result = resolveIncludes(template, new Map()); + // Should resolve from disk — the git partial exists on disk + expect(result).toBeTruthy(); + expect(result).not.toContain('include('); + }); + + it('throws when partial not found in DB or disk', () => { + const template = '<%~ include("partials/nonexistent-partial-xyz") %>'; + expect(() => resolveIncludes(template, new Map())).toThrow( + 'Partial not found: partials/nonexistent-partial-xyz', + ); + }); + + it('resolves multiple includes', () => { + const template = 'A <%~ include("partials/one") %> B <%~ include("partials/two") %> C'; + const dbPartials = new Map([ + ['one', 'FIRST'], + ['two', 'SECOND'], + ]); + const result = resolveIncludes(template, dbPartials); + expect(result).toBe('A FIRST B SECOND C'); + }); + + it('returns template unchanged when no includes', () => { + const template = 'No includes here, just plain text.'; + const result = resolveIncludes(template, new Map()); + expect(result).toBe(template); + }); + + it('prefers DB partial over disk', () => { + const template = '<%~ include("partials/git") %>'; + const dbPartials = new Map([['git', 'OVERRIDE']]); + const result = resolveIncludes(template, dbPartials); + expect(result).toBe('OVERRIDE'); + }); +}); + +describe('renderCustomPrompt', () => { + it('renders Eta variables', () => { + const template = 'Branch: <%= it.baseBranch %>'; + const result = renderCustomPrompt(template, { baseBranch: 'main' }); + expect(result).toBe('Branch: main'); + }); + + it('resolves includes and renders variables', () => { + const template = 'Branch: <%= it.baseBranch %>\n<%~ include("partials/custom") %>'; + const dbPartials = new Map([['custom', 'Project: <%= it.projectId %>']]); + const result = renderCustomPrompt(template, { baseBranch: 'dev', projectId: 'p1' }, dbPartials); + expect(result).toContain('Branch: dev'); + expect(result).toContain('Project: p1'); + }); + + it('handles empty context', () => { + const template = 'Hello world'; + const result = renderCustomPrompt(template); + expect(result).toBe('Hello world'); + }); + + it('renders undefined variables as "undefined"', () => { + const template = 'Value: [<%= it.baseBranch %>]'; + const result = renderCustomPrompt(template, {}); + // Eta renders undefined context values as the literal string "undefined" + expect(result).toBe('Value: [undefined]'); + }); +}); + +describe('validateTemplate', () => { + it('returns valid for correct Eta syntax', () => { + const result = validateTemplate('Hello <%= it.baseBranch %>'); + expect(result).toEqual({ valid: true }); + }); + + it('returns valid for template with includes (DB partials)', () => { + const dbPartials = new Map([['test', 'Partial content']]); + const result = validateTemplate('<%~ include("partials/test") %>', dbPartials); + expect(result).toEqual({ valid: true }); + }); + + it('returns invalid for broken Eta syntax', () => { + const result = validateTemplate('<% if (true) { %>'); + expect(result.valid).toBe(false); + expect('error' in result && result.error).toBeTruthy(); + }); + + it('returns invalid for missing partial', () => { + const result = validateTemplate('<%~ include("partials/does-not-exist-xyz") %>'); + expect(result.valid).toBe(false); + }); +}); + +describe('getRawTemplate', () => { + it('returns raw .eta template content', () => { + const raw = getRawTemplate('briefing'); + expect(raw).toContain('<%'); + expect(raw).toBeTruthy(); + }); + + it('throws for unknown agent type', () => { + expect(() => getRawTemplate('unknown-type')).toThrow('Unknown agent type: unknown-type'); + }); +}); + +describe('getRawPartial', () => { + it('returns raw partial content from disk', () => { + const content = getRawPartial('git'); + expect(content).toBeTruthy(); + expect(typeof content).toBe('string'); + }); + + it('throws for nonexistent partial', () => { + expect(() => getRawPartial('nonexistent-xyz')).toThrow(); + }); +}); + +describe('getValidAgentTypes', () => { + it('returns an array of agent type strings', () => { + const types = getValidAgentTypes(); + expect(Array.isArray(types)).toBe(true); + expect(types.length).toBeGreaterThan(0); + expect(types).toContain('briefing'); + expect(types).toContain('implementation'); + expect(types).toContain('review'); + }); + + it('returns a copy (not the original array)', () => { + const a = getValidAgentTypes(); + const b = getValidAgentTypes(); + expect(a).not.toBe(b); + expect(a).toEqual(b); + }); +}); + +describe('getAvailablePartialNames', () => { + it('returns an array of partial names', () => { + const names = getAvailablePartialNames(); + expect(Array.isArray(names)).toBe(true); + expect(names.length).toBeGreaterThan(0); + expect(names).toContain('git'); + }); + + it('returns names sorted alphabetically', () => { + const names = getAvailablePartialNames(); + const sorted = [...names].sort(); + expect(names).toEqual(sorted); + }); + + it('returns names without .eta extension', () => { + const names = getAvailablePartialNames(); + for (const name of names) { + expect(name).not.toContain('.eta'); + } + }); +}); + +describe('getTemplateVariables', () => { + it('returns an array of variable definitions', () => { + const vars = getTemplateVariables(); + expect(Array.isArray(vars)).toBe(true); + expect(vars.length).toBeGreaterThan(0); + }); + + it('each variable has name, group, and description', () => { + const vars = getTemplateVariables(); + for (const v of vars) { + expect(v).toHaveProperty('name'); + expect(v).toHaveProperty('group'); + expect(v).toHaveProperty('description'); + expect(typeof v.name).toBe('string'); + expect(typeof v.group).toBe('string'); + expect(typeof v.description).toBe('string'); + } + }); + + it('includes common variables', () => { + const vars = getTemplateVariables(); + const names = vars.map((v) => v.name); + expect(names).toContain('baseBranch'); + expect(names).toContain('cardId'); + expect(names).toContain('projectId'); + }); +}); diff --git a/tests/unit/agents/shared/modelResolution.test.ts b/tests/unit/agents/shared/modelResolution.test.ts new file mode 100644 index 00000000..bcdcc479 --- /dev/null +++ b/tests/unit/agents/shared/modelResolution.test.ts @@ -0,0 +1,245 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CascadeConfig, ProjectConfig } from '../../../../src/types/index.js'; + +// Mock readContextFiles +vi.mock('../../../../src/agents/utils/setup.js', () => ({ + readContextFiles: vi.fn().mockResolvedValue([]), +})); + +import { resolveModelConfig } from '../../../../src/agents/shared/modelResolution.js'; + +function makeProject(overrides: Partial = {}): ProjectConfig { + return { + id: 'test-proj', + orgId: 'org-1', + name: 'Test', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + pm: { type: 'trello' }, + ...overrides, + } as ProjectConfig; +} + +function makeConfig(overrides: Partial = {}): CascadeConfig { + return { + defaults: { + model: 'default-model', + agentModels: {}, + maxIterations: 50, + agentIterations: {}, + watchdogTimeoutMs: 1800000, + cardBudgetUsd: 5, + agentBackend: 'llmist', + progressModel: 'progress-model', + progressIntervalMinutes: 5, + prompts: {}, + ...overrides, + }, + projects: [], + }; +} + +describe('resolveModelConfig', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('prompt resolution chain', () => { + it('uses .eta file when no custom prompts configured', async () => { + const result = await resolveModelConfig({ + agentType: 'briefing', + project: makeProject(), + config: makeConfig(), + repoDir: '/tmp/test', + }); + + expect(result.systemPrompt).toContain('product manager'); + expect(result.systemPrompt).toContain('DO NOT IMPLEMENT'); + }); + + it('uses project prompt when configured', async () => { + const project = makeProject({ + prompts: { briefing: 'You are a custom briefing agent for <%= it.baseBranch %>.' }, + }); + + const result = await resolveModelConfig({ + agentType: 'briefing', + project, + config: makeConfig(), + repoDir: '/tmp/test', + promptContext: { baseBranch: 'develop' }, + }); + + expect(result.systemPrompt).toBe('You are a custom briefing agent for develop.'); + }); + + it('uses defaults prompt when no project prompt', async () => { + const config = makeConfig({ + prompts: { briefing: 'Global custom briefing for <%= it.projectId %>.' }, + }); + + const result = await resolveModelConfig({ + agentType: 'briefing', + project: makeProject(), + config, + repoDir: '/tmp/test', + promptContext: { projectId: 'p1' }, + }); + + expect(result.systemPrompt).toBe('Global custom briefing for p1.'); + }); + + it('prefers project prompt over defaults prompt', async () => { + const project = makeProject({ + prompts: { briefing: 'Project-level prompt.' }, + }); + const config = makeConfig({ + prompts: { briefing: 'Defaults-level prompt.' }, + }); + + const result = await resolveModelConfig({ + agentType: 'briefing', + project, + config, + repoDir: '/tmp/test', + }); + + expect(result.systemPrompt).toBe('Project-level prompt.'); + }); + + it('falls back to .eta when agent type has no custom prompt', async () => { + const config = makeConfig({ + prompts: { planning: 'Only planning has a custom prompt.' }, + }); + + const result = await resolveModelConfig({ + agentType: 'briefing', + project: makeProject(), + config, + repoDir: '/tmp/test', + }); + + // Should fall back to .eta file for briefing + expect(result.systemPrompt).toContain('product manager'); + }); + + it('resolves includes in custom prompts via dbPartials', async () => { + const project = makeProject({ + prompts: { briefing: 'Custom: <%~ include("partials/custom") %>' }, + }); + const dbPartials = new Map([['custom', 'Injected partial content']]); + + const result = await resolveModelConfig({ + agentType: 'briefing', + project, + config: makeConfig(), + repoDir: '/tmp/test', + dbPartials, + }); + + expect(result.systemPrompt).toContain('Injected partial content'); + }); + + it('passes dbPartials to .eta file rendering', async () => { + const dbPartials = new Map([['git', 'Custom git instructions from DB']]); + + const result = await resolveModelConfig({ + agentType: 'implementation', + project: makeProject(), + config: makeConfig(), + repoDir: '/tmp/test', + dbPartials, + }); + + expect(result.systemPrompt).toContain('Custom git instructions from DB'); + }); + }); + + describe('model resolution', () => { + it('uses default model when no overrides', async () => { + const result = await resolveModelConfig({ + agentType: 'briefing', + project: makeProject(), + config: makeConfig({ model: 'my-default' }), + repoDir: '/tmp/test', + }); + + expect(result.model).toBe('my-default'); + }); + + it('prefers modelOverride over project and default', async () => { + const project = makeProject({ model: 'project-model' }); + + const result = await resolveModelConfig({ + agentType: 'briefing', + project, + config: makeConfig({ model: 'default-model' }), + repoDir: '/tmp/test', + modelOverride: 'override-model', + }); + + expect(result.model).toBe('override-model'); + }); + + it('uses agent-specific model from project', async () => { + const project = makeProject({ + agentModels: { briefing: 'agent-specific-model' }, + }); + + const result = await resolveModelConfig({ + agentType: 'briefing', + project, + config: makeConfig(), + repoDir: '/tmp/test', + }); + + expect(result.model).toBe('agent-specific-model'); + }); + + it('uses configKey for model lookup when provided', async () => { + const config = makeConfig({ + agentModels: { review: 'review-model' }, + }); + + const result = await resolveModelConfig({ + agentType: 'respond-to-review', + project: makeProject(), + config, + repoDir: '/tmp/test', + configKey: 'review', + }); + + expect(result.model).toBe('review-model'); + }); + }); + + describe('iterations resolution', () => { + it('uses default maxIterations', async () => { + const result = await resolveModelConfig({ + agentType: 'briefing', + project: makeProject(), + config: makeConfig({ maxIterations: 42 }), + repoDir: '/tmp/test', + }); + + expect(result.maxIterations).toBe(42); + }); + + it('uses agent-specific iterations', async () => { + const config = makeConfig({ + agentIterations: { briefing: 10 }, + maxIterations: 50, + }); + + const result = await resolveModelConfig({ + agentType: 'briefing', + project: makeProject(), + config, + repoDir: '/tmp/test', + }); + + expect(result.maxIterations).toBe(10); + }); + }); +}); diff --git a/tests/unit/api/routers/prompts.test.ts b/tests/unit/api/routers/prompts.test.ts new file mode 100644 index 00000000..5c4c0cb0 --- /dev/null +++ b/tests/unit/api/routers/prompts.test.ts @@ -0,0 +1,272 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TRPCContext } from '../../../../src/api/trpc.js'; + +// Mock prompt functions +const mockGetValidAgentTypes = vi.fn(); +const mockGetRawTemplate = vi.fn(); +const mockGetTemplateVariables = vi.fn(); +const mockValidateTemplate = vi.fn(); +const mockGetAvailablePartialNames = vi.fn(); +const mockGetRawPartial = vi.fn(); + +vi.mock('../../../../src/agents/prompts/index.js', () => ({ + getValidAgentTypes: (...args: unknown[]) => mockGetValidAgentTypes(...args), + getRawTemplate: (...args: unknown[]) => mockGetRawTemplate(...args), + getTemplateVariables: (...args: unknown[]) => mockGetTemplateVariables(...args), + validateTemplate: (...args: unknown[]) => mockValidateTemplate(...args), + getAvailablePartialNames: (...args: unknown[]) => mockGetAvailablePartialNames(...args), + getRawPartial: (...args: unknown[]) => mockGetRawPartial(...args), +})); + +// Mock partials repository +const mockLoadPartials = vi.fn(); +const mockListPartials = vi.fn(); +const mockGetPartial = vi.fn(); +const mockUpsertPartial = vi.fn(); +const mockDeletePartial = vi.fn(); + +vi.mock('../../../../src/db/repositories/partialsRepository.js', () => ({ + loadPartials: (...args: unknown[]) => mockLoadPartials(...args), + listPartials: (...args: unknown[]) => mockListPartials(...args), + getPartial: (...args: unknown[]) => mockGetPartial(...args), + upsertPartial: (...args: unknown[]) => mockUpsertPartial(...args), + deletePartial: (...args: unknown[]) => mockDeletePartial(...args), +})); + +import { promptsRouter } from '../../../../src/api/routers/prompts.js'; + +function createCaller(ctx: TRPCContext) { + return promptsRouter.createCaller(ctx); +} + +const mockUser = { + id: 'user-1', + orgId: 'org-1', + email: 'test@example.com', + name: 'Test', + role: 'admin', +}; + +describe('promptsRouter', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('agentTypes', () => { + it('returns list of agent types', async () => { + const types = ['briefing', 'planning', 'implementation']; + mockGetValidAgentTypes.mockReturnValue(types); + const caller = createCaller({ user: mockUser }); + + const result = await caller.agentTypes(); + + expect(result).toEqual(types); + expect(mockGetValidAgentTypes).toHaveBeenCalled(); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null }); + await expect(caller.agentTypes()).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); + }); + + describe('getDefault', () => { + it('returns raw template for valid agent type', async () => { + mockGetRawTemplate.mockReturnValue('Template content: <%= it.baseBranch %>'); + const caller = createCaller({ user: mockUser }); + + const result = await caller.getDefault({ agentType: 'briefing' }); + + expect(result).toEqual({ content: 'Template content: <%= it.baseBranch %>' }); + expect(mockGetRawTemplate).toHaveBeenCalledWith('briefing'); + }); + + it('throws NOT_FOUND for unknown agent type', async () => { + mockGetRawTemplate.mockImplementation(() => { + throw new Error('Unknown'); + }); + const caller = createCaller({ user: mockUser }); + + await expect(caller.getDefault({ agentType: 'unknown' })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null }); + await expect(caller.getDefault({ agentType: 'briefing' })).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + }); + + describe('variables', () => { + it('returns template variables', async () => { + const vars = [{ name: 'baseBranch', group: 'Common', description: 'Base branch' }]; + mockGetTemplateVariables.mockReturnValue(vars); + const caller = createCaller({ user: mockUser }); + + const result = await caller.variables(); + + expect(result).toEqual(vars); + }); + }); + + describe('validate', () => { + it('returns valid for correct template', async () => { + mockLoadPartials.mockResolvedValue(new Map()); + mockValidateTemplate.mockReturnValue({ valid: true }); + const caller = createCaller({ user: mockUser }); + + const result = await caller.validate({ template: 'Hello <%= it.name %>' }); + + expect(result).toEqual({ valid: true }); + expect(mockLoadPartials).toHaveBeenCalled(); + }); + + it('returns invalid with error for bad template', async () => { + mockLoadPartials.mockResolvedValue(new Map()); + mockValidateTemplate.mockReturnValue({ valid: false, error: 'Syntax error' }); + const caller = createCaller({ user: mockUser }); + + const result = await caller.validate({ template: '<% broken' }); + + expect(result).toEqual({ valid: false, error: 'Syntax error' }); + }); + }); + + describe('listPartials', () => { + it('merges DB and disk partials', async () => { + mockListPartials.mockResolvedValue([ + { id: 1, name: 'git', content: 'DB git\ncontent', orgId: null }, + ]); + mockGetAvailablePartialNames.mockReturnValue(['git', 'tmux']); + mockGetRawPartial.mockImplementation((name: string) => { + if (name === 'tmux') return 'Tmux content\nline 2\nline 3'; + throw new Error('Not found'); + }); + const caller = createCaller({ user: mockUser }); + + const result = await caller.listPartials(); + + expect(result).toEqual([ + { name: 'git', source: 'db', lines: 2, id: 1 }, + { name: 'tmux', source: 'disk', lines: 3 }, + ]); + }); + + it('includes DB-only partials not on disk', async () => { + mockListPartials.mockResolvedValue([ + { id: 5, name: 'custom-partial', content: 'Custom\ncontent', orgId: null }, + ]); + mockGetAvailablePartialNames.mockReturnValue([]); + const caller = createCaller({ user: mockUser }); + + const result = await caller.listPartials(); + + expect(result).toEqual([{ name: 'custom-partial', source: 'db', lines: 2, id: 5 }]); + }); + }); + + describe('getPartial', () => { + it('returns DB partial when available', async () => { + mockGetPartial.mockResolvedValue({ id: 1, name: 'git', content: 'DB content' }); + const caller = createCaller({ user: mockUser }); + + const result = await caller.getPartial({ name: 'git' }); + + expect(result).toEqual({ name: 'git', content: 'DB content', source: 'db', id: 1 }); + }); + + it('falls back to disk when no DB partial', async () => { + mockGetPartial.mockResolvedValue(null); + mockGetRawPartial.mockReturnValue('Disk content'); + const caller = createCaller({ user: mockUser }); + + const result = await caller.getPartial({ name: 'git' }); + + expect(result).toEqual({ name: 'git', content: 'Disk content', source: 'disk' }); + }); + + it('throws NOT_FOUND when partial not in DB or disk', async () => { + mockGetPartial.mockResolvedValue(null); + mockGetRawPartial.mockImplementation(() => { + throw new Error('Not found'); + }); + const caller = createCaller({ user: mockUser }); + + await expect(caller.getPartial({ name: 'nonexistent' })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + }); + + describe('getDefaultPartial', () => { + it('returns disk partial content', async () => { + mockGetRawPartial.mockReturnValue('Default content'); + const caller = createCaller({ user: mockUser }); + + const result = await caller.getDefaultPartial({ name: 'git' }); + + expect(result).toEqual({ content: 'Default content' }); + }); + + it('throws NOT_FOUND when no disk partial', async () => { + mockGetRawPartial.mockImplementation(() => { + throw new Error('Not found'); + }); + const caller = createCaller({ user: mockUser }); + + await expect(caller.getDefaultPartial({ name: 'nonexistent' })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + }); + + describe('upsertPartial', () => { + it('upserts valid partial content', async () => { + mockLoadPartials.mockResolvedValue(new Map()); + mockValidateTemplate.mockReturnValue({ valid: true }); + mockUpsertPartial.mockResolvedValue({ + id: 1, + name: 'git', + content: 'New content', + orgId: null, + }); + const caller = createCaller({ user: mockUser }); + + const result = await caller.upsertPartial({ name: 'git', content: 'New content' }); + + expect(result).toMatchObject({ name: 'git', content: 'New content' }); + expect(mockUpsertPartial).toHaveBeenCalledWith({ name: 'git', content: 'New content' }); + }); + + it('rejects invalid partial content', async () => { + mockLoadPartials.mockResolvedValue(new Map()); + mockValidateTemplate.mockReturnValue({ valid: false, error: 'Bad syntax' }); + const caller = createCaller({ user: mockUser }); + + await expect( + caller.upsertPartial({ name: 'git', content: '<% broken' }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + }); + }); + + describe('deletePartial', () => { + it('deletes a partial by id', async () => { + mockDeletePartial.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser }); + + await caller.deletePartial({ id: 1 }); + + expect(mockDeletePartial).toHaveBeenCalledWith(1); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null }); + await expect(caller.deletePartial({ id: 1 })).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + }); +}); diff --git a/tests/unit/pm/jira/adf.test.ts b/tests/unit/pm/jira/adf.test.ts new file mode 100644 index 00000000..28a21550 --- /dev/null +++ b/tests/unit/pm/jira/adf.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, it } from 'vitest'; +import { adfToPlainText, markdownToAdf } from '../../../../src/pm/jira/adf.js'; + +describe('markdownToAdf', () => { + it('converts a simple paragraph', () => { + const result = markdownToAdf('Hello world'); + expect(result).toEqual({ + type: 'doc', + version: 1, + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello world' }] }], + }); + }); + + it('converts headings', () => { + const result = markdownToAdf('## My Heading') as { content: unknown[] }; + expect(result.content[0]).toEqual({ + type: 'heading', + attrs: { level: 2 }, + content: [{ type: 'text', text: 'My Heading' }], + }); + }); + + it('converts different heading levels', () => { + for (let level = 1; level <= 6; level++) { + const md = `${'#'.repeat(level)} Heading ${level}`; + const result = markdownToAdf(md) as { content: Array<{ attrs: { level: number } }> }; + expect(result.content[0].attrs.level).toBe(level); + } + }); + + it('converts bullet lists', () => { + const result = markdownToAdf('- Item 1\n- Item 2\n- Item 3') as { content: unknown[] }; + const list = result.content[0] as { type: string; content: unknown[] }; + expect(list.type).toBe('bulletList'); + expect(list.content).toHaveLength(3); + }); + + it('converts bullet lists with * marker', () => { + const result = markdownToAdf('* Item 1\n* Item 2') as { content: unknown[] }; + const list = result.content[0] as { type: string; content: unknown[] }; + expect(list.type).toBe('bulletList'); + expect(list.content).toHaveLength(2); + }); + + it('converts code blocks', () => { + const md = '```typescript\nconst x = 1;\n```'; + const result = markdownToAdf(md) as { content: unknown[] }; + expect(result.content[0]).toEqual({ + type: 'codeBlock', + attrs: { language: 'typescript' }, + content: [{ type: 'text', text: 'const x = 1;' }], + }); + }); + + it('converts code blocks without language', () => { + const md = '```\nsome code\n```'; + const result = markdownToAdf(md) as { content: unknown[] }; + const block = result.content[0] as { attrs: Record }; + expect(block.attrs).toEqual({}); + }); + + it('converts bold text', () => { + const result = markdownToAdf('Hello **bold** world') as { content: unknown[] }; + const para = result.content[0] as { content: unknown[] }; + expect(para.content).toContainEqual({ + type: 'text', + text: 'bold', + marks: [{ type: 'strong' }], + }); + }); + + it('converts inline code', () => { + const result = markdownToAdf('Use `code` here') as { content: unknown[] }; + const para = result.content[0] as { content: unknown[] }; + expect(para.content).toContainEqual({ + type: 'text', + text: 'code', + marks: [{ type: 'code' }], + }); + }); + + it('skips empty lines', () => { + const result = markdownToAdf('Line 1\n\nLine 2') as { content: unknown[] }; + expect(result.content).toHaveLength(2); + expect(result.content[0]).toMatchObject({ type: 'paragraph' }); + expect(result.content[1]).toMatchObject({ type: 'paragraph' }); + }); + + it('returns empty paragraph for empty input', () => { + const result = markdownToAdf('') as { content: unknown[] }; + expect(result.content).toEqual([{ type: 'paragraph', content: [] }]); + }); + + it('handles mixed content', () => { + const md = '# Title\n\nSome text\n\n- Item 1\n- Item 2\n\n```js\nconsole.log(1);\n```'; + const result = markdownToAdf(md) as { content: Array<{ type: string }> }; + const types = result.content.map((n) => n.type); + expect(types).toEqual(['heading', 'paragraph', 'bulletList', 'codeBlock']); + }); +}); + +describe('adfToPlainText', () => { + it('converts a simple paragraph', () => { + const adf = { + type: 'doc', + version: 1, + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Hello world' }], + }, + ], + }; + expect(adfToPlainText(adf)).toBe('Hello world'); + }); + + it('converts headings', () => { + const adf = { + type: 'doc', + version: 1, + content: [ + { + type: 'heading', + attrs: { level: 2 }, + content: [{ type: 'text', text: 'Title' }], + }, + ], + }; + expect(adfToPlainText(adf)).toBe('## Title'); + }); + + it('converts bullet lists', () => { + const adf = { + type: 'doc', + version: 1, + content: [ + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item 1' }] }], + }, + { + type: 'listItem', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item 2' }] }], + }, + ], + }, + ], + }; + const result = adfToPlainText(adf); + expect(result).toContain('- Item 1'); + expect(result).toContain('- Item 2'); + }); + + it('converts code blocks', () => { + const adf = { + type: 'doc', + version: 1, + content: [ + { + type: 'codeBlock', + content: [{ type: 'text', text: 'const x = 1;' }], + }, + ], + }; + const result = adfToPlainText(adf); + expect(result).toContain('```'); + expect(result).toContain('const x = 1;'); + }); + + it('returns empty string for null/undefined', () => { + expect(adfToPlainText(null)).toBe(''); + expect(adfToPlainText(undefined)).toBe(''); + }); + + it('returns empty string for non-object', () => { + expect(adfToPlainText('string')).toBe(''); + expect(adfToPlainText(42)).toBe(''); + }); + + it('returns text from text node', () => { + expect(adfToPlainText({ type: 'text', text: 'Hello' })).toBe('Hello'); + }); + + it('returns empty string when text is missing', () => { + expect(adfToPlainText({ type: 'text' })).toBe(''); + }); + + it('handles nested content', () => { + const adf = { + type: 'doc', + version: 1, + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Hello ' }, + { type: 'text', text: 'world' }, + ], + }, + ], + }; + expect(adfToPlainText(adf)).toBe('Hello \nworld'); + }); + + it('collapses triple+ newlines', () => { + const adf = { + type: 'doc', + version: 1, + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'Line 1' }] }, + { type: 'paragraph', content: [{ type: 'text', text: 'Line 2' }] }, + ], + }; + const result = adfToPlainText(adf); + expect(result).not.toMatch(/\n{3,}/); + }); +}); + +describe('roundtrip: markdownToAdf -> adfToPlainText', () => { + it('preserves simple text', () => { + const md = 'Hello world'; + const adf = markdownToAdf(md); + const result = adfToPlainText(adf); + expect(result).toBe('Hello world'); + }); + + it('preserves headings', () => { + const md = '## My Title'; + const adf = markdownToAdf(md); + const result = adfToPlainText(adf); + expect(result).toBe('## My Title'); + }); + + it('preserves bullet list content', () => { + const md = '- First\n- Second'; + const adf = markdownToAdf(md); + const result = adfToPlainText(adf); + expect(result).toContain('First'); + expect(result).toContain('Second'); + }); + + it('preserves code block content', () => { + const md = '```\ncode here\n```'; + const adf = markdownToAdf(md); + const result = adfToPlainText(adf); + expect(result).toContain('code here'); + }); +}); diff --git a/tools/seed-prompts.ts b/tools/seed-prompts.ts new file mode 100644 index 00000000..e70261f1 --- /dev/null +++ b/tools/seed-prompts.ts @@ -0,0 +1,90 @@ +#!/usr/bin/env tsx +/** + * Seed database with prompt templates and partials from disk .eta files. + * + * Reads all .eta templates from src/agents/prompts/templates/ and inserts them + * as global agent_configs rows (prompt column). Reads all partials from + * src/agents/prompts/templates/partials/ and inserts them as prompt_partials rows. + * + * Uses upsert semantics — safe to re-run. + * + * Usage: + * npx tsx tools/seed-prompts.ts + * + * Requires DATABASE_URL to be set. + */ + +import { and, eq, isNull } from 'drizzle-orm'; +import { + getAvailablePartialNames, + getRawPartial, + getRawTemplate, + getValidAgentTypes, +} from '../src/agents/prompts/index.js'; +import { closeDb, getDb } from '../src/db/client.js'; +import { upsertPartial } from '../src/db/repositories/partialsRepository.js'; +import { agentConfigs } from '../src/db/schema/index.js'; + +async function seedTemplates() { + const db = getDb(); + const agentTypes = getValidAgentTypes(); + + console.log(`Seeding ${agentTypes.length} agent prompt templates...`); + + for (const agentType of agentTypes) { + const content = getRawTemplate(agentType); + + // Check if a global config row already exists for this agent type + const [existing] = await db + .select({ id: agentConfigs.id }) + .from(agentConfigs) + .where( + and( + eq(agentConfigs.agentType, agentType), + isNull(agentConfigs.projectId), + isNull(agentConfigs.orgId), + ), + ); + + if (existing) { + await db + .update(agentConfigs) + .set({ prompt: content, updatedAt: new Date() }) + .where(eq(agentConfigs.id, existing.id)); + console.log(` Updated: ${agentType}`); + } else { + await db.insert(agentConfigs).values({ + agentType, + prompt: content, + }); + console.log(` Created: ${agentType}`); + } + } +} + +async function seedPartials() { + const partialNames = getAvailablePartialNames(); + + console.log(`Seeding ${partialNames.length} prompt partials...`); + + for (const name of partialNames) { + const content = getRawPartial(name); + await upsertPartial({ name, content }); + console.log(` Upserted: ${name}`); + } +} + +async function main() { + try { + await seedTemplates(); + await seedPartials(); + console.log('\nDone.'); + } catch (err) { + console.error('Error seeding prompts:', err); + process.exit(1); + } finally { + await closeDb(); + } +} + +main(); diff --git a/web/src/components/layout/sidebar.tsx b/web/src/components/layout/sidebar.tsx index 6810f8e0..36a4e970 100644 --- a/web/src/components/layout/sidebar.tsx +++ b/web/src/components/layout/sidebar.tsx @@ -1,7 +1,15 @@ import { Separator } from '@/components/ui/separator.js'; import { cn } from '@/lib/utils.js'; import { Link, useRouterState } from '@tanstack/react-router'; -import { Activity, Bot, FolderGit2, KeyRound, LayoutDashboard, Settings } from 'lucide-react'; +import { + Activity, + Bot, + FileText, + FolderGit2, + KeyRound, + LayoutDashboard, + Settings, +} from 'lucide-react'; interface SidebarProps { user: { name: string; email: string } | undefined; @@ -16,6 +24,7 @@ const settingsNav = [ { to: '/settings/general' as const, label: 'General', icon: Settings }, { to: '/settings/credentials' as const, label: 'Credentials', icon: KeyRound }, { to: '/settings/agents' as const, label: 'Agent Configs', icon: Bot }, + { to: '/settings/prompts' as const, label: 'Prompts', icon: FileText }, ]; function NavLink({ diff --git a/web/src/components/projects/project-agent-configs.tsx b/web/src/components/projects/project-agent-configs.tsx index 13b8ff8c..5f981653 100644 --- a/web/src/components/projects/project-agent-configs.tsx +++ b/web/src/components/projects/project-agent-configs.tsx @@ -16,9 +16,9 @@ import { TableHeader, TableRow, } from '@/components/ui/table.js'; -import { Textarea } from '@/components/ui/textarea.js'; import { trpc, trpcClient } from '@/lib/trpc.js'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Link } from '@tanstack/react-router'; import { Pencil, Plus, Trash2 } from 'lucide-react'; import { useState } from 'react'; @@ -130,13 +130,14 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { Model Max Iterations Backend + Prompt {configs.length === 0 && ( - + No project-scoped agent configs @@ -147,6 +148,15 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { {config.model ?? '-'} {config.maxIterations ?? '-'} {config.agentBackend ?? '-'} + + {config.prompt ? ( + + custom + + ) : ( + '-' + )} +
- -