Verify-gate uitbreiden: DIVERGENT/PARTIAL vereist agent-acknowledgement (#53)

* feat(schema): add Task.verify_required enum (ALIGNED / ALIGNED_OR_PARTIAL / ANY)

Adds VerifyRequired enum and verify_required field (default ALIGNED_OR_PARTIAL)
to the Task model. Also declares the claude_jobs_status_finished_at_idx index
in the schema to match the live DB. Applied via db execute + migrate resolve.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ui): add verify_required select to TaskDetailDialog

SoloTask interface, solo page mapping, solo store, PATCH route handler
and TaskDetailDialog all updated to expose the three-level verify gate
(ALIGNED / ALIGNED_OR_PARTIAL / ANY) as a native select. Disabled with
DemoTooltip in demo mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-02 17:45:19 +02:00 committed by GitHub
parent d93c91c386
commit ced0a8a4c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 87 additions and 8 deletions

View file

@ -60,6 +60,7 @@ const baseTask: SoloTask = {
sort_order: 1, sort_order: 1,
status: 'TO_DO', status: 'TO_DO',
verify_only: false, verify_only: false,
verify_required: 'ALIGNED_OR_PARTIAL',
story_id: 'story-1', story_id: 'story-1',
story_code: 'ST-100', story_code: 'ST-100',
story_title: 'Test Story', story_title: 'Test Story',

View file

@ -12,6 +12,7 @@ const baseTask = (id: string, overrides: Partial<SoloTask> = {}): SoloTask => ({
sort_order: 1, sort_order: 1,
status: 'TO_DO', status: 'TO_DO',
verify_only: false, verify_only: false,
verify_required: 'ALIGNED_OR_PARTIAL',
story_id: 'story-1', story_id: 'story-1',
story_code: 'ST-100', story_code: 'ST-100',
story_title: 'Original Story', story_title: 'Original Story',

View file

@ -83,6 +83,7 @@ export default async function SoloProductPage({ params }: Props) {
sort_order: t.sort_order, sort_order: t.sort_order,
status: t.status as SoloTask['status'], status: t.status as SoloTask['status'],
verify_only: t.verify_only, verify_only: t.verify_only,
verify_required: t.verify_required as SoloTask['verify_required'],
story_id: t.story.id, story_id: t.story.id,
story_code: t.story.code, story_code: t.story.code,
story_title: t.story.title, story_title: t.story.title,

View file

@ -10,18 +10,22 @@ import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update'
// drive tasks into a state the shared UI can't display. // drive tasks into a state the shared UI can't display.
const PATCHABLE_TASK_STATUS = TASK_STATUS_API_VALUES.filter((s) => s !== 'review') const PATCHABLE_TASK_STATUS = TASK_STATUS_API_VALUES.filter((s) => s !== 'review')
const VERIFY_REQUIRED_VALUES = ['ALIGNED', 'ALIGNED_OR_PARTIAL', 'ANY'] as const
const patchSchema = z const patchSchema = z
.object({ .object({
status: z.enum(PATCHABLE_TASK_STATUS as [string, ...string[]]).optional(), status: z.enum(PATCHABLE_TASK_STATUS as [string, ...string[]]).optional(),
implementation_plan: z.string().optional(), implementation_plan: z.string().optional(),
verify_only: z.boolean().optional(), verify_only: z.boolean().optional(),
verify_required: z.enum(VERIFY_REQUIRED_VALUES).optional(),
}) })
.refine( .refine(
(data) => (data) =>
data.status !== undefined || data.status !== undefined ||
data.implementation_plan !== undefined || data.implementation_plan !== undefined ||
data.verify_only !== undefined, data.verify_only !== undefined ||
{ message: 'Geef minimaal status, implementation_plan of verify_only mee' }, data.verify_required !== undefined,
{ message: 'Geef minimaal status, implementation_plan, verify_only of verify_required mee' },
) )
export async function PATCH( export async function PATCH(
@ -88,19 +92,21 @@ export async function PATCH(
} }
} }
// Combine simple field writes (plan, verify_only) into one update call // Combine simple field writes (plan, verify_only, verify_required) into one update call
const simpleData: { implementation_plan?: string; verify_only?: boolean } = {} const simpleData: { implementation_plan?: string; verify_only?: boolean; verify_required?: typeof VERIFY_REQUIRED_VALUES[number] } = {}
if (parsed.data.implementation_plan !== undefined) if (parsed.data.implementation_plan !== undefined)
simpleData.implementation_plan = parsed.data.implementation_plan simpleData.implementation_plan = parsed.data.implementation_plan
if (parsed.data.verify_only !== undefined) if (parsed.data.verify_only !== undefined)
simpleData.verify_only = parsed.data.verify_only simpleData.verify_only = parsed.data.verify_only
if (parsed.data.verify_required !== undefined)
simpleData.verify_required = parsed.data.verify_required
const updated = await prisma.$transaction(async (tx) => { const updated = await prisma.$transaction(async (tx) => {
const simpleUpdate = Object.keys(simpleData).length > 0 const simpleUpdate = Object.keys(simpleData).length > 0
? await tx.task.update({ ? await tx.task.update({
where: { id }, where: { id },
data: simpleData, data: simpleData,
select: { id: true, status: true, implementation_plan: true, verify_only: true }, select: { id: true, status: true, implementation_plan: true, verify_only: true, verify_required: true },
}) })
: null : null
@ -111,6 +117,7 @@ export async function PATCH(
status: result.task.status, status: result.task.status,
implementation_plan: result.task.implementation_plan, implementation_plan: result.task.implementation_plan,
verify_only: simpleUpdate?.verify_only, verify_only: simpleUpdate?.verify_only,
verify_required: simpleUpdate?.verify_required,
} }
} }
@ -125,5 +132,6 @@ export async function PATCH(
status: taskStatusToApi(updated.status), status: taskStatusToApi(updated.status),
implementation_plan: updated.implementation_plan, implementation_plan: updated.implementation_plan,
...(updated.verify_only !== undefined && { verify_only: updated.verify_only }), ...(updated.verify_only !== undefined && { verify_only: updated.verify_only }),
...(updated.verify_required !== undefined && { verify_required: updated.verify_required }),
}) })
} }

