From 5cd792a8fecf38c23e56337f50ab1307458434db Mon Sep 17 00:00:00 2001 From: janpeter visser Date: Fri, 1 May 2026 13:01:32 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20DONE=20gate=20in=20update=5Fjob=5Fstatu?= =?UTF-8?q?s=20=E2=80=94=20reject=20if=20verify=5Fresult=20null=20or=20EMP?= =?UTF-8?q?TY=20without=20verify=5Fonly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- __tests__/update-job-status-gate.test.ts | 39 ++++++++++++++++++++++++ src/tools/update-job-status.ts | 34 +++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 __tests__/update-job-status-gate.test.ts diff --git a/__tests__/update-job-status-gate.test.ts b/__tests__/update-job-status-gate.test.ts new file mode 100644 index 0000000..4a2b07e --- /dev/null +++ b/__tests__/update-job-status-gate.test.ts @@ -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) + }) +}) diff --git a/src/tools/update-job-status.ts b/src/tools/update-job-status.ts index fb5fe17..614da29 100644 --- a/src/tools/update-job-status.ts +++ b/src/tools/update-job-status.ts @@ -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 = { running: 'RUNNING', done: 'DONE', @@ -140,6 +161,8 @@ export function registerUpdateJobStatusTool(server: McpServer) { 'Report progress on a claimed ClaudeJob. Allowed transitions from CLAIMED/RUNNING: ' + 'running (start), done (finished), failed (error). ' + '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.', inputSchema, }, @@ -157,6 +180,8 @@ export function registerUpdateJobStatusTool(server: McpServer) { user_id: true, product_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 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) actualStatus = plan.dbStatus === 'DONE' ? 'done' : 'failed' pushedAt = plan.pushedAt @@ -223,6 +254,7 @@ export function registerUpdateJobStatusTool(server: McpServer) { branch: true, pushed_at: true, pr_url: true, + verify_result: true, summary: true, error: true, started_at: true, @@ -247,6 +279,7 @@ export function registerUpdateJobStatusTool(server: McpServer) { branch: updated.branch ?? undefined, pushed_at: updated.pushed_at?.toISOString() ?? undefined, pr_url: updated.pr_url ?? undefined, + verify_result: updated.verify_result?.toLowerCase() ?? undefined, summary: updated.summary ?? undefined, error: updated.error ?? undefined, }), @@ -268,6 +301,7 @@ export function registerUpdateJobStatusTool(server: McpServer) { branch: updated.branch, pushed_at: updated.pushed_at?.toISOString() ?? null, pr_url: updated.pr_url ?? null, + verify_result: updated.verify_result?.toLowerCase() ?? null, summary: updated.summary, error: updated.error, started_at: updated.started_at?.toISOString() ?? null,