- ) : !repositoriesData?.integrationInstalled ? (
-
- ) : (
- <>
- {/* For GitLab, only show "Selected repositories" since "All" is not supported */}
- {!isGitLab && (
-
- setRepositorySelectionMode(value as 'all' | 'selected')
- }
- className="space-y-3"
- >
-
-
-
- All repositories ({repositoriesData.repositories.length})
-
-
-
-
-
- Selected repositories
-
+ {/* Repository Selection (Advanced) — which repositories to review + configure. */}
+ {perRepoOverridesEnabled && (
+
+
+
+
Repositories
+
+ Choose which repositories to review and configure below. Others are not
+ reviewed.
+
+
+ {repositoriesData?.integrationInstalled && (
+
+
+ Last synced:{' '}
+ {repositoriesData.syncedAt
+ ? formatDistanceToNow(new Date(repositoriesData.syncedAt), {
+ addSuffix: true,
+ })
+ : 'Never'}
+
+
+
+
-
- )}
+ )}
+
- {/* For GitLab, always show the multi-select; for GitHub, only when 'selected' mode */}
- {(isGitLab || repositorySelectionMode === 'selected') && (
-
-
+ {isLoadingRepositories ? (
+
+
Loading repositories...
+
+ ) : repositoriesError ? (
+
+
+ Failed to load repositories. Please try refreshing the page.
+
+
+ ) : !repositoriesData?.integrationInstalled ? (
+
+
+ {repositoriesData?.errorMessage ||
+ `${platformLabel} integration is not connected. Please connect ${platformLabel} in the Integrations page to configure repository selection.`}
+
+ ) : selectableRepositories.length === 0 ? (
+
+
+ No repositories found. Please ensure the {platformLabel}{' '}
+ {isGitLab ? 'integration' : 'App'} has access to your repositories.
+
+
+ ) : (
+
)}
- >
+
)}
-
- {/* Per-repository model overrides — independent of the trigger selection
+ {/* Per-repository model overrides — independent of the trigger selection
mode above; applies whether reviews run on all or selected repos. */}
- {repositoriesData?.integrationInstalled && selectableRepositories.length > 0 && (
-
-
-
-
- Per-repository models
-
-
- Run specific repositories' reviews on a different model than the default.
-
+ {perRepoOverridesEnabled &&
+ repositoriesData?.integrationInstalled &&
+ selectedRepositories.length > 0 && (
+
+
+
Per-repository model
+
+ Optionally run specific repositories' reviews on a different model or
+ effort than the global default.
+
+
+
+
+ {/* Per-repository council opt-in + the shared specialist config it applies. */}
+ {councilUiEnabled &&
+ perRepoOverridesEnabled &&
+ selectedRepositories.length > 0 && (
+
+
+
Council review
+
+ Choose which repositories run the council review (multiple
+ specialists, combined into one decision) on every pull request. Others
+ use the standard single reviewer.
+
+
+
+ {selectedRepositories.map(repo => (
+
+
+ {repo.full_name}
+
+
+ handleCouncilRepoToggle(repo.id, checked)
+ }
+ disabled={!isEnabled || orgSaveMutation.isPending}
+ aria-label={`Enable council review for ${repo.full_name}`}
+ />
+
+ ))}
+
+
+ {/* Shared specialists + governance — applied to every council-enabled repo. */}
+ {councilEnabled && (
+
+
+ Specialists (applied to all council-enabled repositories)
+
+
+
+
Governance decision
+
+ setCouncilAggregation(value as CouncilAggregationStrategy)
+ }
+ disabled={orgSaveMutation.isPending}
+ >
+
+
+
+
+ {COUNCIL_AGGREGATION_STRATEGIES.map(strategy => (
+
+ {formatAggregationStrategy(strategy)}
+
+ ))}
+
+
+
+ Select {COUNCIL_MIN_SPECIALISTS}–4 specialists.{' '}
+ {councilEnabledCount} selected.
+
+
+
+ )}
+
+ )}
-
-
- {overridesEnabled && (
-
)}
-
- )}
+
{/* GitLab Webhook Configuration */}
{isGitLab &&
@@ -1072,50 +1304,6 @@ export function ReviewConfigForm({
)}
- {/* Focus Areas */}
-
-
Focus Areas
-
- Select specific areas for the agent to pay special attention to
-
-
- {FOCUS_AREAS.map(area => (
-
-
handleFocusAreaToggle(area.id)}
- />
-
-
- {area.label}
-
-
{area.description}
-
-
- ))}
-
-
-
- {/* Custom Instructions */}
-
-
Custom Instructions (Optional)
-
-
{/* Save Button */}
{
expect(specialists[1].model_slug).toBeUndefined();
expect(specialists[1].thinking_effort).toBeUndefined();
});
+
+ it('councilSelectionsFromConfig round-trips a saved config back into picker state', () => {
+ const selections = defaultCouncilSelections();
+ for (const id of Object.keys(selections)) selections[id].enabled = false;
+ selections.security = { enabled: true, modelSlug: 'anthropic/x', thinkingEffort: 'high' };
+ selections.testing = { enabled: true, modelSlug: null, thinkingEffort: null };
+
+ const council = {
+ enabled: true,
+ aggregation_strategy: 'unanimous' as const,
+ specialists: buildCouncilSpecialists(selections),
+ };
+ const hydrated = councilSelectionsFromConfig(council);
+
+ // Enabled presets come back with their saved model/effort; absent ones are disabled.
+ expect(hydrated.security).toEqual({
+ enabled: true,
+ modelSlug: 'anthropic/x',
+ thinkingEffort: 'high',
+ });
+ expect(hydrated.testing).toEqual({ enabled: true, modelSlug: null, thinkingEffort: null });
+ expect(hydrated.performance.enabled).toBe(false);
+ expect(hydrated.correctness.enabled).toBe(false);
+ // Re-building from the hydrated selections reproduces the same specialist ids.
+ expect(buildCouncilSpecialists(hydrated).map(s => s.id)).toEqual(['security', 'testing']);
+ });
+
+ it('councilSelectionsFromConfig disables everything for a null/empty config', () => {
+ const hydrated = councilSelectionsFromConfig(null);
+ expect(countEnabledSelections(hydrated)).toBe(0);
+ expect(Object.keys(hydrated).sort()).toEqual(COUNCIL_SPECIALIST_PRESETS.map(p => p.id).sort());
+ });
});
diff --git a/apps/web/src/lib/code-reviews/core/council-selection.ts b/apps/web/src/lib/code-reviews/core/council-selection.ts
index c6c963be17..50ba84b275 100644
--- a/apps/web/src/lib/code-reviews/core/council-selection.ts
+++ b/apps/web/src/lib/code-reviews/core/council-selection.ts
@@ -1,4 +1,4 @@
-import type { CouncilSpecialist } from '@kilocode/db/schema-types';
+import type { CodeReviewCouncilConfig, CouncilSpecialist } from '@kilocode/db/schema-types';
import {
COUNCIL_SPECIALIST_PRESETS,
presetToSpecialist,
@@ -37,6 +37,30 @@ export function defaultCouncilSelections(): Record {
+ const byId = new Map((council?.specialists ?? []).map(specialist => [specialist.id, specialist]));
+ return Object.fromEntries(
+ COUNCIL_SPECIALIST_PRESETS.map(preset => {
+ const saved = byId.get(preset.id);
+ return [
+ preset.id,
+ {
+ enabled: saved?.enabled ?? false,
+ modelSlug: saved?.model_slug ?? null,
+ thinkingEffort: saved?.thinking_effort ?? null,
+ },
+ ];
+ })
+ );
+}
+
/** Number of currently-enabled specialists across the selection state. */
export function countEnabledSelections(
selections: Record
diff --git a/apps/web/src/lib/code-reviews/council/finalize-council-result.test.ts b/apps/web/src/lib/code-reviews/council/finalize-council-result.test.ts
index cb46bd7361..efa170d187 100644
--- a/apps/web/src/lib/code-reviews/council/finalize-council-result.test.ts
+++ b/apps/web/src/lib/code-reviews/council/finalize-council-result.test.ts
@@ -1,7 +1,8 @@
import { describe, expect, it } from '@jest/globals';
import { COUNCIL_RESULT_MARKER_TAG } from '@kilocode/worker-utils/code-review-council';
-import type { CodeReviewCouncilConfig } from '@kilocode/db/schema-types';
-import { buildCouncilResult } from './finalize-council-result';
+import type { CloudAgentCodeReview } from '@kilocode/db/schema';
+import type { CodeReviewAgentConfig, CodeReviewCouncilConfig } from '@kilocode/db/schema-types';
+import { buildCouncilResult, computeCouncilResultForReview } from './finalize-council-result';
const council: CodeReviewCouncilConfig = {
enabled: true,
@@ -185,3 +186,48 @@ describe('buildCouncilResult', () => {
expect(result.specialists.every(s => s.vote === null)).toBe(true);
});
});
+
+describe('computeCouncilResultForReview — automated (webhook) council config source', () => {
+ // Webhook council reviews carry no manual_config; the council config comes from the org config.
+ const webhookReview = {
+ review_type: 'council',
+ manual_config: null,
+ } as unknown as CloudAgentCodeReview;
+ const orgAgentConfig = { model_slug: 'base/model', council } as unknown as CodeReviewAgentConfig;
+ const manifest = manifestText([
+ { specialistId: 'security', findings: crit },
+ { specialistId: 'performance', findings: [] },
+ ]);
+
+ it('falls back to the org agent config when there is no manual_config', () => {
+ const result = computeCouncilResultForReview({
+ review: webhookReview,
+ lastAssistantMessageText: manifest,
+ orgAgentConfig,
+ });
+ expect(result?.decision).toBe('block'); // unanimous + one critical
+ expect(result?.specialists).toHaveLength(2);
+ expect(result?.specialists[0].model).toBe('anthropic/x'); // configured per-specialist model
+ expect(result?.specialists[1].model).toBe('base/model'); // inherits the org base model
+ });
+
+ it('returns null for a webhook council review when no org config is available (fail-safe)', () => {
+ expect(
+ computeCouncilResultForReview({
+ review: webhookReview,
+ lastAssistantMessageText: manifest,
+ orgAgentConfig: null,
+ })
+ ).toBeNull();
+ });
+
+ it('returns null for a non-council review even with an org council config', () => {
+ expect(
+ computeCouncilResultForReview({
+ review: { review_type: 'standard', manual_config: null } as unknown as CloudAgentCodeReview,
+ lastAssistantMessageText: manifest,
+ orgAgentConfig,
+ })
+ ).toBeNull();
+ });
+});
diff --git a/apps/web/src/lib/code-reviews/council/finalize-council-result.ts b/apps/web/src/lib/code-reviews/council/finalize-council-result.ts
index 9c28afde8f..d0a470f6b0 100644
--- a/apps/web/src/lib/code-reviews/council/finalize-council-result.ts
+++ b/apps/web/src/lib/code-reviews/council/finalize-council-result.ts
@@ -2,6 +2,7 @@ import 'server-only';
import type { CloudAgentCodeReview } from '@kilocode/db/schema';
import type {
+ CodeReviewAgentConfig,
CodeReviewCouncilConfig,
CodeReviewCouncilResult,
CouncilResultSpecialist,
@@ -101,10 +102,16 @@ export function buildCouncilResult(params: {
export function computeCouncilResultForReview(params: {
review: CloudAgentCodeReview;
lastAssistantMessageText: string | null | undefined;
+ // Council-config source for AUTOMATED (webhook) reviews, which carry no `manual_config`.
+ // Manual reviews resolve their council from `manual_config` and ignore this. Resolved + passed
+ // by the caller (the status callback) because this function is pure/DB-free and also runs inside
+ // the analytics completion transaction. Absent → automated council reviews persist no result.
+ orgAgentConfig?: CodeReviewAgentConfig | null;
}): CodeReviewCouncilResult | null {
const { review, lastAssistantMessageText } = params;
if (review.review_type !== 'council') return null;
- const agentConfig = getManualCodeReviewConfig(review)?.agentConfig;
+ const agentConfig =
+ getManualCodeReviewConfig(review)?.agentConfig ?? params.orgAgentConfig ?? null;
const council = agentConfig?.council;
if (!council) return null;
@@ -134,6 +141,7 @@ export function computeCouncilResultForReview(params: {
export async function finalizeCouncilResultForReview(params: {
review: CloudAgentCodeReview;
lastAssistantMessageText: string | null | undefined;
+ orgAgentConfig?: CodeReviewAgentConfig | null;
}): Promise {
const councilResult = computeCouncilResultForReview(params);
if (!councilResult) return null;
diff --git a/apps/web/src/lib/integrations/platforms/github/webhook-handlers/pull-request-handler.test.ts b/apps/web/src/lib/integrations/platforms/github/webhook-handlers/pull-request-handler.test.ts
index c7d795fea9..1229fcd075 100644
--- a/apps/web/src/lib/integrations/platforms/github/webhook-handlers/pull-request-handler.test.ts
+++ b/apps/web/src/lib/integrations/platforms/github/webhook-handlers/pull-request-handler.test.ts
@@ -12,6 +12,7 @@ const mockTryDispatchPendingReviews = jest.fn();
const mockCancelReview = jest.fn();
const mockAddReactionToPR = jest.fn();
const mockIsMergeCommit = jest.fn();
+const mockIsCouncilEntitledForOwner = jest.fn();
jest.mock('@/lib/bot-users/bot-user-service', () => ({
getBotUserId: (organizationId: string, botType: string) =>
@@ -23,6 +24,10 @@ jest.mock('@/lib/agent-config/db/agent-configs', () => ({
mockGetAgentConfigForOwner(owner, agentType, platform),
}));
+jest.mock('@/lib/code-reviews/core/council-entitlement', () => ({
+ isCouncilEntitledForOwner: (...args: unknown[]) => mockIsCouncilEntitledForOwner(...args),
+}));
+
jest.mock('@/lib/code-reviews/db/code-reviews', () => ({
createCodeReview: (...args: unknown[]) => mockCreateCodeReview(...args),
cancelSupersededReviewsForPR: (...args: unknown[]) => mockCancelSupersededReviewsForPR(...args),
@@ -117,6 +122,7 @@ beforeEach(() => {
mockCancelReview.mockResolvedValue({ success: true, reviewId: 'old-review' });
mockAddReactionToPR.mockResolvedValue(undefined);
mockIsMergeCommit.mockResolvedValue(false);
+ mockIsCouncilEntitledForOwner.mockResolvedValue(false);
});
describe('resolvePullRequestCheckoutRef', () => {
@@ -285,6 +291,80 @@ describe('handlePullRequest', () => {
);
});
+ describe('automated council review type', () => {
+ const councilConfig = {
+ is_enabled: true,
+ config: {
+ council: {
+ enabled: true,
+ aggregation_strategy: 'unanimous',
+ specialists: [
+ {
+ id: 'security',
+ role: 'security',
+ name: 'Security',
+ enabled: true,
+ required: false,
+ lens: 'x',
+ },
+ {
+ id: 'performance',
+ role: 'performance',
+ name: 'Performance',
+ enabled: true,
+ required: false,
+ lens: 'y',
+ },
+ ],
+ },
+ // Repo 123 (acme/widgets, from pullRequestPayload) opted into council.
+ council_enabled_repository_ids: [123],
+ },
+ };
+
+ it('creates a council review when entitled + council active + repo opted in', async () => {
+ mockGetBotUserId.mockResolvedValue('bot-user-1');
+ mockGetAgentConfigForOwner.mockResolvedValue(councilConfig);
+ mockIsCouncilEntitledForOwner.mockResolvedValue(true);
+
+ await handlePullRequest(pullRequestPayload(), platformIntegration());
+
+ expect(mockCreateCodeReview).toHaveBeenCalledWith(
+ expect.objectContaining({ reviewType: 'council', repoFullName: 'acme/widgets' })
+ );
+ });
+
+ it('falls back to standard when the repo has not opted into council (entitlement not queried)', async () => {
+ mockGetBotUserId.mockResolvedValue('bot-user-1');
+ mockGetAgentConfigForOwner.mockResolvedValue({
+ ...councilConfig,
+ config: { ...councilConfig.config, council_enabled_repository_ids: [999] },
+ });
+ mockIsCouncilEntitledForOwner.mockResolvedValue(true);
+
+ await handlePullRequest(pullRequestPayload(), platformIntegration());
+
+ expect(mockCreateCodeReview).toHaveBeenCalledWith(
+ expect.objectContaining({ reviewType: 'standard' })
+ );
+ // Entitlement is only looked up when the repo opted in + council is active.
+ expect(mockIsCouncilEntitledForOwner).not.toHaveBeenCalled();
+ });
+
+ it('falls back to standard when the org is not council-entitled', async () => {
+ mockGetBotUserId.mockResolvedValue('bot-user-1');
+ mockGetAgentConfigForOwner.mockResolvedValue(councilConfig);
+ mockIsCouncilEntitledForOwner.mockResolvedValue(false);
+
+ await handlePullRequest(pullRequestPayload(), platformIntegration());
+
+ expect(mockCreateCodeReview).toHaveBeenCalledWith(
+ expect.objectContaining({ reviewType: 'standard' })
+ );
+ expect(mockIsCouncilEntitledForOwner).toHaveBeenCalled();
+ });
+ });
+
it('cancels superseded DB rows, interrupts queued/running only, and creates the new review', async () => {
mockGetBotUserId.mockResolvedValue('bot-user-1');
mockGetAgentConfigForOwner.mockResolvedValue({
diff --git a/apps/web/src/lib/integrations/platforms/github/webhook-handlers/pull-request-handler.ts b/apps/web/src/lib/integrations/platforms/github/webhook-handlers/pull-request-handler.ts
index a45920ab54..873235b370 100644
--- a/apps/web/src/lib/integrations/platforms/github/webhook-handlers/pull-request-handler.ts
+++ b/apps/web/src/lib/integrations/platforms/github/webhook-handlers/pull-request-handler.ts
@@ -13,6 +13,11 @@ import {
} from '@/lib/code-reviews/db/code-reviews';
import { tryDispatchPendingReviews } from '@/lib/code-reviews/dispatch/dispatch-pending-reviews';
import { getAgentConfigForOwner } from '@/lib/agent-config/db/agent-configs';
+import {
+ determineAutomatedReviewType,
+ isCouncilActive,
+} from '@kilocode/worker-utils/code-review-council';
+import { isCouncilEntitledForOwner } from '@/lib/code-reviews/core/council-entitlement';
import type { PlatformIntegration } from '@kilocode/db/schema';
import type { Owner } from '@/lib/code-reviews/core';
import { getBotUserId } from '@/lib/bot-users/bot-user-service';
@@ -297,9 +302,25 @@ export async function handlePullRequestCodeReview(
);
}
+ // 6b. Decide standard vs council for this automated review. Council is a per-repo opt-in and
+ // requires an active council config + entitlement. The entitlement lookup is a DB call, so it
+ // is gated behind the two cheap local checks (most webhooks are standard and skip it). Falls
+ // back to 'standard' if any condition is missing, so a bad/absent council config never blocks.
+ const councilConfigActive = isCouncilActive(config.council);
+ const councilEnabledForRepo =
+ Array.isArray(config.council_enabled_repository_ids) &&
+ config.council_enabled_repository_ids.includes(repository.id);
+ const councilEntitled =
+ councilConfigActive && councilEnabledForRepo ? await isCouncilEntitledForOwner(owner) : false;
+ const reviewType = determineAutomatedReviewType(
+ { isDraft: pull_request.draft ?? false, author: pull_request.user.login },
+ { councilEntitled, councilConfigActive, councilEnabledForRepo }
+ );
+
// 7. Create review record (session_id will be updated async)
const reviewId = await createCodeReview({
owner,
+ reviewType,
platformIntegrationId: integration.id,
repoFullName: repository.full_name,
prNumber: pull_request.number,
diff --git a/apps/web/src/routers/code-reviews-router.ts b/apps/web/src/routers/code-reviews-router.ts
index cdfb4b63e3..b1c2fbbeed 100644
--- a/apps/web/src/routers/code-reviews-router.ts
+++ b/apps/web/src/routers/code-reviews-router.ts
@@ -213,6 +213,8 @@ export const personalReviewAgentRouter = createTRPCRouter({
selectedRepositoryIds: [],
manuallyAddedRepositories: [],
repositoryModelOverrides: [],
+ council: null,
+ councilEnabledRepositoryIds: [],
disableReviewMd: true,
reviewMemoryEnabled: false,
actionRequired: null,
@@ -245,6 +247,10 @@ export const personalReviewAgentRouter = createTRPCRouter({
thinkingEffort: override.thinking_effort ?? null,
})),
disableReviewMd: cfg.disable_review_md ?? true,
+ // Council is org-only; personal configs never set it, but expose it so the query shape
+ // matches the org query (the form's `configData` is a union of the two).
+ council: cfg.council ?? null,
+ councilEnabledRepositoryIds: cfg.council_enabled_repository_ids ?? [],
reviewMemoryEnabled: getReviewMemoryEnabledFromConfig(config.config),
actionRequired: getCodeReviewActionRequiredState(config),
};
diff --git a/apps/web/src/routers/organizations/organization-code-reviews-router.test.ts b/apps/web/src/routers/organizations/organization-code-reviews-router.test.ts
index c3d5871af4..b019c4a34e 100644
--- a/apps/web/src/routers/organizations/organization-code-reviews-router.test.ts
+++ b/apps/web/src/routers/organizations/organization-code-reviews-router.test.ts
@@ -103,3 +103,78 @@ describe('organization review agent router: toggleReviewAgent', () => {
expect(logs).toEqual([{ message: 'Enabled AI Code Review Agent for github' }]);
});
});
+
+describe('organization review agent router: council config', () => {
+ afterAll(async () => {
+ for (const organizationId of createdOrganizationIds) {
+ await db
+ .delete(organization_audit_logs)
+ .where(eq(organization_audit_logs.organization_id, organizationId));
+ await db
+ .delete(agent_configs)
+ .where(eq(agent_configs.owned_by_organization_id, organizationId));
+ await db.delete(organizations).where(eq(organizations.id, organizationId));
+ }
+ });
+
+ const activeCouncil = {
+ enabled: true as const,
+ aggregation_strategy: 'unanimous' as const,
+ specialists: [
+ {
+ id: 'security',
+ role: 'security' as const,
+ name: 'Security',
+ enabled: true,
+ required: false,
+ lens: 'x',
+ },
+ {
+ id: 'performance',
+ role: 'performance' as const,
+ name: 'Performance',
+ enabled: true,
+ required: false,
+ lens: 'y',
+ },
+ ],
+ };
+
+ it('getReviewConfig exposes council fields (null/empty by default)', async () => {
+ const { owner, organization } = await createFixtureOrganization();
+ const caller = await createCallerForUser(owner.id);
+
+ const cfg = await caller.organizations.reviewAgent.getReviewConfig({
+ organizationId: organization.id,
+ platform: 'github',
+ });
+
+ expect(cfg.council).toBeNull();
+ expect(cfg.councilEnabledRepositoryIds).toEqual([]);
+ });
+
+ it('saves and reloads the council config + per-repo opt-ins for an entitled org', async () => {
+ // The fixture org has the trial bypass, which grants council entitlement (require_seats=false).
+ const { owner, organization } = await createFixtureOrganization();
+ const caller = await createCallerForUser(owner.id);
+
+ await caller.organizations.reviewAgent.saveReviewConfig({
+ organizationId: organization.id,
+ platform: 'github',
+ reviewStyle: 'balanced',
+ focusAreas: [],
+ modelSlug: 'anthropic/claude-sonnet-5',
+ council: activeCouncil,
+ councilEnabledRepositoryIds: [123, 456],
+ });
+
+ const cfg = await caller.organizations.reviewAgent.getReviewConfig({
+ organizationId: organization.id,
+ platform: 'github',
+ });
+ expect(cfg.council?.enabled).toBe(true);
+ expect(cfg.council?.aggregation_strategy).toBe('unanimous');
+ expect(cfg.council?.specialists).toHaveLength(2);
+ expect(cfg.councilEnabledRepositoryIds).toEqual([123, 456]);
+ });
+});
diff --git a/apps/web/src/routers/organizations/organization-code-reviews-router.ts b/apps/web/src/routers/organizations/organization-code-reviews-router.ts
index 8e00d9b5a3..f9cf5a19d7 100644
--- a/apps/web/src/routers/organizations/organization-code-reviews-router.ts
+++ b/apps/web/src/routers/organizations/organization-code-reviews-router.ts
@@ -29,6 +29,8 @@ import { fetchGitLabRepositoriesForOrganization } from '@/lib/cloud-agent/gitlab
import { PRIMARY_DEFAULT_MODEL } from '@/lib/ai-gateway/models';
import { createDefaultCodeReviewConfig } from '@/lib/code-reviews/core/default-config';
import { isCouncilEntitledForOrganization } from '@/lib/code-reviews/core/council-entitlement';
+import { CodeReviewCouncilConfigSchema } from '@kilocode/db/schema-types';
+import { isCouncilActive } from '@kilocode/worker-utils/code-review-council';
import { PLATFORM } from '@/lib/integrations/core/constants';
import { isPlatformIntegrationHealthy } from '@/lib/integrations/core/health';
import {
@@ -132,6 +134,11 @@ const SaveReviewConfigInputSchema = OrganizationIdInputSchema.extend({
.optional(),
disableReviewMd: z.boolean().optional(),
gateThreshold: z.enum(['off', 'all', 'warning', 'critical']).optional(),
+ // Org-level council config (specialists + governance), shared by every council-enabled repo.
+ // `null`/absent leaves council unset. Persisted only for entitled orgs (checked in the handler).
+ council: CodeReviewCouncilConfigSchema.nullable().optional(),
+ // Per-repository council opt-in: repos (by platform id) that run council on automated reviews.
+ councilEnabledRepositoryIds: z.array(z.union([z.number(), z.string()])).optional(),
// GitLab-specific: auto-configure webhooks
autoConfigureWebhooks: z.boolean().optional().default(true),
});
@@ -489,6 +496,8 @@ export const organizationReviewAgentRouter = createTRPCRouter({
selectedRepositoryIds: [],
manuallyAddedRepositories: [],
repositoryModelOverrides: [],
+ council: null,
+ councilEnabledRepositoryIds: [],
disableReviewMd: true,
reviewMemoryEnabled: false,
actionRequired: null,
@@ -530,6 +539,8 @@ export const organizationReviewAgentRouter = createTRPCRouter({
thinkingEffort: override.thinking_effort ?? null,
})),
disableReviewMd: isBitbucket ? true : (cfg.disable_review_md ?? true),
+ council: cfg.council ?? null,
+ councilEnabledRepositoryIds: cfg.council_enabled_repository_ids ?? [],
reviewMemoryEnabled: isBitbucket ? false : getReviewMemoryEnabledFromConfig(config.config),
actionRequired: getCodeReviewActionRequiredState(config),
};
@@ -587,6 +598,20 @@ export const organizationReviewAgentRouter = createTRPCRouter({
thinking_effort: override.thinkingEffort ?? null,
}));
+ // Council config may only be SAVED by council-entitled orgs. This is the authoritative
+ // save-time gate (the UI also hides the section); without it a non-entitled org could
+ // persist an active council that the automated trigger would then honor.
+ const council = input.council ?? undefined;
+ if (council && isCouncilActive(council)) {
+ const entitled = await isCouncilEntitledForOrganization(input.organizationId);
+ if (!entitled) {
+ throw new TRPCError({
+ code: 'FORBIDDEN',
+ message: 'Council review requires an enterprise plan with an active subscription.',
+ });
+ }
+ }
+
await upsertAgentConfig({
organizationId: input.organizationId,
agentType: 'code_review',
@@ -604,6 +629,8 @@ export const organizationReviewAgentRouter = createTRPCRouter({
selected_repository_ids: selectedRepositoryIds,
manually_added_repositories: isBitbucket ? [] : input.manuallyAddedRepositories || [],
repository_model_overrides: repositoryModelOverrides,
+ council,
+ council_enabled_repository_ids: input.councilEnabledRepositoryIds ?? [],
disable_review_md: isBitbucket ? true : (input.disableReviewMd ?? true),
review_memory_enabled: false,
review_analytics_enabled: false,
diff --git a/packages/db/src/schema-types.ts b/packages/db/src/schema-types.ts
index 807e823d89..419edb869b 100644
--- a/packages/db/src/schema-types.ts
+++ b/packages/db/src/schema-types.ts
@@ -1492,6 +1492,12 @@ export const CodeReviewAgentConfigSchema = z.object({
manually_added_repositories: z.array(ManuallyAddedRepositorySchema).optional(),
// Per-repository model overrides. Absent/empty = every repo uses the global model_slug.
repository_model_overrides: z.array(RepositoryModelOverrideSchema).optional(),
+ // Per-repository council opt-in. A repository whose ID is listed here runs the COUNCIL review
+ // type on automated (webhook) reviews, using the single org-level `council` config above.
+ // Absent/empty = no repo runs council automatically. Only meaningful when the org is
+ // council-entitled and `council` is configured + active; the automated trigger re-checks both.
+ // Matched against the platform repository ID the same way as `selected_repository_ids`.
+ council_enabled_repository_ids: z.array(z.union([z.number(), z.string()])).optional(),
disable_review_md: z.boolean().optional(),
// Controls when the PR gate check (GitHub Check Run / GitLab commit status)
// reports a failure based on review findings.
diff --git a/packages/worker-utils/src/code-review-council.test.ts b/packages/worker-utils/src/code-review-council.test.ts
index 590597c101..13eb1c06f0 100644
--- a/packages/worker-utils/src/code-review-council.test.ts
+++ b/packages/worker-utils/src/code-review-council.test.ts
@@ -551,12 +551,34 @@ describe('presets', () => {
});
describe('determineAutomatedReviewType', () => {
- it('is a safe stub that always returns standard', () => {
- expect(determineAutomatedReviewType({}, { councilAvailable: true })).toBe('standard');
+ const ALL_ON = {
+ councilEntitled: true,
+ councilConfigActive: true,
+ councilEnabledForRepo: true,
+ };
+
+ it('returns council only when entitled AND config active AND repo opted in', () => {
+ expect(determineAutomatedReviewType({}, ALL_ON)).toBe('council');
+ });
+
+ it('falls back to standard when any condition is missing (fail-safe)', () => {
+ expect(determineAutomatedReviewType({}, { ...ALL_ON, councilEntitled: false })).toBe(
+ 'standard'
+ );
+ expect(determineAutomatedReviewType({}, { ...ALL_ON, councilConfigActive: false })).toBe(
+ 'standard'
+ );
+ expect(determineAutomatedReviewType({}, { ...ALL_ON, councilEnabledForRepo: false })).toBe(
+ 'standard'
+ );
+ });
+
+ it('does not use SCM-controlled PR facts (a "council" label cannot force council)', () => {
+ // Entitlement/config/opt-in are Kilo-side; a dev-controlled label must not upgrade the type.
expect(
determineAutomatedReviewType(
{ isDraft: false, labels: ['council'], changedFileCount: 40 },
- { councilAvailable: true }
+ { councilEntitled: false, councilConfigActive: false, councilEnabledForRepo: false }
)
).toBe('standard');
});
@@ -622,6 +644,20 @@ describe('buildCouncilReviewSection', () => {
expect(section).toContain('No specialist raised a blocking finding');
});
+ it('states the outvoted block count on a majority pass that still had blocking votes', () => {
+ const section = buildCouncilReviewSection(
+ councilResult('pass', ['block', 'pass', 'pass', 'pass'], 'majority'),
+ { gates: true }
+ );
+ expect(section).toContain('**Decision: Approved** (Majority governance)');
+ expect(section).toContain('1 block, 3 pass');
+ // The contradictory "No specialist raised a blocking finding" must not appear here.
+ expect(section).not.toContain('No specialist raised a blocking finding');
+ expect(section).toContain(
+ '1 specialist raised a blocking finding, but the passing votes carried the Majority decision.'
+ );
+ });
+
it('renders an advisory section with governance info but no merge decision', () => {
const section = buildCouncilReviewSection(councilResult(null, ['pass', 'block'], 'advisory'), {
gates: false,
diff --git a/packages/worker-utils/src/code-review-council.ts b/packages/worker-utils/src/code-review-council.ts
index 29a3fcb960..e67d2f6e17 100644
--- a/packages/worker-utils/src/code-review-council.ts
+++ b/packages/worker-utils/src/code-review-council.ts
@@ -583,7 +583,12 @@ export function buildCouncilReviewSection(
: 'This manual review reports the decision but does not block merge. An automated review with this decision would block merge until the blocking findings are addressed.';
} else if (result.decision === 'pass') {
decisionLine = `**Decision: Approved** (${governanceLabel} governance)`;
- detailLine = 'No specialist raised a blocking finding.';
+ // Under majority governance a pass can still carry blocking votes (they were outvoted); only
+ // claim "no blocking finding" when that is actually true, otherwise state the outvoted count.
+ detailLine =
+ blockVotes > 0
+ ? `${blockVotes} specialist${blockVotes === 1 ? '' : 's'} raised a blocking finding, but the passing votes carried the ${governanceLabel} decision.`
+ : 'No specialist raised a blocking finding.';
} else {
// Advisory: the governance label already reads "Advisory (report only)", so don't repeat it
// in the decision line — the governance-mode line below states it once.
@@ -716,20 +721,30 @@ export type AutomatedReviewPrFacts = {
};
/**
- * Determines the review type for an AUTOMATED (webhook) run from PR facts.
+ * Determines the review type for an AUTOMATED (webhook) run.
*
- * STUB (intentional, phased plan): the real standard-vs-council determination is later
- * work — it must be configured/evaluated Kilo-side and resistant to SCM-side abuse (a
- * dev must not be able to force paid council reviews via a PR label). For now this is a
- * safe stub that always returns `'standard'`, so automated reviews behave exactly as
- * they do today. The plumbing (passing full PR facts + `councilAvailable`) is defined
- * here so the logic can be filled in at the webhook step without further wiring.
+ * Plan A: an automated review runs the COUNCIL type only when ALL of these hold, otherwise it
+ * falls back to `'standard'` (fail-safe — a bad or missing council config never blocks reviews):
+ * - `councilEntitled` — the org is council-entitled (enterprise + active entitlement). This is
+ * the abuse-resistant gate: it is decided Kilo-side, never from an SCM-controlled signal.
+ * - `councilConfigActive` — the org has a configured, active council (>= min specialists).
+ * - `councilEnabledForRepo` — the target repo explicitly opted in
+ * (`config.council_enabled_repository_ids`). Council is per-repo opt-in, not org-wide.
*
- * Manual runs never call this — they carry an explicit user-selected review type.
+ * PR-facts based gating (draft/size/labels) is intentionally NOT applied yet: every PR on an
+ * opted-in repo gets a council review. `prFacts` is threaded through so that policy can be added
+ * here later without re-wiring the webhook handlers. Manual runs never call this — they carry an
+ * explicit user-selected review type.
*/
export function determineAutomatedReviewType(
_prFacts: AutomatedReviewPrFacts,
- _options: { councilAvailable: boolean }
+ options: {
+ councilEntitled: boolean;
+ councilConfigActive: boolean;
+ councilEnabledForRepo: boolean;
+ }
): CodeReviewType {
- return 'standard';
+ const useCouncil =
+ options.councilEntitled && options.councilConfigActive && options.councilEnabledForRepo;
+ return useCouncil ? 'council' : 'standard';
}