diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10ad67e4..4a3afcf6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,7 +67,7 @@ jobs: docker-build-check: name: Validate Docker builds runs-on: ubuntu-latest - needs: lint-and-test + if: github.event_name == 'push' steps: - uses: actions/checkout@v4 diff --git a/src/backends/adapter.ts b/src/backends/adapter.ts index 1fdc93de..02c9425e 100644 --- a/src/backends/adapter.ts +++ b/src/backends/adapter.ts @@ -6,22 +6,23 @@ import { resolveModelConfig } from '../agents/shared/modelResolution.js'; import { setupRepository } from '../agents/shared/repository.js'; import { createAgentLogger } from '../agents/utils/logging.js'; import { CUSTOM_MODELS } from '../config/customModels.js'; -import { getProjectSecrets } from '../config/provider.js'; +import { getAgentCredential, getProjectSecrets } from '../config/provider.js'; import { type CompleteRunInput, completeRun, createRun, storeRunLogs, } from '../db/repositories/runsRepository.js'; -import { readWorkItem } from '../gadgets/pm/core/readWorkItem.js'; +import { withGitHubToken } from '../github/client.js'; import type { AgentInput, AgentResult, CascadeConfig, ProjectConfig } from '../types/index.js'; import { loadCascadeEnv, unloadCascadeEnv } from '../utils/cascadeEnv.js'; import { cleanupLogDirectory, cleanupLogFile, createFileLogger } from '../utils/fileLogger.js'; import { clearWatchdogCleanup, setWatchdogCleanup } from '../utils/lifecycle.js'; import { logger } from '../utils/logging.js'; import { cleanupTempDir } from '../utils/repo.js'; +import { getAgentProfile } from './agent-profiles.js'; import { createProgressMonitor } from './progress.js'; -import type { AgentBackend, AgentBackendInput, ContextInjection, ToolManifest } from './types.js'; +import type { AgentBackend, AgentBackendInput, LogWriter, ToolManifest } from './types.js'; /** * Get the CLI tool manifests for CASCADE-specific tools. @@ -205,30 +206,6 @@ function getToolManifests(): ToolManifest[] { ]; } -/** - * Pre-fetch context data (card content, etc.) for injection into agent context. - */ -async function fetchContextInjections( - input: AgentInput, - log: ReturnType, -): Promise { - const injections: ContextInjection[] = []; - const cardId = input.cardId; - - if (cardId && !input.logDir) { - log.info('Fetching work item data for context injection', { cardId }); - const cardData = await readWorkItem(cardId, true); - injections.push({ - toolName: 'ReadWorkItem', - params: { workItemId: cardId, includeComments: true }, - result: cardData, - description: 'Pre-fetched work item data', - }); - } - - return injections; -} - /** * Resolve the working directory — either a pre-existing log dir or a fresh repo clone. */ @@ -249,15 +226,34 @@ async function resolveRepoDir( }); } +/** + * Create a LogWriter that writes to both the file logger and the structured logger. + */ +function createLogWriter(fileLogger: ReturnType): LogWriter { + return (level: string, message: string, context?: Record) => { + fileLogger.write(level, message, context); + const logFn = + level === 'ERROR' + ? logger.error + : level === 'WARN' + ? logger.warn + : level === 'DEBUG' + ? logger.debug + : logger.info; + logFn.call(logger, message, context); + }; +} + /** * Build the BackendInput by resolving model config, fetching context, etc. + * Uses agent profiles to customize tools, context, and prompts per agent type. */ async function buildBackendInput( agentType: string, input: AgentInput & { project: ProjectConfig; config: CascadeConfig }, repoDir: string, - fileLogger: ReturnType, - log: ReturnType, + logWriter: LogWriter, + _log: ReturnType, _backendName?: string, ): Promise> { const { project, config, cardId } = input; @@ -277,7 +273,7 @@ async function buildBackendInput( pmType, }; - const { systemPrompt, model, maxIterations } = await resolveModelConfig({ + const { systemPrompt, model, maxIterations, contextFiles } = await resolveModelConfig({ agentType, project, config, @@ -285,7 +281,15 @@ async function buildBackendInput( promptContext, }); - const contextInjections = await fetchContextInjections(input, log); + const profile = getAgentProfile(agentType); + + // Use profile to fetch agent-specific context injections + const contextInjections = await profile.fetchContext({ + input, + repoDir, + contextFiles, + logWriter, + }); const cliToolsDir = new URL('../../bin', import.meta.url).pathname; @@ -298,26 +302,17 @@ async function buildBackendInput( config, repoDir, systemPrompt, - taskPrompt: `Analyze and process the work item with ID: ${cardId || 'unknown'}. The work item data has been pre-loaded.`, + taskPrompt: profile.buildTaskPrompt(input), cliToolsDir, - availableTools: getToolManifests(), + availableTools: profile.filterTools(getToolManifests()), contextInjections, maxIterations, budgetUsd: input.remainingBudgetUsd as number | undefined, model, - logWriter: (level: string, message: string, context?: Record) => { - fileLogger.write(level, message, context); - const logFn = - level === 'ERROR' - ? logger.error - : level === 'WARN' - ? logger.warn - : level === 'DEBUG' - ? logger.debug - : logger.info; - logFn.call(logger, message, context); - }, + logWriter, agentInput: input, + sdkTools: profile.sdkTools, + enableStopHooks: profile.enableStopHooks, ...(Object.keys(projectSecrets).length > 0 && { projectSecrets }), }; } @@ -443,6 +438,24 @@ function warnIfSubscriptionCostMismatch(_backend: AgentBackend, _project: Projec // No-op: ANTHROPIC_API_KEY is no longer used. Claude Code uses OAuth only. } +/** + * Resolve the GitHub token for profiles that need GitHub client access. + * Uses agent-scoped override if available, otherwise falls back to project secrets. + */ +async function resolveGitHubToken( + profile: ReturnType, + projectId: string, + agentType: string, +): Promise { + if (!profile.needsGitHubToken) return undefined; + + const agentToken = await getAgentCredential(projectId, agentType, 'GITHUB_TOKEN'); + if (agentToken) return agentToken; + + const secrets = await getProjectSecrets(projectId); + return secrets.GITHUB_TOKEN; +} + async function finalizeBackendRun( runId: string | undefined, fileLogger: ReturnType, @@ -480,15 +493,42 @@ export async function executeWithBackend( try { repoDir = await resolveRepoDir(input, log, agentType); const envSnapshot = loadCascadeEnv(repoDir, log); + const logWriter = createLogWriter(fileLogger); + + const profile = getAgentProfile(agentType); + const gitHubToken = await resolveGitHubToken(profile, input.project.id, agentType); + + // Build backend input and run pre-execute, wrapped in GitHub token scope if needed + const resolvedRepoDir = repoDir; + const buildAndPrepare = async () => { + const partial = await buildBackendInput( + agentType, + input, + resolvedRepoDir, + logWriter, + log, + backend.name, + ); + + // Override GITHUB_TOKEN in subprocess secrets with agent-scoped token + if (gitHubToken && profile.needsGitHubToken) { + partial.projectSecrets = { + ...partial.projectSecrets, + GITHUB_TOKEN: gitHubToken, + }; + } + + // Pre-execute hook (e.g., post initial PR comment for review) + if (profile.preExecute) { + await profile.preExecute({ input, logWriter }); + } + + return partial; + }; - const partialInput = await buildBackendInput( - agentType, - input, - repoDir, - fileLogger, - log, - backend.name, - ); + const partialInput = gitHubToken + ? await withGitHubToken(gitHubToken, buildAndPrepare) + : await buildAndPrepare(); runId = await tryCreateBackendRun( agentType, @@ -499,18 +539,7 @@ export async function executeWithBackend( ); const monitor = createProgressMonitor({ - logWriter: (level: string, message: string, context?: Record) => { - fileLogger.write(level, message, context); - const logFn = - level === 'ERROR' - ? logger.error - : level === 'WARN' - ? logger.warn - : level === 'DEBUG' - ? logger.debug - : logger.info; - logFn.call(logger, message, context); - }, + logWriter, agentType, taskDescription: cardId ? `Work item ${cardId}` : 'Unknown task', progressModel: input.config.defaults.progressModel, diff --git a/src/backends/agent-profiles.ts b/src/backends/agent-profiles.ts new file mode 100644 index 00000000..8233db3f --- /dev/null +++ b/src/backends/agent-profiles.ts @@ -0,0 +1,418 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { formatPRDetails, formatPRDiff } from '../agents/shared/prFormatting.js'; +import type { ContextFile } from '../agents/utils/setup.js'; +import { REVIEW_FILE_CONTENT_TOKEN_LIMIT, estimateTokens } from '../config/reviewConfig.js'; +import { ListDirectory } from '../gadgets/ListDirectory.js'; +import { formatCheckStatus } from '../gadgets/github/core/getPRChecks.js'; +import { readWorkItem } from '../gadgets/pm/core/readWorkItem.js'; +import { type PRDiffFile, githubClient } from '../github/client.js'; +import type { AgentInput } from '../types/index.js'; +import type { ContextInjection, LogWriter, ToolManifest } from './types.js'; + +// ============================================================================ +// Tool Name Sets +// ============================================================================ + +/** PM tools available to most agents */ +const PM_TOOLS = [ + 'ReadWorkItem', + 'PostComment', + 'UpdateWorkItem', + 'CreateWorkItem', + 'ListWorkItems', + 'AddChecklist', +]; + +/** PM checklist update — excluded from planning to prevent premature completion */ +const PM_CHECKLIST_TOOL = 'UpdateChecklistItem'; + +/** GitHub review tools for code review agents */ +const GITHUB_REVIEW_TOOLS = [ + 'GetPRDetails', + 'GetPRDiff', + 'GetPRChecks', + 'GetPRComments', + 'PostPRComment', + 'UpdatePRComment', + 'ReplyToReviewComment', + 'CreatePRReview', +]; + +const SESSION_TOOL = 'Finish'; + +const ALL_SDK_TOOLS = ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep']; +const READ_ONLY_SDK_TOOLS = ['Read', 'Bash', 'Glob', 'Grep']; + +// ============================================================================ +// AgentProfile Interface +// ============================================================================ + +interface FetchContextParams { + input: AgentInput; + repoDir: string; + contextFiles: ContextFile[]; + logWriter: LogWriter; +} + +interface PreExecuteParams { + input: AgentInput; + logWriter: LogWriter; +} + +export interface AgentProfile { + /** Filter the full set of tool manifests down to what this agent needs */ + filterTools(allTools: ToolManifest[]): ToolManifest[]; + /** SDK tools for Claude Code (subset of Read, Write, Edit, Bash, Glob, Grep) */ + sdkTools: string[]; + /** Whether to enable stop hooks that check for uncommitted/unpushed changes */ + enableStopHooks: boolean; + /** Whether this profile needs the GitHub client for context fetching */ + needsGitHubToken: boolean; + /** Fetch context injections for this agent type */ + fetchContext(params: FetchContextParams): Promise; + /** Build the task prompt for this agent type */ + buildTaskPrompt(input: AgentInput): string; + /** Optional pre-execute hook (e.g., post initial PR comment) */ + preExecute?(params: PreExecuteParams): Promise; +} + +// ============================================================================ +// Context Fetching Helpers +// ============================================================================ + +function filterToolsByNames(allTools: ToolManifest[], names: string[]): ToolManifest[] { + const nameSet = new Set(names); + return allTools.filter((t) => nameSet.has(t.name)); +} + +function fetchDirectoryListing(repoDir: string): ContextInjection { + const listDirGadget = new ListDirectory(); + const params = { + comment: 'Pre-fetching codebase structure for context', + directoryPath: '.', + maxDepth: 3, + includeGitIgnored: false, + }; + + // ListDirectory uses process.cwd() — we need to be in repoDir + const originalCwd = process.cwd(); + try { + process.chdir(repoDir); + const result = listDirGadget.execute(params); + return { + toolName: 'ListDirectory', + params, + result, + description: 'Pre-fetched codebase structure', + }; + } finally { + process.chdir(originalCwd); + } +} + +function fetchContextFileInjections(contextFiles: ContextFile[]): ContextInjection[] { + return contextFiles.map((file) => ({ + toolName: 'ReadFile', + params: { comment: `Pre-fetching ${file.path} for project context`, filePath: file.path }, + result: file.content, + description: `Pre-fetched ${file.path}`, + })); +} + +function fetchSquintOverview(repoDir: string): ContextInjection | null { + const squintDb = join(repoDir, '.squint.db'); + if (!existsSync(squintDb)) return null; + + try { + const output = execFileSync('squint', ['overview', '-d', squintDb], { + encoding: 'utf-8', + timeout: 30_000, + }); + if (!output?.trim()) return null; + + return { + toolName: 'SquintOverview', + params: { comment: 'Pre-fetching Squint codebase overview for context', database: squintDb }, + result: output, + description: 'Pre-fetched Squint codebase overview', + }; + } catch { + return null; + } +} + +async function fetchWorkItemInjection(cardId: string): Promise { + try { + const cardData = await readWorkItem(cardId, true); + return { + toolName: 'ReadWorkItem', + params: { workItemId: cardId, includeComments: true }, + result: cardData, + description: 'Pre-fetched work item data', + }; + } catch { + return null; + } +} + +/** Read full contents of changed PR files up to token limit (ported from review.ts:53-79) */ +async function readPRFileContents( + repoDir: string, + prDiff: PRDiffFile[], +): Promise<{ included: Array<{ path: string; content: string }>; skipped: string[] }> { + const included: Array<{ path: string; content: string }> = []; + const skipped: string[] = []; + let totalTokens = 0; + + for (const file of prDiff) { + if (file.status === 'removed' || !file.patch) continue; + + const filePath = join(repoDir, file.filename); + try { + const content = await readFile(filePath, 'utf-8'); + const tokens = estimateTokens(content); + + if (totalTokens + tokens <= REVIEW_FILE_CONTENT_TOKEN_LIMIT) { + included.push({ path: file.filename, content }); + totalTokens += tokens; + } else { + skipped.push(file.filename); + } + } catch { + // File might not exist (renamed from), skip + } + } + + return { included, skipped }; +} + +/** Fetch PR context injections (ported from review.ts:93-144) */ +async function fetchPRContextInjections( + owner: string, + repo: string, + prNumber: number, + repoDir: string, + logWriter: LogWriter, +): Promise<{ injections: ContextInjection[]; skippedFiles: string[] }> { + const injections: ContextInjection[] = []; + + logWriter('INFO', 'Fetching PR details, diff, and check status', { owner, repo, prNumber }); + + const prDetails = await githubClient.getPR(owner, repo, prNumber); + const prDiff = await githubClient.getPRDiff(owner, repo, prNumber); + const checkStatus = await githubClient.getCheckSuiteStatus(owner, repo, prDetails.headSha); + + const prDetailsFormatted = formatPRDetails(prDetails); + const diffFormatted = formatPRDiff(prDiff); + const checkStatusFormatted = formatCheckStatus(prNumber, checkStatus); + + injections.push({ + toolName: 'GetPRDetails', + params: { comment: 'Pre-fetching PR details for review context', owner, repo, prNumber }, + result: prDetailsFormatted, + description: 'Pre-fetched PR details', + }); + + injections.push({ + toolName: 'GetPRDiff', + params: { comment: 'Pre-fetching PR diff for code review', owner, repo, prNumber }, + result: diffFormatted, + description: 'Pre-fetched PR diff', + }); + + injections.push({ + toolName: 'GetPRChecks', + params: { comment: 'Pre-fetching CI check status for review', owner, repo, prNumber }, + result: checkStatusFormatted, + description: 'Pre-fetched CI check status', + }); + + // Read full contents of changed files + logWriter('INFO', 'Reading PR file contents', { fileCount: prDiff.length }); + const fileContents = await readPRFileContents(repoDir, prDiff); + logWriter('INFO', 'File contents loaded', { + included: fileContents.included.length, + skipped: fileContents.skipped.length, + }); + + for (const file of fileContents.included) { + injections.push({ + toolName: 'ReadFile', + params: { comment: `Pre-fetching ${file.path} for review`, filePath: file.path }, + result: `path=${file.path}\n\n${file.content}`, + description: `Pre-fetched ${file.path}`, + }); + } + + return { injections, skippedFiles: fileContents.skipped }; +} + +// ============================================================================ +// Common Context Builders +// ============================================================================ + +/** Standard context for work-item-based agents: dirListing + contextFiles + squint + workItem */ +async function fetchWorkItemContext(params: FetchContextParams): Promise { + const injections: ContextInjection[] = []; + + injections.push(fetchDirectoryListing(params.repoDir)); + injections.push(...fetchContextFileInjections(params.contextFiles)); + + const squint = fetchSquintOverview(params.repoDir); + if (squint) injections.push(squint); + + if (params.input.cardId) { + const workItem = await fetchWorkItemInjection(params.input.cardId); + if (workItem) injections.push(workItem); + } + + return injections; +} + +/** PR review context: PR details + diff + checks + file contents + contextFiles + squint */ +async function fetchReviewContext(params: FetchContextParams): Promise { + const injections: ContextInjection[] = []; + + const repoFullName = params.input.repoFullName as string; + const prNumber = params.input.prNumber as number; + const [owner, repo] = repoFullName.split('/'); + + // PR context first (most relevant for review) + const { injections: prInjections } = await fetchPRContextInjections( + owner, + repo, + prNumber, + params.repoDir, + params.logWriter, + ); + injections.push(...prInjections); + + // Then context files and squint for codebase understanding + injections.push(...fetchContextFileInjections(params.contextFiles)); + + const squint = fetchSquintOverview(params.repoDir); + if (squint) injections.push(squint); + + return injections; +} + +// ============================================================================ +// Task Prompt Builders +// ============================================================================ + +function buildWorkItemTaskPrompt(input: AgentInput): string { + return `Analyze and process the work item with ID: ${input.cardId || 'unknown'}. The work item data has been pre-loaded.`; +} + +function buildCommentResponseTaskPrompt(input: AgentInput): string { + const commentText = input.triggerCommentText as string; + const commentAuthor = (input.triggerCommentAuthor as string) || 'unknown'; + return `A user (@${commentAuthor}) mentioned you in a comment on work item ${input.cardId || 'unknown'}. + +Their comment: +--- +${commentText} +--- + +The work item data (title, description, checklists, attachments, comments) has been pre-loaded above. +Read the user's comment carefully and respond accordingly. Default to surgical, targeted updates unless they clearly ask for a full rewrite.`; +} + +function buildReviewTaskPrompt(input: AgentInput): string { + const repoFullName = input.repoFullName as string; + const prNumber = input.prNumber as number; + const [owner, repo] = repoFullName.split('/'); + + return `Review PR #${prNumber} in ${owner}/${repo}. + +Examine the code changes carefully and submit your review using CreatePRReview. + +## GitHub Context + +Owner: ${owner} +Repo: ${repo} +PR Number: ${prNumber} + +Use these values when calling GitHub tools (GetPRDetails, GetPRDiff, CreatePRReview).`; +} + +// ============================================================================ +// Agent Profiles +// ============================================================================ + +const briefingProfile: AgentProfile = { + filterTools: (allTools) => + filterToolsByNames(allTools, [...PM_TOOLS, PM_CHECKLIST_TOOL, SESSION_TOOL]), + sdkTools: ALL_SDK_TOOLS, + enableStopHooks: false, + needsGitHubToken: false, + fetchContext: fetchWorkItemContext, + buildTaskPrompt: buildWorkItemTaskPrompt, +}; + +const planningProfile: AgentProfile = { + filterTools: (allTools) => filterToolsByNames(allTools, [...PM_TOOLS, SESSION_TOOL]), + sdkTools: READ_ONLY_SDK_TOOLS, + enableStopHooks: false, + needsGitHubToken: false, + fetchContext: fetchWorkItemContext, + buildTaskPrompt: buildWorkItemTaskPrompt, +}; + +const reviewProfile: AgentProfile = { + filterTools: (allTools) => filterToolsByNames(allTools, [...GITHUB_REVIEW_TOOLS, SESSION_TOOL]), + sdkTools: READ_ONLY_SDK_TOOLS, + enableStopHooks: false, + needsGitHubToken: true, + fetchContext: fetchReviewContext, + buildTaskPrompt: buildReviewTaskPrompt, + + async preExecute({ input, logWriter }: PreExecuteParams): Promise { + const repoFullName = input.repoFullName as string; + const prNumber = input.prNumber as number; + const [owner, repo] = repoFullName.split('/'); + + logWriter('INFO', 'Posting initial review comment', { owner, repo, prNumber }); + await githubClient.createPRComment(owner, repo, prNumber, '🔍 Reviewing PR...'); + }, +}; + +const respondToPlanningCommentProfile: AgentProfile = { + filterTools: (allTools) => + filterToolsByNames(allTools, [...PM_TOOLS, PM_CHECKLIST_TOOL, SESSION_TOOL]), + sdkTools: READ_ONLY_SDK_TOOLS, + enableStopHooks: false, + needsGitHubToken: false, + fetchContext: fetchWorkItemContext, + buildTaskPrompt: buildCommentResponseTaskPrompt, +}; + +const defaultProfile: AgentProfile = { + filterTools: (allTools) => allTools, + sdkTools: ALL_SDK_TOOLS, + enableStopHooks: true, + needsGitHubToken: false, + fetchContext: fetchWorkItemContext, + buildTaskPrompt: buildWorkItemTaskPrompt, +}; + +// ============================================================================ +// Profile Registry +// ============================================================================ + +const PROFILE_REGISTRY: Record = { + briefing: briefingProfile, + planning: planningProfile, + review: reviewProfile, + 'respond-to-planning-comment': respondToPlanningCommentProfile, + 'respond-to-review': reviewProfile, + 'respond-to-pr-comment': reviewProfile, +}; + +export function getAgentProfile(agentType: string): AgentProfile { + return PROFILE_REGISTRY[agentType] ?? defaultProfile; +} diff --git a/src/backends/claude-code/hooks.ts b/src/backends/claude-code/hooks.ts index fbd2ba5b..b7ddb11f 100644 --- a/src/backends/claude-code/hooks.ts +++ b/src/backends/claude-code/hooks.ts @@ -192,15 +192,19 @@ export function buildStopHooks(logWriter: LogWriter, repoDir: string): HookCallb /** * Build all SDK hooks for the Claude Code backend. + * + * @param enableStopHooks - Whether to include Stop hooks that check for uncommitted/unpushed changes. + * Should be true for implementation agents, false for briefing/planning/review agents. */ export function buildHooks( logWriter: LogWriter, repoDir: string, + enableStopHooks = true, ): Partial> { return { PreToolUse: buildPreToolUseHooks(logWriter), PostToolUse: buildPostToolUseHooks(logWriter), PostToolUseFailure: buildPostToolUseFailureHooks(logWriter), - Stop: buildStopHooks(logWriter, repoDir), + ...(enableStopHooks && { Stop: buildStopHooks(logWriter, repoDir) }), }; } diff --git a/src/backends/claude-code/index.ts b/src/backends/claude-code/index.ts index 8d62b5b9..5e3a87e7 100644 --- a/src/backends/claude-code/index.ts +++ b/src/backends/claude-code/index.ts @@ -318,7 +318,9 @@ export class ClaudeCodeBackend implements AgentBackend { }); const { env } = buildEnv(input.projectSecrets); - const hooks = buildHooks(input.logWriter, input.repoDir); + const hooks = buildHooks(input.logWriter, input.repoDir, input.enableStopHooks ?? true); + + const sdkTools = input.sdkTools ?? ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep']; const assistantMessages: SDKAssistantMessage[] = []; let resultMessage: SDKResultMessage | undefined; @@ -334,8 +336,8 @@ export class ClaudeCodeBackend implements AgentBackend { maxBudgetUsd: input.budgetUsd, permissionMode: 'bypassPermissions', allowDangerouslySkipPermissions: true, - tools: ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep'], - allowedTools: ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep'], + tools: sdkTools, + allowedTools: sdkTools, persistSession: false, hooks, env, diff --git a/src/backends/types.ts b/src/backends/types.ts index 84faff14..3feeec93 100644 --- a/src/backends/types.ts +++ b/src/backends/types.ts @@ -60,6 +60,10 @@ export interface AgentBackendInput { agentInput: AgentInput; /** Per-project secrets to inject into subprocess environment */ projectSecrets?: Record; + /** SDK tools to allow (defaults to all 6: Read, Write, Edit, Bash, Glob, Grep) */ + sdkTools?: string[]; + /** Whether to enable stop hooks that check for uncommitted/unpushed changes (defaults to true) */ + enableStopHooks?: boolean; } export type LogWriter = (level: string, message: string, context?: Record) => void; diff --git a/src/jira/client.ts b/src/jira/client.ts index cedca18e..2f9aeaaf 100644 --- a/src/jira/client.ts +++ b/src/jira/client.ts @@ -81,13 +81,17 @@ export const jiraClient = { logger.debug('Adding JIRA comment', { issueKey }); await getClient().issueComments.addComment({ issueIdOrKey: issueKey, - comment: body as any, + comment: body as Parameters[0]['comment'], }); }, async createIssue(fields: Record) { - logger.debug('Creating JIRA issue', { project: (fields.project as any)?.key }); - return getClient().issues.createIssue({ fields: fields as any }); + logger.debug('Creating JIRA issue', { + project: (fields.project as { key?: string })?.key, + }); + return getClient().issues.createIssue({ + fields: fields as Parameters[0]['fields'], + }); }, async transitionIssue(issueKey: string, transitionId: string) { diff --git a/src/pm/jira/adapter.ts b/src/pm/jira/adapter.ts index 4ff54386..165c01cc 100644 --- a/src/pm/jira/adapter.ts +++ b/src/pm/jira/adapter.ts @@ -26,6 +26,50 @@ interface JiraConfig { customFields?: { cost?: string }; } +/** Partial shape of a JIRA comment from the API */ +interface JiraComment { + id?: string; + created?: string; + body?: unknown; + author?: { accountId?: string; displayName?: string; emailAddress?: string }; +} + +/** Partial shape of a JIRA issue from search results */ +interface JiraSearchIssue { + key?: string; + fields?: { + summary?: string; + status?: { name?: string }; + labels?: string[]; + subtasks?: JiraSubtask[]; + attachment?: JiraAttachment[]; + }; +} + +/** Partial shape of a JIRA subtask */ +interface JiraSubtask { + key?: string; + id?: string; + fields?: { summary?: string; status?: { name?: string } }; +} + +/** Partial shape of a JIRA attachment */ +interface JiraAttachment { + id?: string; + filename?: string; + content?: string; + mimeType?: string; + size?: number; + created?: string; +} + +/** Partial shape of a JIRA transition */ +interface JiraTransition { + id?: string; + name?: string; + to?: { name?: string }; +} + export class JiraPMProvider implements PMProvider { readonly type = 'jira' as const; @@ -51,7 +95,7 @@ export class JiraPMProvider implements PMProvider { async getWorkItemComments(id: string): Promise { const comments = await jiraClient.getIssueComments(id); - return comments.map((c: any) => ({ + return comments.map((c: JiraComment) => ({ id: c.id ?? '', date: c.created ?? '', text: adfToPlainText(c.body), @@ -101,7 +145,7 @@ export class JiraPMProvider implements PMProvider { // containerId is the JIRA project key const jql = `project = "${containerId}" ORDER BY created DESC`; const issues = await jiraClient.searchIssues(jql); - return issues.map((issue: any) => ({ + return issues.map((issue: JiraSearchIssue) => ({ id: issue.key ?? '', title: issue.fields?.summary ?? '', description: '', @@ -117,7 +161,7 @@ export class JiraPMProvider implements PMProvider { // destination is a JIRA status name — find the transition ID const transitions = await jiraClient.getTransitions(id); const transition = transitions.find( - (t: any) => + (t: JiraTransition) => t.name?.toLowerCase() === destination.toLowerCase() || t.to?.name?.toLowerCase() === destination.toLowerCase() || t.id === destination, @@ -126,7 +170,7 @@ export class JiraPMProvider implements PMProvider { logger.warn('No JIRA transition found for destination', { issueKey: id, destination, - available: transitions.map((t: any) => `${t.id}:${t.name}`), + available: transitions.map((t: JiraTransition) => `${t.id}:${t.name}`), }); return; } @@ -151,10 +195,10 @@ export class JiraPMProvider implements PMProvider { async getChecklists(workItemId: string): Promise { // JIRA doesn't have native checklists — map subtasks const issue = await jiraClient.getIssue(workItemId); - const subtasks = (issue.fields as any)?.subtasks ?? []; + const subtasks = ((issue.fields as JiraSearchIssue['fields'])?.subtasks as JiraSubtask[]) ?? []; if (subtasks.length === 0) return []; - const items: ChecklistItem[] = subtasks.map((st: any) => ({ + const items: ChecklistItem[] = subtasks.map((st: JiraSubtask) => ({ id: st.key ?? st.id ?? '', name: st.fields?.summary ?? '', complete: st.fields?.status?.name === 'Done', @@ -212,8 +256,9 @@ export class JiraPMProvider implements PMProvider { async getAttachments(workItemId: string): Promise { const issue = await jiraClient.getIssue(workItemId); - const attachments = (issue.fields as any)?.attachment ?? []; - return attachments.map((a: any) => ({ + const attachments = + ((issue.fields as JiraSearchIssue['fields'])?.attachment as JiraAttachment[]) ?? []; + return attachments.map((a: JiraAttachment) => ({ id: a.id ?? '', name: a.filename ?? '', url: a.content ?? '', diff --git a/tests/unit/agents/fetchImplementationSteps.test.ts b/tests/unit/agents/fetchImplementationSteps.test.ts index 257ce332..840ae1ba 100644 --- a/tests/unit/agents/fetchImplementationSteps.test.ts +++ b/tests/unit/agents/fetchImplementationSteps.test.ts @@ -5,6 +5,7 @@ vi.mock('../../../src/pm/index.js', () => ({ })); import { fetchImplementationSteps } from '../../../src/agents/base.js'; +import type { PMProvider } from '../../../src/pm/index.js'; import { getPMProvider } from '../../../src/pm/index.js'; const mockPMProvider = { @@ -14,7 +15,7 @@ const mockPMProvider = { describe('fetchImplementationSteps', () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(getPMProvider).mockReturnValue(mockPMProvider as any); + vi.mocked(getPMProvider).mockReturnValue(mockPMProvider as unknown as PMProvider); }); it('extracts incomplete items from Implementation Steps checklist', async () => { diff --git a/tests/unit/backends/adapter.test.ts b/tests/unit/backends/adapter.test.ts index e6257ef4..6f61b7ed 100644 --- a/tests/unit/backends/adapter.test.ts +++ b/tests/unit/backends/adapter.test.ts @@ -1,10 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; // Mock all external dependencies -vi.mock('../../../src/gadgets/pm/core/readWorkItem.js', () => ({ - readWorkItem: vi.fn(), -})); - vi.mock('../../../src/agents/shared/repository.js', () => ({ setupRepository: vi.fn(), })); @@ -55,6 +51,15 @@ vi.mock('../../../src/utils/logging.js', () => ({ vi.mock('../../../src/config/provider.js', () => ({ getProjectSecrets: vi.fn(), + getAgentCredential: vi.fn(), +})); + +vi.mock('../../../src/github/client.js', () => ({ + withGitHubToken: vi.fn((_token: string, fn: () => Promise) => fn()), +})); + +vi.mock('../../../src/backends/agent-profiles.js', () => ({ + getAgentProfile: vi.fn(), })); vi.mock('../../../src/agents/prompts/index.js', () => ({})); @@ -63,10 +68,10 @@ import { resolveModelConfig } from '../../../src/agents/shared/modelResolution.j import { setupRepository } from '../../../src/agents/shared/repository.js'; import { createAgentLogger } from '../../../src/agents/utils/logging.js'; import { executeWithBackend } from '../../../src/backends/adapter.js'; +import { type AgentProfile, getAgentProfile } from '../../../src/backends/agent-profiles.js'; import { createProgressMonitor } from '../../../src/backends/progress.js'; import type { AgentBackend } from '../../../src/backends/types.js'; import { getProjectSecrets } from '../../../src/config/provider.js'; -import { readWorkItem } from '../../../src/gadgets/pm/core/readWorkItem.js'; import type { AgentInput, CascadeConfig, ProjectConfig } from '../../../src/types/index.js'; import { loadCascadeEnv, unloadCascadeEnv } from '../../../src/utils/cascadeEnv.js'; import { @@ -78,7 +83,6 @@ import { clearWatchdogCleanup, setWatchdogCleanup } from '../../../src/utils/lif import { logger } from '../../../src/utils/logging.js'; import { cleanupTempDir } from '../../../src/utils/repo.js'; -const mockReadWorkItem = vi.mocked(readWorkItem); const mockSetupRepository = vi.mocked(setupRepository); const mockResolveModelConfig = vi.mocked(resolveModelConfig); const mockCreateFileLogger = vi.mocked(createFileLogger); @@ -91,6 +95,7 @@ const mockCleanupLogDirectory = vi.mocked(cleanupLogDirectory); const mockClearWatchdogCleanup = vi.mocked(clearWatchdogCleanup); const mockCreateProgressMonitor = vi.mocked(createProgressMonitor); const mockGetProjectSecrets = vi.mocked(getProjectSecrets); +const mockGetAgentProfile = vi.mocked(getAgentProfile); function makeProject(): ProjectConfig { return { @@ -143,6 +148,18 @@ function makeMockBackend(): AgentBackend { }; } +function makeMockProfile(overrides?: Partial): AgentProfile { + return { + filterTools: (tools) => tools, + sdkTools: ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep'], + enableStopHooks: true, + needsGitHubToken: false, + fetchContext: vi.fn().mockResolvedValue([]), + buildTaskPrompt: () => 'Process the work item', + ...overrides, + }; +} + function setupMocks() { const mockLoggerInstance = { write: vi.fn(), @@ -161,10 +178,11 @@ function setupMocks() { systemPrompt: 'You are an agent', model: 'test-model', maxIterations: 50, + contextFiles: [], } as never); - mockReadWorkItem.mockResolvedValue('Card data'); mockCreateProgressMonitor.mockReturnValue(null); mockGetProjectSecrets.mockResolvedValue({}); + mockGetAgentProfile.mockReturnValue(makeMockProfile()); return mockLoggerInstance; } @@ -282,24 +300,46 @@ describe('executeWithBackend', () => { expect(mockCleanupLogFile).toHaveBeenCalled(); }); - it('fetches card data for context injection when cardId present and no logDir', async () => { + it('calls profile.fetchContext for context injection', async () => { setupMocks(); + const mockFetchContext = vi.fn().mockResolvedValue([]); + mockGetAgentProfile.mockReturnValue(makeMockProfile({ fetchContext: mockFetchContext })); const backend = makeMockBackend(); const input = makeInput({ cardId: 'card123' }); await executeWithBackend(backend, 'implementation', input); - expect(mockReadWorkItem).toHaveBeenCalledWith('card123', true); + expect(mockFetchContext).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ cardId: 'card123' }), + contextFiles: [], + }), + ); }); - it('skips context injection when logDir present', async () => { + it('uses profile to filter tools and set sdkTools', async () => { setupMocks(); + const filterTools = vi.fn((tools) => + tools.filter((t: { name: string }) => t.name === 'Finish'), + ); + mockGetAgentProfile.mockReturnValue( + makeMockProfile({ + filterTools, + sdkTools: ['Read', 'Bash', 'Glob', 'Grep'], + enableStopHooks: false, + }), + ); const backend = makeMockBackend(); - const input = makeInput({ cardId: 'card123', logDir: '/some/dir' }); + const input = makeInput(); await executeWithBackend(backend, 'implementation', input); - expect(mockReadWorkItem).not.toHaveBeenCalled(); + expect(filterTools).toHaveBeenCalled(); + const backendInput = vi.mocked(backend.execute).mock.calls[0][0]; + expect(backendInput.availableTools).toHaveLength(1); + expect(backendInput.availableTools[0].name).toBe('Finish'); + expect(backendInput.sdkTools).toEqual(['Read', 'Bash', 'Glob', 'Grep']); + expect(backendInput.enableStopHooks).toBe(false); }); it('marks implementation agent as failed when no PR was created', async () => { diff --git a/tests/unit/backends/progress.test.ts b/tests/unit/backends/progress.test.ts index 62778a28..8d637971 100644 --- a/tests/unit/backends/progress.test.ts +++ b/tests/unit/backends/progress.test.ts @@ -44,6 +44,7 @@ import { import { getSessionState } from '../../../src/gadgets/sessionState.js'; import { loadTodos } from '../../../src/gadgets/todo/storage.js'; import { githubClient } from '../../../src/github/client.js'; +import type { PMProvider } from '../../../src/pm/index.js'; import { getPMProviderOrNull } from '../../../src/pm/index.js'; const mockGetPMProvider = vi.mocked(getPMProviderOrNull); @@ -185,7 +186,7 @@ describe('ProgressMonitor — tick behavior', () => { trello: { cardId: 'card1' }, }); - mockGetPMProvider.mockReturnValue(mockPMProvider as any); + mockGetPMProvider.mockReturnValue(mockPMProvider as unknown as PMProvider); mockCallProgressModel.mockResolvedValue('**Progress**: All good'); mockPMProvider.addComment.mockResolvedValue(undefined as never); @@ -209,7 +210,7 @@ describe('ProgressMonitor — tick behavior', () => { trello: { cardId: 'card1' }, }); - mockGetPMProvider.mockReturnValue(mockPMProvider as any); + mockGetPMProvider.mockReturnValue(mockPMProvider as unknown as PMProvider); mockCallProgressModel.mockRejectedValue(new Error('Model error')); mockFormatStatus.mockReturnValue('Fallback progress'); mockPMProvider.addComment.mockResolvedValue(undefined as never); @@ -233,7 +234,7 @@ describe('ProgressMonitor — tick behavior', () => { trello: { cardId: 'card1' }, }); - mockGetPMProvider.mockReturnValue(mockPMProvider as any); + mockGetPMProvider.mockReturnValue(mockPMProvider as unknown as PMProvider); mockCallProgressModel.mockResolvedValue('Progress'); mockPMProvider.addComment.mockResolvedValue(undefined as never); mockSyncChecklist.mockResolvedValue(); @@ -256,7 +257,7 @@ describe('ProgressMonitor — tick behavior', () => { trello: { cardId: 'card1' }, }); - mockGetPMProvider.mockReturnValue(mockPMProvider as any); + mockGetPMProvider.mockReturnValue(mockPMProvider as unknown as PMProvider); mockCallProgressModel.mockResolvedValue('Progress'); mockPMProvider.addComment.mockResolvedValue(undefined as never); @@ -337,7 +338,7 @@ describe('ProgressMonitor — tick behavior', () => { trello: { cardId: 'card1' }, }); - mockGetPMProvider.mockReturnValue(mockPMProvider as any); + mockGetPMProvider.mockReturnValue(mockPMProvider as unknown as PMProvider); mockCallProgressModel.mockResolvedValue('Progress'); mockPMProvider.addComment.mockRejectedValue(new Error('API error')); @@ -353,7 +354,7 @@ describe('ProgressMonitor — tick behavior', () => { }); it('prevents concurrent ticks', async () => { - mockGetPMProvider.mockReturnValue(mockPMProvider as any); + mockGetPMProvider.mockReturnValue(mockPMProvider as unknown as PMProvider); const monitor = new ProgressMonitor({ agentType: 'implementation', taskDescription: 'Test task', diff --git a/tests/unit/triggers/budget.test.ts b/tests/unit/triggers/budget.test.ts index 1d1383f4..17c0d4b6 100644 --- a/tests/unit/triggers/budget.test.ts +++ b/tests/unit/triggers/budget.test.ts @@ -4,12 +4,13 @@ vi.mock('../../../src/pm/index.js', () => ({ getPMProvider: vi.fn(), })); +import type { PMProvider } from '../../../src/pm/index.js'; import { getPMProvider } from '../../../src/pm/index.js'; import { checkBudgetExceeded, resolveCardBudget } from '../../../src/triggers/shared/budget.js'; import type { CascadeConfig, ProjectConfig } from '../../../src/types/index.js'; const mockPMProvider = { getCustomFieldNumber: vi.fn() }; -vi.mocked(getPMProvider).mockReturnValue(mockPMProvider as any); +vi.mocked(getPMProvider).mockReturnValue(mockPMProvider as unknown as PMProvider); const baseProject: ProjectConfig = { id: 'test', diff --git a/tests/unit/triggers/debug-runner.test.ts b/tests/unit/triggers/debug-runner.test.ts index 9425c1a7..504f402b 100644 --- a/tests/unit/triggers/debug-runner.test.ts +++ b/tests/unit/triggers/debug-runner.test.ts @@ -35,6 +35,7 @@ import { getRunLogs, storeDebugAnalysis, } from '../../../src/db/repositories/runsRepository.js'; +import type { PMProvider } from '../../../src/pm/index.js'; import { getPMProvider } from '../../../src/pm/index.js'; import { triggerDebugAnalysis } from '../../../src/triggers/shared/debug-runner.js'; @@ -59,7 +60,7 @@ const mockConfig = {} as CascadeConfig; describe('triggerDebugAnalysis', () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(getPMProvider).mockReturnValue(mockPMProvider as any); + vi.mocked(getPMProvider).mockReturnValue(mockPMProvider as unknown as PMProvider); }); it('returns early when run is not found', async () => {