diff --git a/client/src/components/apps/ClaimReviewerSource.jsx b/client/src/components/apps/ClaimReviewerSource.jsx
new file mode 100644
index 0000000000..e21019dfaa
--- /dev/null
+++ b/client/src/components/apps/ClaimReviewerSource.jsx
@@ -0,0 +1,36 @@
+import { Link } from 'react-router';
+
+/**
+ * Where a claim's reviewer list came from, in one sentence.
+ *
+ * Shared by the two manual claim surfaces because they were already drifting on
+ * the answer: a claim resolves the claim-work task metadata FIRST and only falls
+ * back to the install-wide Code Review Defaults, so a user who changed the
+ * defaults and sees a different chain needs to be sent to whichever one is
+ * actually supplying it — and being sent to the wrong panel is the same class of
+ * confusion as not being told at all.
+ *
+ * The link target is deliberate and verified: the reviewer picker (and the "Use
+ * system Code Review Defaults" reset beside it) is rendered ONLY by
+ * `GlobalConfigControls`, reachable only through Chief of Staff → Schedule's
+ * TaskConfigDrawer. The per-app `claim-work` override the server merges on top
+ * carries reviewer keys too, but the app's Automation tab has no picker for
+ * them — naming it here would send the user to a screen with no such control.
+ */
+export default function ClaimReviewerSource({ source }) {
+ if (source === 'task-override') {
+ return (
+ <>
+ {' — from the '}claim-work {' reviewer override in '}
+ Chief of Staff → Schedule
+ {', not Models → Code Reviewers. Clear it there to follow the install default again.'}
+ >
+ );
+ }
+ return (
+ <>
+ {' — from '}
+ Models → Code Reviewers.
+ >
+ );
+}
diff --git a/client/src/components/apps/SlashDoRunDrawer.jsx b/client/src/components/apps/SlashDoRunDrawer.jsx
index ccbab708d2..6af617d78d 100644
--- a/client/src/components/apps/SlashDoRunDrawer.jsx
+++ b/client/src/components/apps/SlashDoRunDrawer.jsx
@@ -7,6 +7,8 @@ import useProviderModels from '../../hooks/useProviderModels';
import useReviewerModelOptions from '../../hooks/useReviewerModelOptions';
import { reviewerModelsFromDefaults, reviewerEffortsFromDefaults } from '../../lib/reviewerModels';
import { CodeReviewDefaultsProvider, useCodeReviewDefaults } from '../../hooks/useCodeReviewDefaults';
+import useClaimReviewers from '../../hooks/useClaimReviewers';
+import ClaimReviewerSource from './ClaimReviewerSource';
import { isProcessProvider } from '../../utils/providers';
import WorkItemPicker from './WorkItemPicker';
import * as api from '../../services/api';
@@ -30,6 +32,11 @@ const enabledProcessProviderFilter = (p) => Boolean(p?.enabled) && isProcessProv
*/
function SlashDoRunDrawerBody({ open, command, label, appId, appName, onClose, onQueued }) {
const codeReviewDefaults = useCodeReviewDefaults();
+ // What a claim actually resolves for this app — the claim-work override layer
+ // the defaults above cannot see. Only `/do:next` reads reviewers server-side,
+ // so no other command pays for the lookup. `installed` still comes from the
+ // defaults, which is why both are fetched.
+ const claimReviewers = useClaimReviewers(command === 'next' ? appId : null);
// Resolved model lists for the reviewer table's Model column (the picker never
// fetches — see its `modelOptions` prop).
const reviewerModelOptions = useReviewerModelOptions();
@@ -42,19 +49,22 @@ function SlashDoRunDrawerBody({ open, command, label, appId, appName, onClose, o
const [effort, setEffort] = useState('');
const [simplify, setSimplify] = useState(true);
- // Seeded from the install's Code Review Defaults for display. `reviewDirty`
- // gates whether they're SENT — see the component doc.
+ // Seeded from what the RUN will resolve for display. `review` staying null is
+ // what gates whether the fields are SENT — see the component doc.
const [review, setReview] = useState(null);
- // The defaults carry per-reviewer models and efforts as `Model` /
- // `Effort` scalars; the picker takes the token-keyed maps, so fold them
- // in for the seeded (untouched) display.
+ // Seeded display for an untouched picker. It has to show the reviewers the RUN
+ // would resolve, which is not the Code Review Defaults whenever a claim-work
+ // override is in play (see `GET /apps/:id/claim-reviewers`). The defaults are
+ // the fallback for the window before the lookup lands and for one that failed —
+ // they carry per-reviewer pins as `Model` / `Effort`
+ // scalars, which the picker takes as token-keyed maps.
const seededReview = useMemo(
- () => ({
+ () => claimReviewers ?? {
...codeReviewDefaults,
reviewerModels: reviewerModelsFromDefaults(codeReviewDefaults),
reviewerEfforts: reviewerEffortsFromDefaults(codeReviewDefaults),
- }),
- [codeReviewDefaults]
+ },
+ [claimReviewers, codeReviewDefaults]
);
const reviewValue = review ?? seededReview;
@@ -171,6 +181,15 @@ function SlashDoRunDrawerBody({ open, command, label, appId, appName, onClose, o
The claim flow opens and merges its own PR, so these reviewers gate that merge (slashdo --review-with).
{!review && ' Leave them untouched to use this app’s configured reviewers.'}
+ {/* Which layer supplied the seeded list. A claim-work override wins
+ over Models → Code Reviewers silently, so a user who changed the
+ install default and sees a different chain here has to be told
+ where it came from. */}
+ {!review && claimReviewers?.source === 'task-override' && (
+
+ Seeded
+
+ )}
>
)}
diff --git a/client/src/components/apps/SlashDoRunDrawer.test.jsx b/client/src/components/apps/SlashDoRunDrawer.test.jsx
index 2dfbc27bf8..24d4c38df5 100644
--- a/client/src/components/apps/SlashDoRunDrawer.test.jsx
+++ b/client/src/components/apps/SlashDoRunDrawer.test.jsx
@@ -1,6 +1,7 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
+import { MemoryRouter } from 'react-router';
import SlashDoRunDrawer from './SlashDoRunDrawer';
const api = vi.hoisted(() => ({
@@ -9,6 +10,9 @@ const api = vi.hoisted(() => ({
// Backs the reviewer table's Model column (useReviewerModelOptions).
getLocalLlmStatus: vi.fn(),
getAppWorkItems: vi.fn(),
+ // What the RUN resolves — the claim-work override layer getCodeReviewDefaults
+ // cannot see, and what the untouched picker is seeded from.
+ getAppClaimReviewers: vi.fn(),
createSlashdoTask: vi.fn()
}));
@@ -22,17 +26,21 @@ vi.mock('../../services/apiLocalLlm', () => ({
getToolUseModels: vi.fn(() => new Promise(() => {})),
}));
+// Routed: the override note links to the panel that owns the pin, so the drawer
+// needs a router the way it has one in the app.
const renderDrawer = (props = {}) => render(
-
+
+
+
);
describe('SlashDoRunDrawer', () => {
@@ -49,6 +57,10 @@ describe('SlashDoRunDrawer', () => {
reason: 'actionable-issues',
transient: false
});
+ api.getAppClaimReviewers.mockResolvedValue({
+ source: 'defaults', reviewers: ['copilot'], usernames: [], optionalReviewers: [],
+ reviewerMaxRounds: {}, reviewerModels: {}, reviewerEfforts: {}, csv: 'copilot'
+ });
api.createSlashdoTask.mockResolvedValue({ id: 'task-1', status: 'pending' });
});
@@ -70,6 +82,35 @@ describe('SlashDoRunDrawer', () => {
expect(settings.reviewers).toBeUndefined();
});
+ // The bug this seeding fixes: the picker used to display the Code Review
+ // Defaults, which do NOT include the claim-work task override the run resolves
+ // FIRST. A user who had moved the install default to `antigravity` saw
+ // `antigravity` here while every claim actually reviewed with codex + claude.
+ it('seeds the untouched picker from the reviewers the RUN resolves, not the install defaults', async () => {
+ api.getCodeReviewDefaults.mockResolvedValue({ reviewers: ['antigravity'], usernames: [], optionalReviewers: [] });
+ api.getAppClaimReviewers.mockResolvedValue({
+ source: 'task-override', reviewers: ['codex', 'claude'], usernames: [], optionalReviewers: [],
+ reviewerMaxRounds: {}, reviewerModels: {}, reviewerEfforts: {}, csv: 'codex,claude'
+ });
+
+ renderDrawer();
+
+ // Selected reviewers render as Remove buttons; unselected ones as Add.
+ await waitFor(() => expect(screen.getByRole('button', { name: /Remove Codex/ })).toBeInTheDocument());
+ expect(screen.getByRole('button', { name: /Remove Claude/ })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /Remove Antigravity/ })).not.toBeInTheDocument();
+ // …and the user is told WHERE that list comes from, since it isn't the panel
+ // they would go to in order to change it.
+ expect(screen.getByText(/claim-work/)).toBeInTheDocument();
+ });
+
+ it('does not blame a claim-work override when the reviewers came from the install defaults', async () => {
+ renderDrawer();
+
+ await waitFor(() => expect(screen.getByText('Reviewers (in order):')).toBeInTheDocument());
+ expect(screen.queryByText(/claim-work/)).not.toBeInTheDocument();
+ });
+
it('sends the reviewer list only once the user edits it', async () => {
const onQueued = vi.fn();
renderDrawer({ onQueued });
diff --git a/client/src/components/apps/tabs/IssuesTab.jsx b/client/src/components/apps/tabs/IssuesTab.jsx
index 572e0fc817..f01032c1c2 100644
--- a/client/src/components/apps/tabs/IssuesTab.jsx
+++ b/client/src/components/apps/tabs/IssuesTab.jsx
@@ -12,6 +12,8 @@ import ProviderModelSelector from '../../ProviderModelSelector';
import { useThemeContext } from '../../ThemeContext';
import { useCosTaskUpdates } from '../../../hooks/useCosTaskUpdates';
import useProviderModels from '../../../hooks/useProviderModels';
+import useClaimReviewers from '../../../hooks/useClaimReviewers';
+import ClaimReviewerSource from '../ClaimReviewerSource';
import { chipColors } from '../../../lib/chipContrast';
import { isProcessProvider } from '../../../utils/providers';
import * as api from '../../../services/api';
@@ -241,8 +243,10 @@ export default function IssuesTab({ appId, appName }) {
// Page-level provider/model/effort pin for every Claim AND Replan button on this tab —
// left untouched (blank), a claim resolves the install's active provider,
// same as the bare button always did (POST /tasks/slashdo -> resolveAgentProviderAndModel;
- // this manual path does NOT consult the app's scheduled claim-work override —
- // that's a separate resolution used only by the automated claim-work task).
+ // this manual path does NOT consult the app's scheduled claim-work override for
+ // the PROVIDER — that pin is read only by the automated claim-work task).
+ // Scoped to the provider deliberately: the REVIEWERS below do come from that
+ // override, which is precisely the mismatch `claimReviewers` exists to surface.
// This picker never persists across a reload; it's a session convenience for
// "claim the next several issues with model X" without reopening the Agent
// Operations drawer each time.
@@ -252,6 +256,11 @@ export default function IssuesTab({ appId, appName }) {
} = useProviderModels({ filter: enabledProcessProviderFilter, allowDefault: true, silent: true, withEffort: true });
const [effort, setEffort] = useState('');
const [overrideContext, setOverrideContext] = useState('');
+ // The reviewers a Claim launched from this tab will actually run — NOT the
+ // Models → Code Reviewers list, whenever a claim-work override is in play (see
+ // `GET /apps/:id/claim-reviewers`). This tab has no reviewer picker, so it
+ // names them read-only beside the provider pin.
+ const claimReviewers = useClaimReviewers(appId);
// Keep the event-driven path based on the latest runs without putting a
// mutable state snapshot in its effect dependencies. Socket callbacks can
@@ -575,6 +584,20 @@ export default function IssuesTab({ appId, appName }) {
/>
+ {claimReviewers && (
+
+
+ Reviewed by
+
+ {/* No empty-list branch: the route resolves through
+ `claimSafeReviewers`, which falls back to a non-empty list rather
+ than ever handing a claim agent nothing to run. */}
+
+ {claimReviewers.csv}
+
+
+
+ )}
Override context or instructions (optional)
diff --git a/client/src/components/apps/tabs/IssuesTab.test.jsx b/client/src/components/apps/tabs/IssuesTab.test.jsx
index 7750c00c11..b069f5bf9b 100644
--- a/client/src/components/apps/tabs/IssuesTab.test.jsx
+++ b/client/src/components/apps/tabs/IssuesTab.test.jsx
@@ -29,6 +29,9 @@ vi.mock('../../../services/api', () => ({
getAppIssues: vi.fn(),
createSlashdoTask: vi.fn(),
getProviders: vi.fn(),
+ // The tab reads the reviewers a claim will actually run so it can name them
+ // (and say whether a claim-work override supplied them).
+ getAppClaimReviewers: vi.fn(),
}));
import * as api from '../../../services/api';
@@ -77,6 +80,10 @@ beforeEach(() => {
api.getAppIssues.mockResolvedValue(okPayload([ISSUE]));
api.createSlashdoTask.mockResolvedValue({ id: 'task-1' });
api.getProviders.mockResolvedValue({ providers: [] });
+ api.getAppClaimReviewers.mockResolvedValue({
+ source: 'defaults', reviewers: ['antigravity'], usernames: [], optionalReviewers: [],
+ reviewerMaxRounds: {}, reviewerModels: {}, reviewerEfforts: {}, csv: 'antigravity',
+ });
});
afterEach(() => {
@@ -267,6 +274,42 @@ describe('IssuesTab', () => {
expect(screen.getByRole('link', { name: /Active/ })).toBeInTheDocument();
});
+ // This tab offers no reviewer picker, so before it named them the reviewer
+ // chain a Claim would run was invisible here — and it is not the Code
+ // Reviewers panel's list whenever a claim-work override exists. That gap sent
+ // claims to codex + claude for weeks after the install default had been moved.
+ it('names the reviewers a Claim will run, and blames the claim-work override that supplied them', async () => {
+ api.getAppClaimReviewers.mockResolvedValue({
+ source: 'task-override', reviewers: ['codex', 'claude'], usernames: [], optionalReviewers: [],
+ reviewerMaxRounds: {}, reviewerModels: {}, reviewerEfforts: {}, csv: 'codex,claude',
+ });
+
+ await renderTab();
+
+ expect(await screen.findByText('codex,claude')).toBeInTheDocument();
+ expect(screen.getByText(/claim-work/)).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: /Chief of Staff/ })).toHaveAttribute('href', '/cos/schedule');
+ });
+
+ it('points at the Code Reviewers panel when no override is in play', async () => {
+ await renderTab();
+
+ expect(await screen.findByText('antigravity')).toBeInTheDocument();
+ expect(screen.queryByText(/claim-work/)).not.toBeInTheDocument();
+ expect(screen.getByRole('link', { name: /Code Reviewers/ })).toHaveAttribute('href', '/models/code-reviewers');
+ });
+
+ it('says nothing about reviewers when the lookup failed, rather than implying none are configured', async () => {
+ // "couldn't ask" and "nothing configured" must not collapse — an empty chain
+ // rendered here would read as a claim that merges with no review at all.
+ api.getAppClaimReviewers.mockRejectedValue(new Error('offline'));
+
+ await renderTab();
+
+ expect(await screen.findByText('Crash on save')).toBeInTheDocument();
+ expect(screen.queryByText('Reviewed by')).not.toBeInTheDocument();
+ });
+
it('sends the page-level provider/model/effort pin along with a claim', async () => {
api.getProviders.mockResolvedValue({
providers: [{
diff --git a/client/src/components/cos/constants.js b/client/src/components/cos/constants.js
index b2ee6a756d..926f778578 100644
--- a/client/src/components/cos/constants.js
+++ b/client/src/components/cos/constants.js
@@ -335,6 +335,9 @@ export {
MAX_REVIEWER_MAX_ROUNDS,
REVIEW_STOP_MODES,
DEFAULT_REVIEW_STOP_MODE,
+ REVIEWER_OVERRIDE_KEYS,
+ REVIEWER_LIST_OVERRIDE_KEYS,
+ hasReviewerOverride,
normalizeReviewers,
MODEL_CAPABLE_CLI_REVIEWERS,
LOCAL_LLM_REVIEWERS,
diff --git a/client/src/components/cos/tabs/schedule/GlobalConfigControls.jsx b/client/src/components/cos/tabs/schedule/GlobalConfigControls.jsx
index 9ca93ca0bd..a81269b5d7 100644
--- a/client/src/components/cos/tabs/schedule/GlobalConfigControls.jsx
+++ b/client/src/components/cos/tabs/schedule/GlobalConfigControls.jsx
@@ -2,7 +2,7 @@ import { useState, useEffect, useMemo } from 'react';
import useFieldDraft from '../../../../hooks/useFieldDraft';
import { RotateCcw, AlertCircle } from 'lucide-react';
import CronInput from '../../../CronInput';
-import { AGENT_OPTIONS, BRANCHES_PER_AGENT_DEFAULT, BRANCHES_PER_AGENT_OPTIONS, BRANCHES_PER_AGENT_TASK_TYPES, DEFAULT_REVIEW_STOP_MODE, IMPLICIT_PR_COMPLETION, PR_AUTHOR_FILTER_OPTIONS, PR_COMPLETION_OPTIONS, pinnedPrCompletion, prCompletionOption, ISSUE_AUTHOR_FILTER_OPTIONS, ISSUE_AUTHOR_FILTER_TASK_TYPES, SWARM_COUNT_OPTIONS, SWARM_TASK_TYPES } from '../../constants';
+import { AGENT_OPTIONS, BRANCHES_PER_AGENT_DEFAULT, BRANCHES_PER_AGENT_OPTIONS, BRANCHES_PER_AGENT_TASK_TYPES, DEFAULT_REVIEW_STOP_MODE, REVIEWER_OVERRIDE_KEYS as REVIEW_CONFIG_KEYS, IMPLICIT_PR_COMPLETION, PR_AUTHOR_FILTER_OPTIONS, PR_COMPLETION_OPTIONS, pinnedPrCompletion, prCompletionOption, ISSUE_AUTHOR_FILTER_OPTIONS, ISSUE_AUTHOR_FILTER_TASK_TYPES, SWARM_COUNT_OPTIONS, SWARM_TASK_TYPES } from '../../constants';
import ReviewerPicker from '../../ReviewerPicker';
import Banner from '../../../ui/Banner';
import InfoTooltip from '../../../ui/InfoTooltip';
@@ -25,20 +25,14 @@ import { INTERVAL_DESCRIPTIONS, PERPETUAL_DESCRIPTION, toggleMetadataField, pipe
// runs (no app) land on the server-side fallback.
const PR_COMPLETION_INHERIT_HINT = `Uses the target app's "After opening PR" default (Apps → Edit App), or "${prCompletionOption(IMPLICIT_PR_COMPLETION)?.label}" when it has none.`;
-// These fields are the task-local reviewer-loop override. Removing them lets
-// the picker and server resolver fall back to the install-wide Code Review
-// Defaults without changing the task's PR policy or other agent options.
-const REVIEW_CONFIG_KEYS = [
- 'reviewer',
- 'reviewers',
- 'usernames',
- 'optionalReviewers',
- 'reviewerMaxRounds',
- 'reviewerModels',
- 'reviewerEfforts',
- 'reviewStopMode',
- 'reviewerApplies',
-];
+// The task-local reviewer-loop override is REVIEWER_OVERRIDE_KEYS (imported as
+// REVIEW_CONFIG_KEYS above). Removing those keys lets the picker and the server
+// resolver fall back to the install-wide Code Review Defaults without changing
+// the task's PR policy or other agent options.
+//
+// Deliberately the WIDE roster, not `hasReviewerOverride`'s list-bearing subset:
+// the reset clears the two run flags too, so gating its visibility on the subset
+// would leave a stop-mode-only override on screen with no control that removes it.
export default function GlobalConfigControls({ taskType, config, onUpdate, onTrigger, category: _category, providers, providersLoaded = true, activeProviderId, apps, updating, setUpdating, allTaskTypes, improvementDisabled, dataInputCatalog }) {
const reviewDefaults = useCodeReviewDefaults();
@@ -230,9 +224,18 @@ export default function GlobalConfigControls({ taskType, config, onUpdate, onTri
// Reviewers only run under review-then-merge, so the picker hides for the two
// policies that never reach them — but an unpinned ('') task may still inherit
// review-then-merge from its app, so that keeps it.
- const reviewersApply = config.taskMetadata?.openPR
- ? prCompletion === '' || prCompletion === 'review-then-merge'
- : !!config.taskMetadata?.reviewLoop;
+ //
+ // A claimFlow task is unconditional: its PROMPT opens and merges its own PR and
+ // runs the reviewers itself, so the resolved list is operative no matter what
+ // `openPR` / `reviewLoop` say (both are false in the shipped claim metadata).
+ // Without this the picker — and the "Use system Code Review Defaults" reset
+ // beside it — never render for claim-work, leaving a reviewer override that
+ // every claim obeys with no control anywhere that can clear it.
+ const reviewersApply = config.taskMetadata?.claimFlow
+ ? true
+ : config.taskMetadata?.openPR
+ ? prCompletion === '' || prCompletion === 'review-then-merge'
+ : !!config.taskMetadata?.reviewLoop;
// `selectedProvider` / `availableModels` come from useTaskModelPins above — it
// resolves the pin against the active provider, lists Antigravity's BASE models
diff --git a/client/src/components/cos/tabs/schedule/GlobalConfigControls.test.jsx b/client/src/components/cos/tabs/schedule/GlobalConfigControls.test.jsx
index 0ece7f2585..82d13bf51a 100644
--- a/client/src/components/cos/tabs/schedule/GlobalConfigControls.test.jsx
+++ b/client/src/components/cos/tabs/schedule/GlobalConfigControls.test.jsx
@@ -111,6 +111,17 @@ describe('GlobalConfigControls — After opening PR', () => {
expect(screen.getByTestId('reviewer-picker')).toBeInTheDocument();
});
+ it('keeps the reviewer picker for a claim flow, whose shipped metadata sets neither flag', () => {
+ // A claim PROMPT opens and merges its own PR and runs the reviewers itself,
+ // so the resolved list is operative even though `openPR` and `reviewLoop` are
+ // both false — which is exactly the shipped `claim-work` metadata. Hiding the
+ // picker here leaves a reviewer override that every claim obeys with no
+ // control anywhere that can clear it, while the claim surfaces tell the user
+ // to come here and do precisely that.
+ renderControls({ taskType: 'claim-work', taskMetadata: { useWorktree: false, openPR: false, claimFlow: true } });
+ expect(screen.getByTestId('reviewer-picker')).toBeInTheDocument();
+ });
+
it('resets the task review override while preserving unrelated task metadata', () => {
const onUpdate = renderControls({
taskMetadata: {
diff --git a/client/src/hooks/README.md b/client/src/hooks/README.md
index 74710729ab..60db94d25f 100644
--- a/client/src/hooks/README.md
+++ b/client/src/hooks/README.md
@@ -165,6 +165,7 @@ grep -i "what you want to do" client/src/hooks/README.md
| `useTaskModelPins` | Provider/model/effort pins for one CoS scheduled task: optimistic write per change (`'' → null` clears; picking a provider clears model+effort, picking a model clears the effort only when that model has no tiers at all), rollback when `onUpdate` resolves falsy, a `saving` flag to gate "Run Now", plus the derived `availableModels` (Antigravity base models, stale pin kept selectable), `effectiveProviderId` and `defaultProviderLabel` a `ProviderModelSelector` needs. | Any surface that retargets a scheduled task's model (schedule card quick controls, config drawer Global defaults) — don't re-roll the select/PUT/rollback trio. |
| `useCanonPatch` | Optimistic canon-entry patch: rebuild the kind list with one entry mutated, apply locally, PATCH the universe, re-apply the server copy. Targets + staleness-guards on the loaded record's `universe.id` so a mid-flight universe swap can't cross-PATCH or resurrect stale state. `apply` is `setUniverse` or `onUniverseChange`. | Inline canon-field edits on a universe (UniverseCanonSection, NounsStage). Don't re-roll the optimistic-then-confirm dance. |
| `useColorMatch` | Drives a song color-match run: counts the singer in with the metronome, walks the notated score in tempo, grades each note against the live mic pitch (#1022 tracker + colorMatch lib), and exposes `{ running, countingIn, noteColors, summary, activeIndex, start, stop }` for the `` + an accuracy readout. Taps the passed recording stream (no second mic); tears down on stop/unmount. | The Song editor's color-match panel. Don't re-wire the metronome + tracker + grading loop by hand. |
+| `useClaimReviewers` | The reviewers a `/do:next` claim will ACTUALLY run for one app — claim-work task metadata resolved OVER the Code Review Defaults, with `source` naming the layer that won. Stays `resolved: false` on a failed lookup so "couldn't ask" never reads as "nothing configured". | Any claim launcher (SlashDoRunDrawer, IssuesTab). `useCodeReviewDefaults` can't see a claim-work override and would show reviewers the run won't use. |
| `useCodeReviewDefaults` | Global Code Review Defaults (Review Loop reviewer chain + every per-reviewer `Model` / `Effort` pin scalar) via a small Provider/hook pair. | TaskAddForm, ScheduleTab, anywhere a default reviewer picker is shown. |
| `useCatalogTypes` | Catalog ingredient type registry (system + user-defined) merged with the static fallback via a Provider/hook pair; synchronous fallback to the built-in six so first render never blanks. | Catalog list/picker/editor; anywhere the catalog type list/lookup is needed. |
| `useDeathClock` | 1-second countdown for death-clock display. | Mortality / death-clock surfaces. |
diff --git a/client/src/hooks/index.js b/client/src/hooks/index.js
index 3f95bbd845..c7ab19603f 100644
--- a/client/src/hooks/index.js
+++ b/client/src/hooks/index.js
@@ -138,6 +138,7 @@ export * from './useMediaCompletionRefresh.js';
export * from './useOpenClawAttachments.js';
// === Settings-derived shared state ===
+export { default as useClaimReviewers } from './useClaimReviewers.js';
export * from './useCodeReviewDefaults.jsx';
export { default as useCatalogTypes } from './useCatalogTypes.jsx';
export * from './useCatalogTypes.jsx';
diff --git a/client/src/hooks/useClaimReviewers.js b/client/src/hooks/useClaimReviewers.js
new file mode 100644
index 0000000000..16f58f4a54
--- /dev/null
+++ b/client/src/hooks/useClaimReviewers.js
@@ -0,0 +1,41 @@
+import { useEffect, useState } from 'react';
+import * as api from '../services/api';
+
+/**
+ * The reviewers a `/do:next` claim will ACTUALLY run for `appId`, or `null`
+ * while the lookup is in flight or has failed.
+ *
+ * Distinct from `useCodeReviewDefaults`, and the distinction is the point: the
+ * defaults hook reads Models → Code Reviewers, while a claim resolves its
+ * reviewers from the claim-work task metadata FIRST and only falls back to those
+ * defaults. An override there therefore runs a chain the defaults hook cannot
+ * see — which is how a claim reviewed with `codex` while every reviewer control
+ * on screen showed `antigravity`. Seed claim surfaces from here; the payload's
+ * `source` says which layer won.
+ *
+ * `null` rather than an empty chain, because "couldn't ask" and "nothing
+ * configured" must not collapse: an empty reviewer list rendered as fact would
+ * read as a claim that merges with no review at all.
+ *
+ * Fetches once per mount; a claim drawer is mounted only while open, which is
+ * the refresh.
+ */
+export default function useClaimReviewers(appId) {
+ const [value, setValue] = useState(null);
+
+ useEffect(() => {
+ setValue(null);
+ if (!appId) return undefined;
+ let cancelled = false;
+ api.getAppClaimReviewers(appId)
+ .then((data) => {
+ // The one guard that matters: without a list there is nothing to show,
+ // and every other field is shaped by the route from the same resolver.
+ if (!cancelled && Array.isArray(data?.reviewers)) setValue(data);
+ })
+ .catch(() => {});
+ return () => { cancelled = true; };
+ }, [appId]);
+
+ return value;
+}
diff --git a/client/src/lib/reviewerPins.js b/client/src/lib/reviewerPins.js
index 751c91b456..f7c233fe71 100644
--- a/client/src/lib/reviewerPins.js
+++ b/client/src/lib/reviewerPins.js
@@ -191,6 +191,39 @@ export const REVIEW_STOP_MODES = [
];
export const DEFAULT_REVIEW_STOP_MODE = 'all';
+// The task-metadata keys that together form a task-local reviewer override —
+// mirror of REVIEWER_OVERRIDE_KEYS. Everything the picker writes, and so
+// everything its "Use system Code Review Defaults" reset has to remove. A key
+// missing here leaves that reset visible after it has already removed everything
+// it knows about, and leaves the removed key silently pinning a reviewer the
+// user thinks they just cleared.
+export const REVIEWER_OVERRIDE_KEYS = Object.freeze([
+ 'reviewer',
+ 'reviewers',
+ 'usernames',
+ 'optionalReviewers',
+ 'reviewerMaxRounds',
+ 'reviewerModels',
+ 'reviewerEfforts',
+ 'reviewStopMode',
+ 'reviewerApplies',
+]);
+
+// The subset that can actually change the resolved reviewer LIST — mirror of
+// REVIEWER_LIST_OVERRIDE_KEYS. The two run flags are excluded: a claim flow has
+// no slashdo flag string to put them in, so neither changes which reviewers run.
+export const REVIEWER_LIST_OVERRIDE_KEYS = Object.freeze(
+ REVIEWER_OVERRIDE_KEYS.filter((key) => key !== 'reviewStopMode' && key !== 'reviewerApplies')
+);
+
+// Mirror of hasReviewerOverride. Key PRESENCE, not truthiness — an explicitly
+// empty `optionalReviewers: []` is a real override, so `||`-style checks report
+// the wrong answer.
+export function hasReviewerOverride(metadata) {
+ return !!metadata && typeof metadata === 'object' && !Array.isArray(metadata)
+ && REVIEWER_LIST_OVERRIDE_KEYS.some((key) => key in metadata);
+}
+
// Resolve task metadata to an ordered, deduped reviewer list (mirror of the
// server's normalizeReviewers): prefers `reviewers`, falls back to the legacy
// single `reviewer`, defaults to DEFAULT_REVIEWERS.
diff --git a/client/src/services/apiApps.js b/client/src/services/apiApps.js
index 3d24513172..cb553e80c2 100644
--- a/client/src/services/apiApps.js
+++ b/client/src/services/apiApps.js
@@ -33,6 +33,19 @@ export const getAppWorkItems = (id, { issueAuthorFilter } = {}, options) => {
const qs = issueAuthorFilter ? `?issueAuthorFilter=${encodeURIComponent(issueAuthorFilter)}` : '';
return request(`/apps/${id}/work-items${qs}`, { silent: true, ...options });
};
+// The reviewers a `/do:next` claim will ACTUALLY run for this app, resolved
+// server-side through the claim-work task metadata layered OVER the install-wide
+// Code Review Defaults: `{ source, reviewers, usernames, optionalReviewers,
+// reviewerMaxRounds, reviewerModels, reviewerEfforts, csv }`. `source` is
+// 'task-override' when the claim-work override supplied any part of the list,
+// else 'defaults'. Note the fallback is per FIELD — a task pinning only
+// `reviewers` still takes its models and usernames from the defaults — so
+// 'task-override' means "an override is in play", not "the defaults were
+// ignored". Seed a claim surface from THIS, not from getCodeReviewDefaults —
+// the latter can't see the override and so shows reviewers the run won't use.
+// Read-only; callers own their fallback, so default to silent.
+export const getAppClaimReviewers = (id, options) =>
+ request(`/apps/${id}/claim-reviewers`, { silent: true, ...options });
// Every OPEN issue on the forge this app's git origin points at (GitHub via gh,
// GitLab via glab): { forge, fullName, issues: [{ number, title, body, labels,
// assignees, author, url, createdAt, updatedAt }], reason, transient }. Backs the
diff --git a/server/lib/README.md b/server/lib/README.md
index e196918ac0..da7ee06b72 100644
--- a/server/lib/README.md
+++ b/server/lib/README.md
@@ -143,7 +143,7 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub
| `antigravity.js` | Antigravity (`agy`) CLI provider helpers — id/sentinel constants (`ANTIGRAVITY_CLI_ID`, `ANTIGRAVITY_CONFIGURED_DEFAULT`, `LEGACY_GEMINI_*`), `isAntigravityCommand`/`isAntigravityCliProvider` predicates, and `ensureAntigravityPrintArgs(args, {model, effort})`/`ensureAntigravityTuiArgs(args, {model, effort})`/`stripAntigravityUnsupportedArgs` argv normalizers. `parseAntigravityModelList(stdout)` parses `agy models` rows — accepts both the modern `\t` shape and the older bare-id-per-line one, deduped, sentinel dropped (mirrored in the vendored toolkit's `internal/antigravity.js`; used by both the provider-catalog refresh and Image Gen's agy model picker). `isAntigravityModelId(id)` is the same id shape as a bare predicate, for spawn sites building an `agy --model` argv from a value no route schema bounded. The strip drops legacy Gemini `--yolo`/`-m`/`--output-format` but PRESERVES the long `--model` (agy accepts it as a per-session flag, so a user-baked pin is a real selection and suppresses the injected one); the two builders inject `--model`/`--effort` from the per-run overrides, always ahead of the trailing `--print` marker whose value is the prompt. |
| `llmText.js` | Pure LLM-output text helpers — `stripCodeFences` (unfence a model reply) and `parseLLMJSON` (unfence then JSON.parse with a descriptive throw). Below the provider layer, so lib can clean model output without importing provider orchestration. |
| `providerCooldown.js` | Provider bench policy — `resolveProviderBench(analysis)` → `null` (don't bench) / a `usage-limit` marker / an `unavailable` marker with the per-category cooldown from `COOLDOWN_MS_BY_CATEGORY`. Declines to bench request/response-specific categories (`isRequestSpecificCategory` / `isSchemaTypeCategory`) so one bad model id or off-shape response can't take a healthy provider offline. Shared by `services/promptRunner.js` (prompt cascade) and `services/agentFinalization.js` (finished CoS agent run) so both bench a category for the same window. Pure. |
-| `reviewerConfig.js` | Review-Loop reviewer vocabulary (split out of `cosValidation.js`, #5702; Zod-free): the reviewer roster + aliases (`REVIEWER_VALUES` / `REVIEWER_ALIASES` / `DEFAULT_REVIEWER`), the local-LLM / PortOS-only / model-capable / effort-selectable subsets, the slug→CLI-binary map (`REVIEWER_CLI_BINARIES`, `isCliReviewer`, `reviewerCliBinary`), the keyed model/effort/max-rounds pin normalizers + resolvers (`normalizeReviewers`, `normalizeReviewerModels`, `normalizeReviewerEfforts`, `resolveReviewerConfig`, `resolveClaimReviewerConfig`, `KEYED_REVIEWER_PINS`), and the emitters that render a reviewer set into slashdo argv (`buildReviewWithArgs`, `buildReviewersCsv`) and into agent-prompt notes (`buildReviewerPinNote`, `buildReviewerEffortNote`). Re-exported flat by `cosValidation.js`, so existing `validation.js` imports keep resolving. |
+| `reviewerConfig.js` | Review-Loop reviewer vocabulary (split out of `cosValidation.js`, #5702; Zod-free): the reviewer roster + aliases (`REVIEWER_VALUES` / `REVIEWER_ALIASES` / `DEFAULT_REVIEWER`), the local-LLM / PortOS-only / model-capable / effort-selectable subsets, the slug→CLI-binary map (`REVIEWER_CLI_BINARIES`, `isCliReviewer`, `reviewerCliBinary`), the keyed model/effort/max-rounds pin normalizers + resolvers (`normalizeReviewers`, `normalizeReviewerModels`, `normalizeReviewerEfforts`, `resolveReviewerConfig`, `resolveClaimReviewerConfig`, `KEYED_REVIEWER_PINS`), the override-key rosters that say when a task has pinned its own reviewers (`REVIEWER_OVERRIDE_KEYS` — everything the picker writes and its reset clears; `REVIEWER_LIST_OVERRIDE_KEYS` / `hasReviewerOverride` — only the keys that can change the resolved LIST), and the emitters that render a reviewer set into slashdo argv (`buildReviewWithArgs`, `buildReviewersCsv`) and into agent-prompt notes (`buildReviewerPinNote`, `buildReviewerEffortNote`). Re-exported flat by `cosValidation.js`, so existing `validation.js` imports keep resolving. |
| `providerPrerequisites.js` | Can a provider run on this host AT ALL — `providerPrerequisites(provider, { runtime, gatewayKeySet })` → `{ met, missing: [{ code, label }] }` (CLI binary on PATH, API key stored for a public endpoint, the sibling key a gateway-backed wrapper inherits from the API record of its own gateway id unless it carries its own), plus `providerRuntimeKey` (`null` for an API provider, for a command carrying an explicit path, and for a provider with its own `PATH` in `envVars` — the runtime table only answers "does the BARE binary resolve on PortOS's PATH?", which is not those providers' question), `isPrivateNetworkEndpoint` (mirror of the client copy: loopback/RFC1918/tailnet CGNAT + ULA/`.ts.net`/single-label hosts need no key) and `describeMissingPrerequisites`. `runtime: null` = NOT PROBED and never counts as missing. Feeds BOTH `GET /api/providers` and the fallback chain in `aiToolkit/providerStatus.js` (via `services/providerPrerequisites.js`), so a `NEEDS SETUP` card and the router read one computation (#4611). `ROUTING_BLOCKING_CODES`/`blocksRouting(missing)` narrow what ROUTING may act on to the missing binary. Stored, inherited, and environment-backed credential findings stay presentation-only here because the server cannot assume the eventual process environment; the client card classifies sanitized env metadata through tri-state lookups (#4612). Pure. |
| `tuiShellLaunch.js` | `buildTuiShellLaunch(provider)` → `{ commandLine, env }` for launching a TUI provider by hand in a Shell session (the AI Providers card's "Launch in Shell" button). Resolves the command via `tuiHandshake.js#buildTuiInvocation` (so the vendor posture flags and `--model`/`--effort` injection match a real TUI spawn) and the env via `cliChildEnv.js#composeProviderEnv`. **The env is why this is server-side and the deep link carries only a provider ID**: a TUI provider's backend lives in `envVars` (`ANTHROPIC_BASE_URL` for an Ollama-backed or Bedrock `claude`, `OPENCODE_CONFIG_CONTENT` for an OpenCode wrapper), so a shell handed only the command line would run the right binary against the vendor cloud instead of the local daemon the user configured — and those values are secret, so they can't ride a URL. Returns null for a non-TUI provider. Must be given a RAW provider, never a client-sanitized one (redacted `'***'` env reads truthy). |
| `tuiHandshake.js` | Shared TUI invocation + paste-handshake constants. Also owns `SUBMIT_KEY` — the single Enter byte every PTY writer sends, whether it's submitting a pasted TUI prompt or a command PortOS injected into a shell session. It is CR, never LF: a POSIX pty's ICRNL hides the difference, but cmd.exe under Windows ConPTY accepts only CR and leaves an LF-terminated line typed-but-unexecuted at the prompt. |
diff --git a/server/lib/apiRouteCatalog.generated.json b/server/lib/apiRouteCatalog.generated.json
index f368f055ab..9d361c944d 100644
--- a/server/lib/apiRouteCatalog.generated.json
+++ b/server/lib/apiRouteCatalog.generated.json
@@ -966,6 +966,14 @@
"server/routes/apps/lifecycle.js"
]
},
+ {
+ "method": "GET",
+ "path": "/api/apps/:id/claim-reviewers",
+ "mountPath": "/api/apps",
+ "sources": [
+ "server/routes/apps/taskTypes.js"
+ ]
+ },
{
"method": "POST",
"path": "/api/apps/:id/detect-icon",
@@ -17537,8 +17545,8 @@
],
"stats": {
"mounts": 147,
- "operations": 2172,
- "declarations": 2180,
+ "operations": 2173,
+ "declarations": 2181,
"sourceFiles": 230
}
}
diff --git a/server/lib/reviewerConfig.js b/server/lib/reviewerConfig.js
index 6b13e18781..24069b97e4 100644
--- a/server/lib/reviewerConfig.js
+++ b/server/lib/reviewerConfig.js
@@ -178,6 +178,46 @@ export function describeReviewerCli(reviewer) {
export const REVIEW_STOP_MODES = ['all', 'on-findings', 'on-clean'];
export const DEFAULT_REVIEW_STOP_MODE = 'all';
+// The task-metadata keys that TOGETHER form a task-local reviewer override —
+// everything the picker writes, and so everything its "Use system Code Review
+// Defaults" reset has to remove. A key missing here leaves a pin behind after a
+// reset the user believes cleared it.
+// Mirrored in client/src/lib/reviewerPins.js.
+export const REVIEWER_OVERRIDE_KEYS = Object.freeze([
+ // Legacy singular, still stored on schedules saved before the list existed.
+ 'reviewer',
+ 'reviewers',
+ 'usernames',
+ 'optionalReviewers',
+ 'reviewerMaxRounds',
+ 'reviewerModels',
+ 'reviewerEfforts',
+ 'reviewStopMode',
+ 'reviewerApplies',
+]);
+
+// The subset `resolveReviewerConfig` actually READS. `reviewStopMode` and
+// `reviewerApplies` are deliberately absent: they are slashdo run flags, and a
+// claim flow has no flag string to put them in (the claim prompt gets a reviewer
+// CSV), so neither can change which reviewers a claim runs. Keying the reported
+// `source` on the full roster would label a task `task-override` for a stop-mode
+// the user set years ago and then send them to clear an "override" that is not
+// supplying the list they are looking at.
+export const REVIEWER_LIST_OVERRIDE_KEYS = Object.freeze(
+ REVIEWER_OVERRIDE_KEYS.filter((key) => key !== 'reviewStopMode' && key !== 'reviewerApplies')
+);
+
+/**
+ * Does this task metadata pin any part of the reviewer list itself (as opposed
+ * to a run flag)? Key PRESENCE is the signal, not truthiness: an explicitly
+ * empty `optionalReviewers: []` or `reviewerModels: {}` is a real override — it
+ * clears the defaults' value — so collapsing it into "nothing configured" would
+ * report the wrong source.
+ */
+export function hasReviewerOverride(metadata) {
+ return isPlainObject(metadata) && REVIEWER_LIST_OVERRIDE_KEYS.some((key) => key in metadata);
+}
+
// Arbitrary GitHub reviewer usernames (e.g. `@CodeReviewbot`) requested as PR
// reviewers to gate merging — a class distinct from the fixed REVIEWER_VALUES
// enum (which either invoke a CLI, hit the local-LLM endpoint, or request the
diff --git a/server/lib/reviewerConfig.test.js b/server/lib/reviewerConfig.test.js
index e14d087148..8be39203cc 100644
--- a/server/lib/reviewerConfig.test.js
+++ b/server/lib/reviewerConfig.test.js
@@ -34,6 +34,9 @@ import {
PORTOS_ONLY_REVIEWERS,
DEFAULT_REVIEWERS,
REVIEW_STOP_MODES,
+ REVIEWER_OVERRIDE_KEYS,
+ REVIEWER_LIST_OVERRIDE_KEYS,
+ hasReviewerOverride,
DEFAULT_REVIEW_STOP_MODE,
MAX_REVIEW_USERNAMES,
MAX_REVIEWER_MAX_ROUNDS,
@@ -437,6 +440,26 @@ describe('client mirror of the reviewer vocabulary', () => {
expect(client.MAX_REVIEW_USERNAMES).toBe(MAX_REVIEW_USERNAMES);
});
+ // The override roster decides two things that must agree: the picker's "Use
+ // system Code Review Defaults" reset (client) removes exactly these keys, and
+ // the claim-reviewer lookup (server) calls a task an override when any is
+ // present. A key on one side only means the reset leaves a pin behind while
+ // the lookup keeps reporting `task-override` for a task the user just cleared.
+ it('matches the server reviewer-override key rosters and predicate', async () => {
+ const client = await import('../../client/src/lib/reviewerPins.js');
+ expect(client.REVIEWER_OVERRIDE_KEYS).toEqual([...REVIEWER_OVERRIDE_KEYS]);
+ expect(client.REVIEWER_LIST_OVERRIDE_KEYS).toEqual([...REVIEWER_LIST_OVERRIDE_KEYS]);
+ // The predicate too, not just the roster: "presence, not truthiness" is the
+ // half that is easy to get wrong, and a client copy that used `||` would
+ // silently disagree about whether an explicitly-cleared pin is an override.
+ for (const metadata of [
+ { reviewers: ['codex'] }, { optionalReviewers: [] }, { reviewerModels: {} },
+ { reviewStopMode: 'on-clean' }, { reviewerApplies: true }, { claimFlow: true }, null, [],
+ ]) {
+ expect(client.hasReviewerOverride(metadata)).toBe(hasReviewerOverride(metadata));
+ }
+ });
+
// Stop modes: the client rows carry UI copy, but their `value`s are the enum
// the save is validated against. An extra row 400s the whole task update.
it('matches the server review stop modes', async () => {
@@ -789,3 +812,42 @@ describe('codeReviewDefaultsFromProvider', () => {
expect(codeReviewDefaultsFromProvider(null)).toBeNull();
});
});
+
+// `hasReviewerOverride` is what a claim-reviewer lookup reports as its `source`,
+// and the user acts on that answer ("clear the override" vs "change the
+// defaults"). The interesting cases are the ones truthiness gets wrong.
+describe('hasReviewerOverride', () => {
+ it('reports an override for any key that can change the resolved list', async () => {
+ for (const key of REVIEWER_LIST_OVERRIDE_KEYS) {
+ expect(hasReviewerOverride({ [key]: undefined })).toBe(true);
+ }
+ });
+
+ it('ignores the two slashdo run flags — a claim has no flag string to put them in', () => {
+ // `resolveReviewerConfig` never reads them, so a task whose only reviewer key
+ // is a stop-mode resolves its whole list from the defaults. Calling that an
+ // override sends the user to clear a pin that is not supplying what they see.
+ expect(REVIEWER_OVERRIDE_KEYS).toContain('reviewStopMode');
+ expect(hasReviewerOverride({ reviewStopMode: 'on-clean', reviewerApplies: true })).toBe(false);
+ });
+
+ it('treats an explicitly EMPTY pin as an override — it clears the defaults value', () => {
+ // `[]`/`{}` are the shape a user gets by unmarking every optional reviewer or
+ // clearing every model pin. Gating on truthiness would call that "unset" and
+ // send them to the Code Reviewers panel, which is not where the value they
+ // are seeing comes from.
+ expect(hasReviewerOverride({ optionalReviewers: [] })).toBe(true);
+ expect(hasReviewerOverride({ reviewerModels: {} })).toBe(true);
+ });
+
+ it('reports no override for task metadata that only carries non-reviewer options', () => {
+ expect(hasReviewerOverride({ useWorktree: false, openPR: false, claimFlow: true, simplify: true })).toBe(false);
+ expect(hasReviewerOverride({ reviewLoop: true, issueAuthorFilter: 'owner' })).toBe(false);
+ });
+
+ it('reports no override for a non-object', () => {
+ for (const value of [null, undefined, [], 'reviewers', 7]) {
+ expect(hasReviewerOverride(value)).toBe(false);
+ }
+ });
+});
diff --git a/server/routes/apps/taskTypes.js b/server/routes/apps/taskTypes.js
index 5e9b5aa8f4..64a7099a0e 100644
--- a/server/routes/apps/taskTypes.js
+++ b/server/routes/apps/taskTypes.js
@@ -6,6 +6,7 @@
* GET /:id/task-types → { taskTypeOverrides }
* GET /:id/work-tracker → { tracker info }
* GET /:id/work-items → { tracker, items, reason }
+ * GET /:id/claim-reviewers → { source, reviewers, csv, … }
* GET /:id/layered-intelligence → { config, isPortos }
* GET /:id/layered-intelligence/outcomes → { stats, execution, metrics, approvalFunnel, rejections, recent }
* PUT /:id/task-types/all → { success, taskTypeOverrides }
@@ -21,7 +22,7 @@ import * as appsService from '../../services/apps.js';
import { PORTOS_APP_ID } from '../../services/apps.js';
import { sanitizeTaskMetadata, ISSUE_AUTHOR_FILTERS } from '../../lib/validation.js';
import { listWorkItems } from '../../services/workItems.js';
-import { resolveClaimWorkMetadata, resolveClaimAuthorFilter } from '../../services/cosTaskGenerator.js';
+import { resolveClaimWorkMetadata, resolveClaimAuthorFilter, resolveAppClaimReviewers } from '../../services/cosTaskGenerator.js';
import { parseCronToNextRun } from '../../services/eventScheduler.js';
import { INTERVAL_TYPES, decodeIntervalType, isCronExpression, isKnownIntervalType } from '../../services/taskScheduleConstants.js';
import { asyncHandler, ServerError } from '../../lib/errorHandler.js';
@@ -96,6 +97,40 @@ router.get('/:id/work-items', loadApp, asyncHandler(async (req, res) => {
res.json({ appId: app.id, appName: app.name, issueAuthorFilter, ...result });
}));
+// GET /api/apps/:id/claim-reviewers - The reviewers a `/do:next` claim will
+// ACTUALLY run for this app. Resolved by `resolveAppClaimReviewers`, the same
+// function `buildClaimWorkTask` fills the claim prompt's `{reviewers}` token
+// from, so a preview cannot report a chain the run won't use.
+//
+// It exists because the two layers disagree in practice: a claim resolves the
+// claim-work task metadata FIRST and only falls back to the install-wide Code
+// Review Defaults, so a stale override there ran codex + claude long after the
+// user had moved the panel to antigravity — while every reviewer control on
+// screen, seeded from `GET /api/code-review/defaults`, showed antigravity.
+// `source` names the layer that won so the UI can send the user to the right one.
+//
+// Read-only: metadata + settings reads, no claim markers, no LLM call.
+router.get('/:id/claim-reviewers', loadApp, asyncHandler(async (req, res) => {
+ const app = req.loadedApp;
+ const { overridden, reviewers, usernames, optionalReviewers, reviewerMaxRounds, reviewerModels, reviewerEfforts, csv } =
+ await resolveAppClaimReviewers(app);
+ // Spelled out rather than spread: `resolveClaimReviewerConfig` also carries
+ // `stopMode` / `reviewerApplies`, which a claim flow has no flag string to put
+ // them in — publishing them would advertise a contract this route can't keep.
+ res.json({
+ appId: app.id,
+ appName: app.name,
+ source: overridden ? 'task-override' : 'defaults',
+ reviewers,
+ usernames,
+ optionalReviewers,
+ reviewerMaxRounds,
+ reviewerModels,
+ reviewerEfforts,
+ csv
+ });
+}));
+
// GET /api/apps/:id/layered-intelligence - Effective Layered Intelligence config
// for this app (the self-improvement loop). Merges the app's stored partial
// config over the shipped defaults so the UI always renders a complete, safe
diff --git a/server/routes/apps/taskTypes.test.js b/server/routes/apps/taskTypes.test.js
index 9900a276be..1f43156303 100644
--- a/server/routes/apps/taskTypes.test.js
+++ b/server/routes/apps/taskTypes.test.js
@@ -34,13 +34,14 @@ vi.mock('../../services/workItems.js', () => ({
}));
vi.mock('../../services/cosTaskGenerator.js', async (importActual) => ({
...(await importActual()),
- resolveClaimWorkMetadata: vi.fn()
+ resolveClaimWorkMetadata: vi.fn(),
+ resolveAppClaimReviewers: vi.fn()
}));
import * as appsService from '../../services/apps.js';
import { listOutcomesResult } from '../../services/layeredIntelligenceOutcomes.js';
import { listWorkItems } from '../../services/workItems.js';
-import { resolveClaimWorkMetadata } from '../../services/cosTaskGenerator.js';
+import { resolveClaimWorkMetadata, resolveAppClaimReviewers } from '../../services/cosTaskGenerator.js';
describe('Apps Task-Type Routes', () => {
let app;
@@ -115,6 +116,61 @@ describe('Apps Task-Type Routes', () => {
});
});
+ // The route's own job is narrow: map the resolver's `overridden` boolean to the
+ // `source` label the UI acts on, and publish only the fields a claim flow can
+ // honor. The reviewer RESOLUTION it previews (layer precedence, copilot guard,
+ // emitted CSV) belongs to `resolveAppClaimReviewers`, which the claim builder
+ // shares — covered in cosTaskGenerator.test.js and reviewerConfig.test.js.
+ describe('GET /api/apps/:id/claim-reviewers', () => {
+ const RESOLVED = {
+ reviewers: ['codex', 'claude'], usernames: [], optionalReviewers: [],
+ reviewerMaxRounds: {}, reviewerModels: {}, reviewerEfforts: {}, csv: 'codex,claude',
+ // resolveClaimReviewerConfig also carries these two, and a claim flow has no
+ // slashdo flag string to put them in — the route must not publish them.
+ stopMode: 'all', reviewerApplies: false
+ };
+
+ beforeEach(() => {
+ appsService.getAppById.mockResolvedValue({ id: 'app-001', name: 'App' });
+ });
+
+ it('reports `task-override` so the UI sends the user to the override, not the defaults panel', async () => {
+ // The #6202 shape: the install default had been moved to `antigravity`, but a
+ // claim-work override saved months earlier still named codex + claude, so
+ // every manual claim reviewed with those while every reviewer control on
+ // screen showed `antigravity`.
+ resolveAppClaimReviewers.mockResolvedValue({ ...RESOLVED, overridden: true });
+
+ const response = await request(app).get('/api/apps/app-001/claim-reviewers');
+
+ expect(response.status).toBe(200);
+ expect(resolveAppClaimReviewers).toHaveBeenCalledWith({ id: 'app-001', name: 'App' });
+ expect(response.body).toMatchObject({
+ appId: 'app-001', source: 'task-override', reviewers: ['codex', 'claude'], csv: 'codex,claude'
+ });
+ });
+
+ it('reports `defaults` when nothing overrode them', async () => {
+ resolveAppClaimReviewers.mockResolvedValue({
+ ...RESOLVED, overridden: false, reviewers: ['antigravity'], csv: 'antigravity[gemini-3.8-flash]'
+ });
+
+ const response = await request(app).get('/api/apps/app-001/claim-reviewers');
+
+ expect(response.body.source).toBe('defaults');
+ expect(response.body.csv).toBe('antigravity[gemini-3.8-flash]');
+ });
+
+ it('does not publish the run flags a claim flow has nowhere to put', async () => {
+ resolveAppClaimReviewers.mockResolvedValue({ ...RESOLVED, overridden: false });
+
+ const response = await request(app).get('/api/apps/app-001/claim-reviewers');
+
+ expect(response.body.stopMode).toBeUndefined();
+ expect(response.body.reviewerApplies).toBeUndefined();
+ });
+ });
+
describe('GET /api/apps/:id/layered-intelligence', () => {
it('returns the effective config + isPortos flag', async () => {
appsService.getAppById.mockResolvedValue({ id: 'app-001', name: 'App' });
diff --git a/server/services/cosTaskGenerator.js b/server/services/cosTaskGenerator.js
index ce5b149e0f..9577fa3c94 100644
--- a/server/services/cosTaskGenerator.js
+++ b/server/services/cosTaskGenerator.js
@@ -26,7 +26,7 @@
import { readFile } from 'fs/promises';
import { existsSync } from 'fs';
import { join } from 'path';
-import { sanitizeTaskMetadata, PIPELINE_STAGE_BEHAVIOR_FLAGS, MAX_TOTAL_SPAWNS, resolveClaimReviewerConfig, reviewerConfigMetadata } from '../lib/validation.js';
+import { sanitizeTaskMetadata, PIPELINE_STAGE_BEHAVIOR_FLAGS, MAX_TOTAL_SPAWNS, resolveClaimReviewerConfig, reviewerConfigMetadata, hasReviewerOverride } from '../lib/validation.js';
import { PATHS } from '../lib/fileUtils.js';
import { MODEL_ABUSE_GUARD_ID, normalizeEligibilityFacts } from '../lib/modelAbuseGuard.js';
import { isPlainObject } from '../lib/objects.js';
@@ -333,6 +333,53 @@ export function resolveClaimAuthorFilter(explicit, metadata) {
return explicit ?? metadata?.issueAuthorFilter ?? 'self';
}
+/**
+ * The reviewer bundle a claim will run: an explicit option wins per field, then
+ * the app's configured `claim-work` metadata, then the Code Review Defaults. One
+ * resolver for the whole bundle (list + usernames + `~opt` set + the three keyed
+ * pins), so the CSV the prompt names and the `reviewers` the task PERSISTS cannot
+ * disagree. Local-LLM reviewers stay in the operative list; the claim prompt's
+ * appended Local Reviewer Procedure tells the agent how to invoke PortOS's review
+ * service rather than silently replacing the user's configured reviewer.
+ *
+ * Shared with `resolveAppClaimReviewers` (and through it the claim-reviewer
+ * lookup route) precisely so a change to this precedence reaches the preview the
+ * UI shows and the run it previews at the same time — the two drifting apart is
+ * the whole defect that lookup exists to close.
+ */
+function claimReviewersFrom(metadata, codeReviewDefaults, explicit = {}) {
+ const { reviewers, usernames, optionalReviewers, reviewerMaxRounds, reviewerModels, reviewerEfforts } = explicit;
+ return resolveClaimReviewerConfig({
+ ...metadata,
+ reviewers: reviewers !== undefined ? (Array.isArray(reviewers) ? reviewers : [reviewers]) : metadata?.reviewers,
+ usernames: usernames ?? metadata?.usernames,
+ optionalReviewers: optionalReviewers ?? metadata?.optionalReviewers,
+ reviewerMaxRounds: reviewerMaxRounds ?? metadata?.reviewerMaxRounds,
+ reviewerModels: reviewerModels ?? metadata?.reviewerModels,
+ reviewerEfforts: reviewerEfforts ?? metadata?.reviewerEfforts
+ }, codeReviewDefaults, codeReviewDefaults?.reviewers);
+}
+
+/**
+ * What a `/do:next` claim would resolve for `app` right now, without queuing one:
+ * the reviewer bundle plus `overridden`, which says whether the claim-work task
+ * metadata supplied any of the list (so the UI can send the user to the override
+ * rather than to the Code Reviewers panel that isn't in play).
+ *
+ * Reads the same two layers `buildClaimWorkTask` does and hands them to the same
+ * `claimReviewersFrom`, so the preview cannot report a chain the run won't use.
+ */
+export async function resolveAppClaimReviewers(app) {
+ // Independent reads — the resolver needs both, but neither depends on the other.
+ const [{ metadata }, codeReviewDefaults] = await Promise.all([
+ resolveClaimWorkMetadata(app),
+ // A settings read failure means "no configured defaults", never a failed
+ // lookup: the task metadata layer wins over them anyway.
+ getCodeReviewDefaults().catch(() => null)
+ ]);
+ return { ...claimReviewersFrom(metadata, codeReviewDefaults), overridden: hasReviewerOverride(metadata) };
+}
+
/**
* Build a one-off "claim the next work item" task for `app`, routed by the app's
* configured workTracker — the manual (Slashdo `/do:next` button) counterpart to
@@ -399,22 +446,9 @@ export async function buildClaimWorkTask(app, {
const resolvedAuthorFilter = resolveClaimAuthorFilter(issueAuthorFilter, metadata);
- // Reviewers: an explicit option wins per field, then the app's configured
- // claim-work metadata, then the Code Review Defaults. One resolver for the
- // whole bundle (list + usernames + `~opt` set + the three keyed pins), so the
- // CSV the prompt names and the `reviewers` this task PERSISTS below cannot
- // disagree. Local-LLM reviewers stay in the operative list; an appended
- // procedure below tells the claim agent how to invoke PortOS's review service
- // instead of silently replacing the user's configured reviewer.
- const claimReviewers = resolveClaimReviewerConfig({
- ...metadata,
- reviewers: reviewers !== undefined ? (Array.isArray(reviewers) ? reviewers : [reviewers]) : metadata.reviewers,
- usernames: usernames ?? metadata.usernames,
- optionalReviewers: optionalReviewers ?? metadata.optionalReviewers,
- reviewerMaxRounds: reviewerMaxRounds ?? metadata.reviewerMaxRounds,
- reviewerModels: reviewerModels ?? metadata.reviewerModels,
- reviewerEfforts: reviewerEfforts ?? metadata.reviewerEfforts
- }, codeReviewDefaults, codeReviewDefaults?.reviewers);
+ const claimReviewers = claimReviewersFrom(metadata, codeReviewDefaults, {
+ reviewers, usernames, optionalReviewers, reviewerMaxRounds, reviewerModels, reviewerEfforts
+ });
const {
reviewers: reviewersList,
reviewerModels: promptReviewerModels,
diff --git a/server/services/cosTaskGenerator.test.js b/server/services/cosTaskGenerator.test.js
index 1d524cc9af..2a7d7c6979 100644
--- a/server/services/cosTaskGenerator.test.js
+++ b/server/services/cosTaskGenerator.test.js
@@ -77,6 +77,7 @@ import {
recordPerpetualTransient,
buildJiraTicketTask,
buildClaimWorkTask,
+ resolveAppClaimReviewers,
buildImprovementDedupSets,
queueDueInstallWideImprovementTasks,
normalizeWorkItemRef,
@@ -503,7 +504,7 @@ describe('{reviewers} interpolation honors Code Review Defaults', () => {
// run if the CSV carries it. Both the scheduled path and buildClaimWorkTask
// feed the cap into the shared claim resolver, which applies task-over-default
// precedence (unit-tested in reviewerConfig.test.js).
- expect(GEN_SRC).toContain('reviewerMaxRounds: reviewerMaxRounds ?? metadata.reviewerMaxRounds');
+ expect(GEN_SRC).toContain('reviewerMaxRounds: reviewerMaxRounds ?? metadata?.reviewerMaxRounds');
expect(GEN_SRC).toContain('resolveClaimReviewerConfig(metadata, codeReviewDefaults, codeReviewDefaults?.reviewers)');
expect(GEN_SRC).not.toContain('resolveReviewerMaxRounds(');
});
@@ -519,8 +520,8 @@ describe('{reviewers} interpolation honors Code Review Defaults', () => {
// agy model id can carry its effort as a suffix, so a path that resolved the
// models alone would emit `--model --effort `, a pair agy
// rejects, while the other paths emitted the split form.
- expect(GEN_SRC).toContain('reviewerModels: reviewerModels ?? metadata.reviewerModels');
- expect(GEN_SRC).toContain('reviewerEfforts: reviewerEfforts ?? metadata.reviewerEfforts');
+ expect(GEN_SRC).toContain('reviewerModels: reviewerModels ?? metadata?.reviewerModels');
+ expect(GEN_SRC).toContain('reviewerEfforts: reviewerEfforts ?? metadata?.reviewerEfforts');
// The play-button path reads the defaults directly (no task metadata to layer).
expect(GEN_SRC).toContain('resolveClaimReviewerConfig({}, codeReviewDefaults, codeReviewDefaults?.reviewers)');
// No path may resolve one map without the other — or reach past the shared
@@ -603,8 +604,13 @@ describe('claim-work single-source routing', () => {
// Reviewers layer an explicit per-field option over the configured claim-work
// metadata, then fall back to the Code Review Defaults — through the claim
// resolver, which keeps local LLMs and excludes the retired Copilot path.
- expect(fn).toMatch(/resolveClaimReviewerConfig\(\{\s*\.\.\.metadata,/);
- expect(fn).toMatch(/reviewers: reviewers !== undefined/);
+ // `claimReviewersFrom` owns that layering for BOTH the builder and the
+ // claim-reviewer lookup route, so the preview the UI shows and the run it
+ // previews can't resolve differently.
+ expect(fn).toMatch(/claimReviewersFrom\(metadata, codeReviewDefaults, \{/);
+ const layering = GEN_SRC.slice(GEN_SRC.indexOf('function claimReviewersFrom('));
+ expect(layering).toMatch(/resolveClaimReviewerConfig\(\{\s*\.\.\.metadata,/);
+ expect(layering).toMatch(/reviewers: reviewers !== undefined/);
expect(fn).toMatch(/buildLocalReviewerInstructions\(reviewersList/);
// A direct claim-work prompt customization overrides the tracker body, same
// as the scheduled router's promptKeyForBody selection.
@@ -1768,3 +1774,59 @@ describe('buildClaimWorkTask reviewer pin', () => {
expect(taskMetadata.swarmCount).toBeUndefined();
});
});
+
+// The read-only preview behind `GET /api/apps/:id/claim-reviewers`. It shares
+// `claimReviewersFrom` with buildClaimWorkTask on purpose: a preview that
+// resolved differently from the run is the exact defect it exists to close, so
+// these assert the RESOLUTION, not that a mock was called.
+//
+// This file's mocks put the interesting disagreement in place already —
+// claim-work metadata pins `['codex', 'claude']` while the Code Review Defaults
+// say `['ollama']`, which is the #6202 shape.
+describe('resolveAppClaimReviewers', () => {
+ const APP = { id: 'app-1', name: 'App', repoPath: '/repo' };
+
+ it('returns the claim-work override that WINS, and flags it as the source', async () => {
+ const result = await resolveAppClaimReviewers(APP);
+
+ expect(result.reviewers).toEqual(['codex', 'claude']);
+ expect(result.csv).toBe('codex,claude,@alice');
+ // `overridden` is what the UI turns into "clear the override" vs "change the
+ // Code Reviewers panel" — getting it backwards sends the user to a control
+ // that isn't supplying what they're looking at.
+ expect(result.overridden).toBe(true);
+ });
+
+ it('falls through to the Code Review Defaults, unflagged, when claim-work pins no list', async () => {
+ getTaskInterval.mockResolvedValueOnce({ prompt: null, taskMetadata: { issueAuthorFilter: 'owner' } });
+
+ const result = await resolveAppClaimReviewers(APP);
+
+ expect(result.reviewers).toEqual(['ollama']);
+ expect(result.overridden).toBe(false);
+ });
+
+ it('does not call a stop-mode-only task an override — it cannot change the list', async () => {
+ // A claim prompt gets a reviewer CSV, not a slashdo flag string, so
+ // `reviewStopMode` reaches nothing. Reporting it as the source would send the
+ // user to clear an override that is not supplying the reviewers they see.
+ getTaskInterval.mockResolvedValueOnce({ prompt: null, taskMetadata: { reviewStopMode: 'on-clean', reviewerApplies: true } });
+
+ const result = await resolveAppClaimReviewers(APP);
+
+ expect(result.reviewers).toEqual(['ollama']);
+ expect(result.overridden).toBe(false);
+ });
+
+ it('strips copilot, matching the list the claim prompt is actually given', async () => {
+ // claimSafeReviewers: copilot has no CLI, so a claim agent told to review
+ // with it stalls (#2507). The preview must show the post-guard list or it
+ // advertises a reviewer the run silently removes.
+ getTaskInterval.mockResolvedValueOnce({ prompt: null, taskMetadata: { reviewers: ['copilot', 'claude'] } });
+
+ const result = await resolveAppClaimReviewers(APP);
+
+ expect(result.reviewers).toEqual(['claude']);
+ expect(result.overridden).toBe(true);
+ });
+});