scrum4me-mcp/src/tools/verify-sprint-task.ts
Madhura68 25ab68073a PBI-50 F3: nieuwe MCP-tools voor SPRINT_IMPLEMENTATION-flow
Vier nieuwe tools + propagateStatusUpwards uitbreiding:

T1 — verify_sprint_task (src/tools/verify-sprint-task.ts):
  Execution-aware verify met frozen plan_snapshot. Input: execution_id +
  worktree_path + optionele summary (voor PARTIAL/DIVERGENT-rationale).
  Vult base_sha dynamisch voor task[1..N] op basis van vorige DONE-execution's
  head_sha. Schrijft verify_result + verify_summary op execution-row.
  Returns { result, reasoning, base_sha, allowed_for_done, reason? } —
  allowed_for_done via standaard checkVerifyGate met snapshot-velden.

T2 — update_task_execution (src/tools/update-task-execution.ts):
  Lifecycle-tool voor SprintTaskExecution: PENDING/RUNNING/DONE/FAILED/SKIPPED
  + base_sha/head_sha/skip_reason. Idempotent. Token-check via
  execution.sprint_job.claimed_by_token_id. started_at/finished_at automatisch.

T3 — job_heartbeat (src/tools/job-heartbeat.ts):
  Verlengt ClaudeJob.lease_until met 5 min via atomic conditional UPDATE
  (token-check + status-check in WHERE). Voor SPRINT-jobs: response bevat
  sprint_run_status + sprint_run_pause_reason zodat worker op UI-side cancel
  of MERGE_CONFLICT-pause kan breken zonder extra query.

T4 — update_task_status sprint_run_id-arg + token-coupling
  (src/tools/update-task-status.ts):
  Optionele sprint_run_id-arg voor expliciete cascade. Validaties: SprintRun
  bestaat + actief, task in deze sprint, current token heeft een actieve
  ClaudeJob in deze run geclaimd (403 anders). Response uitgebreid met
  sprint_run_status_change.

T5 — propagateStatusUpwards sprintRunId-param
  (src/lib/tasks-status-update.ts):
  Optionele sprintRunId-parameter. Resolve-volgorde: expliciete arg →
  ClaudeJob.task_id-lookup → Story → Sprint → SprintRun.findFirst({active}).
  De derde fallback dekt SPRINT_IMPLEMENTATION (geen task_id-koppeling) én
  handmatige task-statuswijzigingen via UI. cancelExceptJobId voor
  sibling-cancel; null voor SPRINT-job betekent geen siblings te cancellen.

src/index.ts: drie nieuwe tools geregistreerd.

