Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/agents/definitions/implementation.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ hooks:
finish:
scm:
requiresPR: true
lifecycle:
moveOnPrepare: inProgress
moveOnSuccess: inReview
linkPR: true
syncChecklist: true

hint: >-
Complete the current todo in as few iterations as possible. Batch related
Expand Down
15 changes: 15 additions & 0 deletions src/agents/definitions/profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {
AgentDefinition,
ContextStepName,
FinishHookFlags,
LifecycleHooks,
SupportedTrigger,
} from './schema.js';
import { CONTEXT_STEP_REGISTRY } from './strategies.js';
Expand All @@ -46,6 +47,8 @@ export interface AgentProfile {
needsGitHubToken: boolean;
/** Finish hook flags (SCM requirements: requiresPR, requiresReview, etc.) */
finishHooks: FinishHookFlags;
/** Lifecycle hooks — drives PM lifecycle behavior (moveOnPrepare, moveOnSuccess, linkPR, syncChecklist) */
lifecycleHooks: LifecycleHooks;
/** Fetch context injections for this agent type */
fetchContext(params: FetchContextParams): Promise<ContextInjection[]>;
/** Build the task prompt for this agent type */
Expand All @@ -67,6 +70,14 @@ export interface AgentProfile {
// Helpers
// ============================================================================

/**
* Resolve lifecycle hooks from an agent definition.
* Returns an empty object (no-op) when no lifecycle block is defined.
*/
function resolveLifecycleHooks(def: AgentDefinition): LifecycleHooks {
return def.hooks?.lifecycle ?? {};
}

/**
* Resolve finish hooks from an agent definition.
*/
Expand Down Expand Up @@ -175,6 +186,9 @@ function buildProfileFromDefinition(def: AgentDefinition, agentType: string): Ag
// Resolve finish hooks
const finish = resolveFinishHooks(def);

// Resolve lifecycle hooks
const lifecycle = resolveLifecycleHooks(def);

const profile: AgentProfile = {
filterTools: (allTools: ToolManifest[]) => {
// Filter tools by the gadget names derived from capabilities
Expand All @@ -184,6 +198,7 @@ function buildProfileFromDefinition(def: AgentDefinition, agentType: string): Ag
allCapabilities,
needsGitHubToken: requiresScmIntegration(def),
finishHooks: finish,
lifecycleHooks: lifecycle,
fetchContext: async (params) => {
// Resolve context pipeline from the trigger (empty array if no trigger or trigger has no pipeline)
const contextPipeline = resolveContextPipeline(triggers, params.input.triggerEvent);
Expand Down
23 changes: 22 additions & 1 deletion src/agents/definitions/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,10 +243,28 @@ const FinishHooksSchema = z.object({
pm: PmFinishSchema.optional(),
});

// --- Lifecycle hook schema ---
/**
* Lifecycle hooks drive PM lifecycle behavior for agents.
* These replace hard-coded agentType === 'implementation' checks.
*
* - `moveOnPrepare`: PM status to move the work item to when prepareForAgent() is called
* - `moveOnSuccess`: PM status to move the work item to when handleSuccess() is called
* - `linkPR`: Whether to link the PR to the work item on success
* - `syncChecklist`: Whether to sync completed todos to the PM checklist on progress ticks
*/
export const LifecycleHooksSchema = z.object({
moveOnPrepare: z.string().optional(),
moveOnSuccess: z.string().optional(),
linkPR: z.boolean().optional(),
syncChecklist: z.boolean().optional(),
});

// --- Top-level integration hooks ---
export const IntegrationHooksSchema = z.object({
trailing: TrailingHooksSchema.optional(),
finish: FinishHooksSchema.optional(),
lifecycle: LifecycleHooksSchema.optional(),
});

const PromptsSchema = z.object({
Expand Down Expand Up @@ -325,9 +343,12 @@ export type IntegrationRequirements = z.infer<typeof IntegrationRequirementsSche
/** Known provider (trello, jira, github, etc.) */
export type KnownProvider = z.infer<typeof KnownProviderSchema>;

/** Integration hooks (trailing + finish) */
/** Integration hooks (trailing + finish + lifecycle) */
export type IntegrationHooks = z.infer<typeof IntegrationHooksSchema>;

/** Lifecycle hook configuration for PM lifecycle behavior */
export type LifecycleHooks = z.infer<typeof LifecycleHooksSchema>;

/** Flattened trailing hook flags for consumers */
export type TrailingHookFlags = z.infer<typeof ScmTrailingSchema> &
z.infer<typeof BuiltinTrailingSchema>;
Expand Down
1 change: 1 addition & 0 deletions src/backends/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export async function executeWithEngine(
isGitHubAck,
engine.definition.id,
partialInput.model ?? '',
profile.lifecycleHooks.syncChecklist ?? false,
),
);

Expand Down
2 changes: 2 additions & 0 deletions src/backends/progressLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export function buildProgressMonitorConfig(
isGitHubAck: boolean,
engineId: string,
model: string,
syncChecklist = false,
) {
const { workItemId } = input;

Expand All @@ -49,6 +50,7 @@ export function buildProgressMonitorConfig(
trello: workItemId ? { workItemId } : undefined,
preSeededCommentId: isGitHubAck ? undefined : (input.ackCommentId as string | undefined),
runLink,
syncChecklist,
...(input.prNumber && input.repoFullName
? {
github: {
Expand Down
10 changes: 8 additions & 2 deletions src/backends/progressMonitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ export interface ProgressMonitorConfig {
github?: { owner: string; repo: string };
/** Pre-seeded comment ID from router ack — skip initial comment posting */
preSeededCommentId?: string;
/**
* Whether to sync completed todos to the PM checklist on each progress tick.
* Replaces the hard-coded agentType === 'implementation' check.
* Defaults to false (no-op for agents without lifecycle.syncChecklist in their definition).
*/
syncChecklist?: boolean;
/**
* Progressive schedule of delays (in minutes) before falling back to
* `intervalMinutes` for steady-state ticks.
Expand Down Expand Up @@ -264,8 +270,8 @@ export class ProgressMonitor implements ProgressReporter {
}
}

// Sync checklist items for implementation agents
if (this.config.agentType === 'implementation' && this.config.trello) {
// Sync checklist items when lifecycle hooks enable it
if (this.config.syncChecklist && this.config.trello) {
await syncCompletedTodosToChecklist(this.config.trello.workItemId);
}
} catch (err) {
Expand Down
52 changes: 29 additions & 23 deletions src/pm/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* manipulating labels, statuses, and comments.
*/

import type { LifecycleHooks } from '../agents/definitions/schema.js';
import type { ProjectConfig } from '../types/index.js';
import { safeOperation, silentOperation } from '../utils/safeOperation.js';
import { pmRegistry } from './registry.js';
Expand Down Expand Up @@ -67,43 +68,48 @@ export class PMLifecycleManager {
private pmConfig: ProjectPMConfig,
) {}

async prepareForAgent(workItemId: string, agentType: string): Promise<void> {
async prepareForAgent(workItemId: string, hooks: LifecycleHooks): Promise<void> {
await this.safeAddLabel(workItemId, this.pmConfig.labels.processing);
await this.safeRemoveLabel(workItemId, this.pmConfig.labels.readyToProcess);
await this.safeRemoveLabel(workItemId, this.pmConfig.labels.processed);

if (agentType === 'implementation') {
await this.safeMove(workItemId, this.pmConfig.statuses.inProgress);
if (hooks.moveOnPrepare) {
const destination =
this.pmConfig.statuses[hooks.moveOnPrepare as keyof typeof this.pmConfig.statuses];
await this.safeMove(workItemId, destination);
}
}

async handleSuccess(
workItemId: string,
agentType: string,
hooks: LifecycleHooks,
prUrl?: string,
progressCommentId?: string,
): Promise<void> {
await this.safeAddLabel(workItemId, this.pmConfig.labels.processed);

if (agentType === 'implementation') {
await this.safeMove(workItemId, this.pmConfig.statuses.inReview);
if (prUrl) {
const prTitle = extractPRTitle(prUrl);
let linked = false;
try {
await this.provider.linkPR(workItemId, prUrl, prTitle);
linked = true;
} catch {
// linkPR failed — fall through to comment fallback
}
if (!linked) {
const message = `PR created: ${prUrl}`;
if (progressCommentId) {
// Replace the progress comment with the "PR created" message
await this.safeUpdateOrAddComment(workItemId, progressCommentId, message);
} else {
await this.safeAddComment(workItemId, message);
}
if (hooks.moveOnSuccess) {
const destination =
this.pmConfig.statuses[hooks.moveOnSuccess as keyof typeof this.pmConfig.statuses];
await this.safeMove(workItemId, destination);
}

if (hooks.linkPR && prUrl) {
const prTitle = extractPRTitle(prUrl);
let linked = false;
try {
await this.provider.linkPR(workItemId, prUrl, prTitle);
linked = true;
} catch {
// linkPR failed — fall through to comment fallback
}
if (!linked) {
const message = `PR created: ${prUrl}`;
if (progressCommentId) {
// Replace the progress comment with the "PR created" message
await this.safeUpdateOrAddComment(workItemId, progressCommentId, message);
} else {
await this.safeAddComment(workItemId, message);
}
}
}
Expand Down
20 changes: 18 additions & 2 deletions src/triggers/shared/agent-execution.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { getAgentProfile } from '../../agents/definitions/profiles.js';
import type { LifecycleHooks } from '../../agents/definitions/schema.js';
import { runAgent } from '../../agents/registry.js';
import { createWorkItem, linkPRToWorkItem } from '../../db/repositories/prWorkItemsRepository.js';
import { updateRunPRNumber } from '../../db/repositories/runsRepository.js';
Expand Down Expand Up @@ -99,6 +101,7 @@ async function runPostAgentLifecycle(
agentResult: AgentResult,
project: ProjectConfig,
lifecycle: PMLifecycleManager,
lifecycleHooks: LifecycleHooks,
executionConfig: AgentExecutionConfig,
): Promise<void> {
const {
Expand Down Expand Up @@ -129,7 +132,7 @@ async function runPostAgentLifecycle(
if (shouldCallHandleSuccess) {
await lifecycle.handleSuccess(
workItemId,
agentType,
lifecycleHooks,
agentResult.prUrl,
agentResult.progressCommentId,
);
Expand Down Expand Up @@ -364,6 +367,18 @@ export async function runAgentExecutionPipeline(
const pmConfig = resolveProjectPMConfig(project);
const lifecycle = new PMLifecycleManager(pmProvider, pmConfig);

// Load lifecycle hooks from agent profile (best-effort — defaults to no-op on failure)
let lifecycleHooks: LifecycleHooks = {};
try {
const agentProfile = await getAgentProfile(agentType);
lifecycleHooks = agentProfile.lifecycleHooks;
} catch (err) {
logger.warn('Failed to load agent profile for lifecycle hooks, using defaults', {
agentType,
error: String(err),
});
}

// Pre-flight integration validation
const validation = await validateIntegrations(project.id, agentType);
if (!validation.valid) {
Expand Down Expand Up @@ -429,7 +444,7 @@ export async function runAgentExecutionPipeline(
}

if (workItemId && !skipPrepareForAgent) {
await lifecycle.prepareForAgent(workItemId, agentType);
await lifecycle.prepareForAgent(workItemId, lifecycleHooks);
}

const agentResult = await runAgent(agentType, {
Expand Down Expand Up @@ -459,6 +474,7 @@ export async function runAgentExecutionPipeline(
agentResult,
project,
lifecycle,
lifecycleHooks,
executionConfig,
);
}
Expand Down
2 changes: 2 additions & 0 deletions tests/unit/backends/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,12 +195,14 @@ function makeMockProfile(overrides?: Partial<AgentProfile>): AgentProfile {
allCapabilities: ['fs:read', 'fs:write', 'shell:exec'],
needsGitHubToken: false,
finishHooks: {},
lifecycleHooks: {},
fetchContext: vi.fn().mockResolvedValue([]),
buildTaskPrompt: () => 'Process the work item',
capabilities: {
required: ['fs:read'],
optional: ['fs:write', 'shell:exec'],
},
getLlmistGadgets: vi.fn().mockReturnValue([]),
...overrides,
};
}
Expand Down
5 changes: 3 additions & 2 deletions tests/unit/backends/progress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,7 @@ describe('ProgressMonitor — tick behavior', () => {
);
});

it('syncs checklist for implementation agents', async () => {
it('syncs checklist when syncChecklist is true', async () => {
const monitor = new ProgressMonitor({
agentType: 'implementation',
taskDescription: 'Test task',
Expand All @@ -468,6 +468,7 @@ describe('ProgressMonitor — tick behavior', () => {
customModels: [],
logWriter: vi.fn(),
trello: { workItemId: 'card1' },
syncChecklist: true,
});

mockGetPMProvider.mockReturnValue(mockPMProvider as unknown as PMProvider);
Expand All @@ -483,7 +484,7 @@ describe('ProgressMonitor — tick behavior', () => {
expect(mockSyncChecklist).toHaveBeenCalledWith('card1');
});

it('does not sync checklist for non-implementation agents', async () => {
it('does not sync checklist when syncChecklist is not set', async () => {
const monitor = new ProgressMonitor({
agentType: 'planning',
taskDescription: 'Test task',
Expand Down
14 changes: 10 additions & 4 deletions tests/unit/backends/progressMonitor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,14 +352,20 @@ describe('ProgressMonitor - tick (via scheduler callback)', () => {
expect(mockPMPosterUpdate).toHaveBeenCalledTimes(1);
});

it('syncs todos to checklist for implementation agent with trello', async () => {
await runTick(makeConfig({ trello: { workItemId: 'card-1' } }));
it('syncs todos to checklist when syncChecklist is true and trello is configured', async () => {
await runTick(makeConfig({ syncChecklist: true, trello: { workItemId: 'card-1' } }));

expect(mockSyncCompletedTodosToChecklist).toHaveBeenCalledWith('card-1');
});

it('does not sync todos for non-implementation agents', async () => {
await runTick(makeConfig({ agentType: 'review', trello: { workItemId: 'card-1' } }));
it('does not sync todos when syncChecklist is false', async () => {
await runTick(makeConfig({ syncChecklist: false, trello: { workItemId: 'card-1' } }));

expect(mockSyncCompletedTodosToChecklist).not.toHaveBeenCalled();
});

it('does not sync todos when syncChecklist is not set (default)', async () => {
await runTick(makeConfig({ trello: { workItemId: 'card-1' } }));

expect(mockSyncCompletedTodosToChecklist).not.toHaveBeenCalled();
});
Expand Down
Loading
Loading