View file

@ -26,6 +26,7 @@ export interface SoloTask {
sort_order: number sort_order: number
status: 'TO_DO' | 'IN_PROGRESS' | 'REVIEW' | 'DONE' status: 'TO_DO' | 'IN_PROGRESS' | 'REVIEW' | 'DONE'
verify_only: boolean verify_only: boolean
verify_required: 'ALIGNED' | 'ALIGNED_OR_PARTIAL' | 'ANY'
story_id: string story_id: string
story_code: string | null story_code: string | null
story_title: string story_title: string

View file

@ -55,16 +55,24 @@ const VERIFY_RESULT_CONFIG: Record<string, { label: string; className: string }>
type SaveState = 'idle' | 'saving' | 'saved' type SaveState = 'idle' | 'saving' | 'saved'
const VERIFY_REQUIRED_LABELS: Record<string, string> = {
ALIGNED: 'Strikt — alleen ALIGNED',
ALIGNED_OR_PARTIAL: 'Standaard — ALIGNED of PARTIAL met uitleg',
ANY: 'Vrij — geen verify-eis',
}
function TaskDetailContent({ task, productId, isDemo, repoUrl, onClose }: TaskDetailContentProps) { function TaskDetailContent({ task, productId, isDemo, repoUrl, onClose }: TaskDetailContentProps) {
const { updatePlan, updateVerifyOnly } = useSoloStore() const { updatePlan, updateVerifyOnly, updateVerifyRequired } = useSoloStore()
const job = useSoloStore(s => s.claudeJobsByTaskId[task.id]) const job = useSoloStore(s => s.claudeJobsByTaskId[task.id])
const connectedWorkers = useSoloStore(s => s.connectedWorkers) const connectedWorkers = useSoloStore(s => s.connectedWorkers)
const [localPlan, setLocalPlan] = useState(task.implementation_plan ?? '') const [localPlan, setLocalPlan] = useState(task.implementation_plan ?? '')
const [localVerifyOnly, setLocalVerifyOnly] = useState(task.verify_only) const [localVerifyOnly, setLocalVerifyOnly] = useState(task.verify_only)
const [localVerifyRequired, setLocalVerifyRequired] = useState(task.verify_required)
const [saveState, setSaveState] = useState<SaveState>('idle') const [saveState, setSaveState] = useState<SaveState>('idle')
const [, startTransition] = useTransition() const [, startTransition] = useTransition()
const [jobPending, startJobTransition] = useTransition() const [jobPending, startJobTransition] = useTransition()
const [verifyOnlyPending, startVerifyOnlyTransition] = useTransition() const [verifyOnlyPending, startVerifyOnlyTransition] = useTransition()
const [verifyRequiredPending, startVerifyRequiredTransition] = useTransition()
const fadeTimer = useRef<ReturnType<typeof setTimeout> | null>(null) const fadeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const savedPlanRef = useRef(task.implementation_plan ?? '') const savedPlanRef = useRef(task.implementation_plan ?? '')
@ -145,6 +153,32 @@ function TaskDetailContent({ task, productId, isDemo, repoUrl, onClose }: TaskDe
}) })
} }
function handleVerifyRequiredChange(e: React.ChangeEvent<HTMLSelectElement>) {
if (isDemo) return
const newValue = e.target.value as typeof localVerifyRequired
const prevValue = localVerifyRequired
setLocalVerifyRequired(newValue)
startVerifyRequiredTransition(async () => {
try {
const res = await fetch(`/api/tasks/${task.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ verify_required: newValue }),
})
if (!res.ok) {
setLocalVerifyRequired(prevValue)
toast.error('Verify-required bijwerken mislukt')
return
}
updateVerifyRequired(task.id, newValue)
} catch {
setLocalVerifyRequired(prevValue)
toast.error('Verify-required bijwerken mislukt')
}
})
}
return ( return (
<> <>
<DialogHeader> <DialogHeader>
@ -220,6 +254,22 @@ function TaskDetailContent({ task, productId, isDemo, repoUrl, onClose }: TaskDe
<span className="text-xs text-muted-foreground">Alleen verifiëren (niet implementeren)</span> <span className="text-xs text-muted-foreground">Alleen verifiëren (niet implementeren)</span>
</div> </div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground shrink-0">Verify-gate:</span>
<DemoTooltip show={isDemo}>
<select
value={localVerifyRequired}
onChange={handleVerifyRequiredChange}
disabled={isDemo || verifyRequiredPending}
className="text-xs rounded-md border border-border bg-surface-container px-2 py-1 text-foreground focus:outline-none focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
>
{(['ALIGNED', 'ALIGNED_OR_PARTIAL', 'ANY'] as const).map(v => (
<option key={v} value={v}>{VERIFY_REQUIRED_LABELS[v]}</option>
))}
</select>
</DemoTooltip>
</div>
<div className="-mx-4 -mb-4 flex flex-wrap items-center gap-2 border-t bg-muted/50 px-4 py-3 rounded-b-xl"> <div className="-mx-4 -mb-4 flex flex-wrap items-center gap-2 border-t bg-muted/50 px-4 py-3 rounded-b-xl">
<Link <Link
href={`/products/${productId}/sprint/planning`} href={`/products/${productId}/sprint/planning`}

View file

@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "VerifyRequired" AS ENUM ('ALIGNED', 'ALIGNED_OR_PARTIAL', 'ANY');
-- AlterTable
ALTER TABLE "tasks" ADD COLUMN "verify_required" "VerifyRequired" NOT NULL DEFAULT 'ALIGNED_OR_PARTIAL';

View file

@ -45,6 +45,12 @@ enum VerifyResult {
DIVERGENT DIVERGENT
} }
enum VerifyRequired {
ALIGNED
ALIGNED_OR_PARTIAL
ANY
}
enum TaskStatus { enum TaskStatus {
TO_DO TO_DO
IN_PROGRESS IN_PROGRESS
@ -246,8 +252,9 @@ model Task {
priority Int priority Int
sort_order Float sort_order Float
status TaskStatus @default(TO_DO) status TaskStatus @default(TO_DO)
verify_only Boolean @default(false) verify_only Boolean @default(false)
created_at DateTime @default(now()) verify_required VerifyRequired @default(ALIGNED_OR_PARTIAL)
created_at DateTime @default(now())
updated_at DateTime @updatedAt updated_at DateTime @updatedAt
claude_questions ClaudeQuestion[] claude_questions ClaudeQuestion[]
claude_jobs ClaudeJob[] claude_jobs ClaudeJob[]
@ -285,6 +292,7 @@ model ClaudeJob {
@@index([user_id, status]) @@index([user_id, status])
@@index([task_id, status]) @@index([task_id, status])
@@index([status, claimed_at]) @@index([status, claimed_at])
@@index([status, finished_at])
@@map("claude_jobs") @@map("claude_jobs")
} }

View file

@ -69,6 +69,7 @@ interface SoloStore {
rollback: (taskId: string, prevStatus: TaskStatus) => void rollback: (taskId: string, prevStatus: TaskStatus) => void
updatePlan: (taskId: string, plan: string | null) => void updatePlan: (taskId: string, plan: string | null) => void
updateVerifyOnly: (taskId: string, value: boolean) => void updateVerifyOnly: (taskId: string, value: boolean) => void
updateVerifyRequired: (taskId: string, value: 'ALIGNED' | 'ALIGNED_OR_PARTIAL' | 'ANY') => void
markPending: (taskId: string) => void markPending: (taskId: string) => void
clearPending: (taskId: string) => void clearPending: (taskId: string) => void
@ -112,6 +113,9 @@ export const useSoloStore = create<SoloStore>((set, get) => ({
updateVerifyOnly: (taskId, value) => updateVerifyOnly: (taskId, value) =>
set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], verify_only: value } } })), set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], verify_only: value } } })),
updateVerifyRequired: (taskId, value) =>
set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], verify_required: value } } })),
markPending: (taskId) => markPending: (taskId) =>
set((s) => { set((s) => {
if (s.pendingOps.has(taskId)) return s if (s.pendingOps.has(taskId)) return s