Tests: 31 files, 243 passing (geen tests voor nieuwe tools nog — F5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:40:18 +02:00

151 lines
5.7 KiB
TypeScript

// PBI-50 F3-T1: verify_sprint_task
//
// Execution-aware verify-tool voor SPRINT_IMPLEMENTATION-flow.
// Verschilt van verify_task_against_plan in:
// - input via execution_id (niet task_id)
// - base_sha komt uit SprintTaskExecution.base_sha; voor task[1..N] zonder
// base_sha vult de tool dynamisch met head_sha van vorige DONE-execution
// - plan_snapshot komt uit execution.plan_snapshot (frozen op claim-tijd)
// - resultaat opgeslagen op execution-row, niet op ClaudeJob.verify_result
// - response geeft allowed_for_done direct mee
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { z } from 'zod'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { prisma } from '../prisma.js'
import { requireWriteAccess } from '../auth.js'
import { toolError, toolJson, withToolErrors } from '../errors.js'
import { classifyDiffAgainstPlan } from '../verify/classify.js'
import { checkVerifyGate } from './update-job-status.js'
const exec = promisify(execFile)
const inputSchema = z.object({
execution_id: z.string().min(1),
worktree_path: z.string().min(1),
summary: z.string().max(2000).optional(),
})
export function registerVerifySprintTaskTool(server: McpServer) {
server.registerTool(
'verify_sprint_task',
{
title: 'Verify SprintTaskExecution against frozen plan',
description:
'Run `git diff <base_sha>...HEAD` in the worktree and classify against the ' +
'frozen plan_snapshot of this SprintTaskExecution. Returns ALIGNED|PARTIAL|EMPTY|' +
'DIVERGENT plus reasoning + allowed_for_done (computed via the standard verify-gate ' +
'with the execution\'s frozen verify_required/verify_only). ' +
'For task[1..N] zonder base_sha vult de tool die in op basis van de head_sha van de ' +
'vorige DONE-execution. Optional summary is opgeslagen voor PARTIAL/DIVERGENT-rationale ' +
'en gebruikt door de gate. ' +
'Call this BEFORE update_task_execution(DONE) for each task in the sprint batch. ' +
'Forbidden for demo accounts.',
inputSchema,
annotations: { readOnlyHint: false },
},
async ({ execution_id, worktree_path, summary }) =>
withToolErrors(async () => {
const auth = await requireWriteAccess()
const execution = await prisma.sprintTaskExecution.findUnique({
where: { id: execution_id },
select: {
id: true,
sprint_job_id: true,
order: true,
base_sha: true,
plan_snapshot: true,
verify_required_snapshot: true,
verify_only_snapshot: true,
sprint_job: {
select: { claimed_by_token_id: true, status: true, kind: true },
},
},
})
if (!execution) {
return toolError(`SprintTaskExecution ${execution_id} not found`)
}
if (execution.sprint_job.kind !== 'SPRINT_IMPLEMENTATION') {
return toolError(
`Execution ${execution_id} hangs at job kind ${execution.sprint_job.kind}, expected SPRINT_IMPLEMENTATION`,
)
}
if (execution.sprint_job.claimed_by_token_id !== auth.tokenId) {
return toolError(
`Forbidden: token does not own SPRINT_IMPLEMENTATION job for execution ${execution_id}`,
)
}
// Resolve base_sha. Voor task[0] is dit gevuld bij claim. Voor
// task[1..N] wordt dit dynamisch gevuld op basis van de vorige
// DONE-execution's head_sha. Persist na fill zodat herhaalde calls
// dezelfde base gebruiken.
let baseSha = execution.base_sha
if (!baseSha) {
const previousDone = await prisma.sprintTaskExecution.findFirst({
where: {
sprint_job_id: execution.sprint_job_id,
order: { lt: execution.order },
status: 'DONE',
head_sha: { not: null },
},
orderBy: { order: 'desc' },
select: { head_sha: true },
})
if (!previousDone?.head_sha) {
return toolError(
`MISSING_BASE_SHA: execution ${execution_id} has no base_sha and no previous DONE-execution with head_sha. Did you skip update_task_execution(DONE) on a prior task?`,
)
}
baseSha = previousDone.head_sha
await prisma.sprintTaskExecution.update({
where: { id: execution_id },
data: { base_sha: baseSha },
})
}
let diff: string
try {
const { stdout } = await exec('git', ['diff', `${baseSha}...HEAD`], {
cwd: worktree_path,
})
diff = stdout
} catch (err) {
return toolError(
`git diff failed in worktree (${worktree_path}): ${(err as Error).message ?? 'unknown error'}`,
)
}
const { result, reasoning } = classifyDiffAgainstPlan({
diff,
plan: execution.plan_snapshot,
})
await prisma.sprintTaskExecution.update({
where: { id: execution_id },
data: {
verify_result: result,
...(summary !== undefined ? { verify_summary: summary } : {}),
},
})
const gate = checkVerifyGate(
result,
execution.verify_only_snapshot,
execution.verify_required_snapshot,
summary,
)
return toolJson({
execution_id: execution.id,
result: result.toLowerCase() as 'aligned' | 'partial' | 'empty' | 'divergent',
reasoning,
base_sha: baseSha,
allowed_for_done: gate.allowed,
reason: gate.allowed ? null : gate.error,
})
}),
)
}