feat: DONE gate in update_job_status — reject if verify_result null or EMPTY without verify_only

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-01 13:01:32 +02:00
parent 994f28f103
commit 5cd792a8fe
2 changed files with 73 additions and 0 deletions

View file

@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest'
import { checkVerifyGate } from '../src/tools/update-job-status.js'
describe('checkVerifyGate', () => {
it('rejects when verify_result is null — agent must verify first', () => {
const r = checkVerifyGate(null, false)
expect(r.allowed).toBe(false)
if (!r.allowed) expect(r.error).toMatch(/verify_task_against_plan/i)
})
it('rejects when verify_result is EMPTY and task is not verify_only', () => {
const r = checkVerifyGate('EMPTY', false)
expect(r.allowed).toBe(false)
if (!r.allowed) {
expect(r.error).toMatch(/EMPTY/i)
expect(r.error).toMatch(/verify_only/i)
}
})
it('allows when verify_result is EMPTY and task IS verify_only', () => {
const r = checkVerifyGate('EMPTY', true)
expect(r.allowed).toBe(true)
})
it('allows when verify_result is ALIGNED', () => {
const r = checkVerifyGate('ALIGNED', false)
expect(r.allowed).toBe(true)
})
it('allows when verify_result is PARTIAL', () => {
const r = checkVerifyGate('PARTIAL', false)
expect(r.allowed).toBe(true)
})
it('allows when verify_result is DIVERGENT', () => {
const r = checkVerifyGate('DIVERGENT', false)
expect(r.allowed).toBe(true)
})
})

View file

@ -91,6 +91,27 @@ export async function prepareDoneUpdate(
} }
} }
export function checkVerifyGate(
verifyResult: string | null,
verifyOnly: boolean,
): { allowed: true } | { allowed: false; error: string } {
if (verifyResult === null) {
return {
allowed: false,
error: 'Roep eerst verify_task_against_plan aan voordat je DONE markeert.',
}
}
if (verifyResult === 'EMPTY' && !verifyOnly) {
return {
allowed: false,
error:
'Plan-vs-implementatie verify gaf EMPTY. Geen wijzigingen gedetecteerd. ' +
'Markeer de task als verify_only of pas de implementatie aan.',
}
}
return { allowed: true }
}
const DB_STATUS_MAP = { const DB_STATUS_MAP = {
running: 'RUNNING', running: 'RUNNING',
done: 'DONE', done: 'DONE',
@ -140,6 +161,8 @@ export function registerUpdateJobStatusTool(server: McpServer) {
'Report progress on a claimed ClaudeJob. Allowed transitions from CLAIMED/RUNNING: ' + 'Report progress on a claimed ClaudeJob. Allowed transitions from CLAIMED/RUNNING: ' +
'running (start), done (finished), failed (error). ' + 'running (start), done (finished), failed (error). ' +
'The Bearer token must match the token that claimed the job. ' + 'The Bearer token must match the token that claimed the job. ' +
'Before marking done: call verify_task_against_plan first — done is rejected when ' +
'verify_result is null or EMPTY (unless task.verify_only is true). ' +
'Automatically emits an SSE event so the Scrum4Me UI updates in real time.', 'Automatically emits an SSE event so the Scrum4Me UI updates in real time.',
inputSchema, inputSchema,
}, },
@ -157,6 +180,8 @@ export function registerUpdateJobStatusTool(server: McpServer) {
user_id: true, user_id: true,
product_id: true, product_id: true,
task_id: true, task_id: true,
verify_result: true,
task: { select: { verify_only: true } },
}, },
}) })
@ -176,6 +201,12 @@ export function registerUpdateJobStatusTool(server: McpServer) {
let skipWorktreeCleanup = false let skipWorktreeCleanup = false
if (status === 'done') { if (status === 'done') {
const gate = checkVerifyGate(
job.verify_result ?? null,
job.task?.verify_only ?? false,
)
if (!gate.allowed) return toolError(gate.error)
const plan = await prepareDoneUpdate(job_id, branch) const plan = await prepareDoneUpdate(job_id, branch)
actualStatus = plan.dbStatus === 'DONE' ? 'done' : 'failed' actualStatus = plan.dbStatus === 'DONE' ? 'done' : 'failed'
pushedAt = plan.pushedAt pushedAt = plan.pushedAt
@ -223,6 +254,7 @@ export function registerUpdateJobStatusTool(server: McpServer) {
branch: true, branch: true,
pushed_at: true, pushed_at: true,
pr_url: true, pr_url: true,
verify_result: true,
summary: true, summary: true,
error: true, error: true,
started_at: true, started_at: true,
@ -247,6 +279,7 @@ export function registerUpdateJobStatusTool(server: McpServer) {
branch: updated.branch ?? undefined, branch: updated.branch ?? undefined,
pushed_at: updated.pushed_at?.toISOString() ?? undefined, pushed_at: updated.pushed_at?.toISOString() ?? undefined,
pr_url: updated.pr_url ?? undefined, pr_url: updated.pr_url ?? undefined,
verify_result: updated.verify_result?.toLowerCase() ?? undefined,
summary: updated.summary ?? undefined, summary: updated.summary ?? undefined,
error: updated.error ?? undefined, error: updated.error ?? undefined,
}), }),
@ -268,6 +301,7 @@ export function registerUpdateJobStatusTool(server: McpServer) {
branch: updated.branch, branch: updated.branch,
pushed_at: updated.pushed_at?.toISOString() ?? null, pushed_at: updated.pushed_at?.toISOString() ?? null,
pr_url: updated.pr_url ?? null, pr_url: updated.pr_url ?? null,
verify_result: updated.verify_result?.toLowerCase() ?? null,
summary: updated.summary, summary: updated.summary,
error: updated.error, error: updated.error,
started_at: updated.started_at?.toISOString() ?? null, started_at: updated.started_at?.toISOString() ?? null,