From de6bbd4edd29d79ff4657caa7bf7fd33d3d1e449 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Thu, 7 May 2026 12:27:48 +0200 Subject: [PATCH 01/27] PBI-50 F2-T1: claim-filter kind-based + lease_until persisten Schema-sync vanaf Scrum4Me (PBI-50 F1): - PrStrategy.SPRINT_BATCH, ClaudeJobKind.SPRINT_IMPLEMENTATION - enum SprintTaskExecutionStatus, model SprintTaskExecution - ClaudeJob.lease_until + status_lease_until index - SprintRun.previous_run_id (self-relation) tryClaimJob in src/tools/wait-for-job.ts: - WHERE-clause refactor naar kind-based discriminatie. NULL-checks vervangen door expliciete `cj.kind IN (...)`. SPRINT_IMPLEMENTATION en TASK_IMPLEMENTATION vereisen beide actieve SprintRun (QUEUED/RUNNING). Idea-kinds blijven standalone claimable. - UPDATE op claim zet `lease_until = NOW() + INTERVAL '5 minutes'`. Tests: 19 wait-for-job tests groen. Co-Authored-By: Claude Opus 4.7 (1M context) --- prisma/schema.prisma | 152 +++++++++++++++++++++++++------------- src/tools/wait-for-job.ts | 28 ++++--- 2 files changed, 118 insertions(+), 62 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dce449e..9766b6f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -87,6 +87,7 @@ enum SprintRunStatus { enum PrStrategy { SPRINT STORY + SPRINT_BATCH } enum IdeaStatus { @@ -105,6 +106,15 @@ enum ClaudeJobKind { IDEA_GRILL IDEA_MAKE_PLAN PLAN_CHAT + SPRINT_IMPLEMENTATION +} + +enum SprintTaskExecutionStatus { + PENDING + RUNNING + DONE + FAILED + SKIPPED } enum IdeaLogType { @@ -299,24 +309,27 @@ model Sprint { } model SprintRun { - id String @id @default(cuid()) - sprint Sprint @relation(fields: [sprint_id], references: [id], onDelete: Cascade) - sprint_id String - started_by User @relation("SprintRunStartedBy", fields: [started_by_id], references: [id]) - started_by_id String - status SprintRunStatus @default(QUEUED) - pr_strategy PrStrategy - branch String? - pr_url String? - started_at DateTime? - finished_at DateTime? - failure_reason String? - failed_task Task? @relation("SprintRunFailedTask", fields: [failed_task_id], references: [id], onDelete: SetNull) - failed_task_id String? - pause_context Json? - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - jobs ClaudeJob[] + id String @id @default(cuid()) + sprint Sprint @relation(fields: [sprint_id], references: [id], onDelete: Cascade) + sprint_id String + started_by User @relation("SprintRunStartedBy", fields: [started_by_id], references: [id]) + started_by_id String + status SprintRunStatus @default(QUEUED) + pr_strategy PrStrategy + branch String? + pr_url String? + started_at DateTime? + finished_at DateTime? + failure_reason String? + failed_task Task? @relation("SprintRunFailedTask", fields: [failed_task_id], references: [id], onDelete: SetNull) + failed_task_id String? + pause_context Json? + previous_run_id String? @unique + previous_run SprintRun? @relation("SprintRunChain", fields: [previous_run_id], references: [id], onDelete: SetNull) + next_run SprintRun? @relation("SprintRunChain") + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + jobs ClaudeJob[] @@index([sprint_id, status]) @@index([started_by_id, status]) @@ -324,32 +337,33 @@ model SprintRun { } model Task { - id String @id @default(cuid()) - story Story @relation(fields: [story_id], references: [id], onDelete: Cascade) - story_id String - product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) - product_id String - sprint Sprint? @relation(fields: [sprint_id], references: [id]) - sprint_id String? - code String @db.VarChar(30) - title String - description String? - implementation_plan String? - priority Int - sort_order Float - status TaskStatus @default(TO_DO) - verify_only Boolean @default(false) - verify_required VerifyRequired @default(ALIGNED_OR_PARTIAL) + id String @id @default(cuid()) + story Story @relation(fields: [story_id], references: [id], onDelete: Cascade) + story_id String + product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) + product_id String + sprint Sprint? @relation(fields: [sprint_id], references: [id]) + sprint_id String? + code String @db.VarChar(30) + title String + description String? + implementation_plan String? + priority Int + sort_order Float + status TaskStatus @default(TO_DO) + verify_only Boolean @default(false) + verify_required VerifyRequired @default(ALIGNED_OR_PARTIAL) // Override product.repo_url for branch/worktree/push purposes. Set when // a task targets a different repo than its parent product (e.g. an // MCP-server task tracked under the main product's PBI). Falls back to // product.repo_url when null. - repo_url String? - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - claude_questions ClaudeQuestion[] - claude_jobs ClaudeJob[] - sprint_run_failures SprintRun[] @relation("SprintRunFailedTask") + repo_url String? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + claude_questions ClaudeQuestion[] + claude_jobs ClaudeJob[] + sprint_run_failures SprintRun[] @relation("SprintRunFailedTask") + sprint_task_executions SprintTaskExecution[] @@unique([product_id, code]) @@index([story_id, priority, sort_order]) @@ -359,20 +373,20 @@ model Task { } model ClaudeJob { - id String @id @default(cuid()) - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) user_id String - product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) + product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) product_id String - task Task? @relation(fields: [task_id], references: [id], onDelete: Cascade) + task Task? @relation(fields: [task_id], references: [id], onDelete: Cascade) task_id String? - idea Idea? @relation(fields: [idea_id], references: [id], onDelete: Cascade) + idea Idea? @relation(fields: [idea_id], references: [id], onDelete: Cascade) idea_id String? - sprint_run SprintRun? @relation(fields: [sprint_run_id], references: [id], onDelete: SetNull) + sprint_run SprintRun? @relation(fields: [sprint_run_id], references: [id], onDelete: SetNull) sprint_run_id String? - kind ClaudeJobKind @default(TASK_IMPLEMENTATION) - status ClaudeJobStatus @default(QUEUED) - claimed_by_token ApiToken? @relation(fields: [claimed_by_token_id], references: [id], onDelete: SetNull) + kind ClaudeJobKind @default(TASK_IMPLEMENTATION) + status ClaudeJobStatus @default(QUEUED) + claimed_by_token ApiToken? @relation(fields: [claimed_by_token_id], references: [id], onDelete: SetNull) claimed_by_token_id String? claimed_at DateTime? started_at DateTime? @@ -391,9 +405,11 @@ model ClaudeJob { pr_url String? summary String? error String? - retry_count Int @default(0) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt + retry_count Int @default(0) + lease_until DateTime? + task_executions SprintTaskExecution[] @relation("SprintJobExecutions") + created_at DateTime @default(now()) + updated_at DateTime @updatedAt @@index([user_id, status]) @@index([task_id, status]) @@ -401,9 +417,41 @@ model ClaudeJob { @@index([sprint_run_id, status]) @@index([status, claimed_at]) @@index([status, finished_at]) + @@index([status, lease_until]) @@map("claude_jobs") } +// PBI-50: frozen scope-snapshot per SPRINT_IMPLEMENTATION-claim. Bij claim +// wordt voor elke TO_DO-task in scope één PENDING-record gemaakt met +// implementation_plan + verify_required gesnapshot. Worker en gate werken +// uitsluitend op deze rows; latere wijzigingen aan Task hebben geen +// invloed op de lopende batch. +model SprintTaskExecution { + id String @id @default(cuid()) + sprint_job ClaudeJob @relation("SprintJobExecutions", fields: [sprint_job_id], references: [id], onDelete: Cascade) + sprint_job_id String + task Task @relation(fields: [task_id], references: [id], onDelete: Cascade) + task_id String + order Int + plan_snapshot String @db.Text + verify_required_snapshot VerifyRequired + verify_only_snapshot Boolean @default(false) + base_sha String? + head_sha String? + status SprintTaskExecutionStatus @default(PENDING) + verify_result VerifyResult? + verify_summary String? @db.Text + skip_reason String? @db.Text + started_at DateTime? + finished_at DateTime? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@unique([sprint_job_id, task_id]) + @@index([sprint_job_id, order]) + @@map("sprint_task_executions") +} + model ModelPrice { id String @id @default(cuid()) model_id String @unique diff --git a/src/tools/wait-for-job.ts b/src/tools/wait-for-job.ts index 5741ec5..d05e9f8 100644 --- a/src/tools/wait-for-job.ts +++ b/src/tools/wait-for-job.ts @@ -308,12 +308,15 @@ export async function tryClaimJob( ): Promise { // Atomic claim in a single transaction — also captures plan_snapshot from task. // - // Sprint-flow filter (PBI-46): - // Idea-jobs (task_id IS NULL) blijven onafhankelijk claimable. - // Task-jobs zijn alleen claimable wanneer ze aan een actieve SprintRun - // hangen (status QUEUED of RUNNING). Legacy task-jobs zonder sprint_run_id - // en jobs in PAUSED/FAILED/CANCELLED/DONE SprintRuns worden overgeslagen. + // PBI-50: claim-filter discrimineert via cj.kind: + // - IDEA_GRILL/IDEA_MAKE_PLAN/PLAN_CHAT: standalone idea-jobs. + // - TASK_IMPLEMENTATION/SPRINT_IMPLEMENTATION: alleen via actieve SprintRun + // (status QUEUED of RUNNING). Legacy task-jobs zonder sprint_run_id en + // jobs in PAUSED/FAILED/CANCELLED/DONE SprintRuns worden overgeslagen. // Bij eerste claim van een nog QUEUED SprintRun → status RUNNING. + // + // PBI-50 lease: lease_until = NOW() + 5min op claim. resetStaleClaimedJobs + // reset bij verlopen lease. const rows = await prisma.$transaction(async (tx) => { const found = productId ? await tx.$queryRaw< @@ -327,8 +330,10 @@ export async function tryClaimJob( AND cj.product_id = ${productId} AND cj.status = 'QUEUED' AND ( - cj.task_id IS NULL - OR (cj.sprint_run_id IS NOT NULL AND sr.status IN ('QUEUED', 'RUNNING')) + cj.kind IN ('IDEA_GRILL', 'IDEA_MAKE_PLAN', 'PLAN_CHAT') + OR (cj.kind IN ('TASK_IMPLEMENTATION', 'SPRINT_IMPLEMENTATION') + AND cj.sprint_run_id IS NOT NULL + AND sr.status IN ('QUEUED', 'RUNNING')) ) ORDER BY cj.created_at ASC LIMIT 1 @@ -344,8 +349,10 @@ export async function tryClaimJob( WHERE cj.user_id = ${userId} AND cj.status = 'QUEUED' AND ( - cj.task_id IS NULL - OR (cj.sprint_run_id IS NOT NULL AND sr.status IN ('QUEUED', 'RUNNING')) + cj.kind IN ('IDEA_GRILL', 'IDEA_MAKE_PLAN', 'PLAN_CHAT') + OR (cj.kind IN ('TASK_IMPLEMENTATION', 'SPRINT_IMPLEMENTATION') + AND cj.sprint_run_id IS NOT NULL + AND sr.status IN ('QUEUED', 'RUNNING')) ) ORDER BY cj.created_at ASC LIMIT 1 @@ -362,7 +369,8 @@ export async function tryClaimJob( SET status = 'CLAIMED', claimed_by_token_id = ${tokenId}, claimed_at = NOW(), - plan_snapshot = ${snapshot} + plan_snapshot = ${snapshot}, + lease_until = NOW() + INTERVAL '5 minutes' WHERE id = ${jobId} ` From 35601e8e4bc976ff8f7cb2885402cfd4700ede3d Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Thu, 7 May 2026 12:33:55 +0200 Subject: [PATCH 02/27] PBI-50 F2-T2/T3: SPRINT_IMPLEMENTATION-pad in getFullJobContext + lease-driven stale-reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F2-T2 — getFullJobContext branche voor `kind === 'SPRINT_IMPLEMENTATION'`: - Fetch sprint_run met deep include (sprint → product + stories → pbi + tasks). - resolveRepoRoot via product; rollbackClaim bij faal. - Branch-resolutie: previous_run_id + branch → reuse (resume-pad), anders verse `feat/sprint-`. createWorktreeForJob met juiste reuseBranch-flag. - Capture base_sha via `git rev-parse HEAD` na worktree-add. - Frozen scope-snapshot: SprintTaskExecution.createMany met plan_snapshot, verify_required_snapshot, verify_only_snapshot per task in scope. Order is PBI→Story→Task. base_sha alleen op task[0] (rest fillt verify-tool). - Update job.branch + job.base_sha + sprint_run.branch in één transactie. - Lookup execution_ids voor response shape. F2-T3 — resetStaleClaimedJobs lease-driven: - WHERE-clause uitgebreid naar `status IN ('CLAIMED','RUNNING')` met OR-clause `lease_until < NOW() OR (lease_until IS NULL AND claimed_at < NOW() - 30min)`. Legacy jobs zonder lease blijven via claimed_at-pad werken; nieuwe jobs via lease_until. - RETURNING uitgebreid met kind, sprint_run_id, branch. - Bij stale FAILED SPRINT_IMPLEMENTATION: push branch (geen mark-ready, geen PR-promotie) zodat werk niet verloren gaat. Vul SprintRun.failure_reason met laatst-RUNNING execution voor diagnose. Imports: getWorktreeRoot uit worktree-paths.js, pushBranchForJob uit push.js. Tests: 31 files, 243 passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tools/wait-for-job.ts | 250 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 237 insertions(+), 13 deletions(-) diff --git a/src/tools/wait-for-job.ts b/src/tools/wait-for-job.ts index d05e9f8..1323e50 100644 --- a/src/tools/wait-for-job.ts +++ b/src/tools/wait-for-job.ts @@ -15,7 +15,9 @@ const execFileP = promisify(execFile) import { requireWriteAccess } from '../auth.js' import { toolJson, toolError, withToolErrors } from '../errors.js' import { createWorktreeForJob } from '../git/worktree.js' +import { getWorktreeRoot } from '../git/worktree-paths.js' import { setupProductWorktrees, releaseLocksOnTerminal } from '../git/job-locks.js' +import { pushBranchForJob } from '../git/push.js' /** Parse `https://github.com//(.git)?` → ``. */ export function repoNameFromUrl(repoUrl: string | null | undefined): string | null { @@ -225,45 +227,96 @@ const inputSchema = z.object({ const STALE_ERROR_MSG = 'agent did not complete job within 2 attempts' export async function resetStaleClaimedJobs(userId: string): Promise { - // Jobs that exceeded the retry limit → FAILED - const failedRows = await prisma.$queryRaw< - Array<{ id: string; task_id: string; product_id: string }> - >` + // PBI-50: lease-driven stale-detection. Jobs in CLAIMED of RUNNING met + // verlopen lease_until (default 5 min, verlengd door job_heartbeat) worden + // gereset. Legacy jobs zonder lease_until vallen terug op de oude + // claimed_at + 30-min-regel. + type StaleRow = { + id: string + task_id: string | null + product_id: string + kind: string + sprint_run_id: string | null + branch: string | null + } + + const failedRows = await prisma.$queryRaw` UPDATE claude_jobs SET status = 'FAILED', finished_at = NOW(), error = ${STALE_ERROR_MSG} WHERE user_id = ${userId} - AND status = 'CLAIMED' - AND claimed_at < NOW() - INTERVAL '30 minutes' + AND status IN ('CLAIMED', 'RUNNING') AND retry_count >= 2 - RETURNING id, task_id, product_id + AND ( + lease_until < NOW() + OR (lease_until IS NULL AND claimed_at < NOW() - INTERVAL '30 minutes') + ) + RETURNING id, task_id, product_id, kind::text AS kind, sprint_run_id, branch ` - // Jobs under the retry limit → back to QUEUED, increment retry_count const requeuedRows = await prisma.$queryRaw< - Array<{ id: string; task_id: string; product_id: string; retry_count: number }> + (StaleRow & { retry_count: number })[] >` UPDATE claude_jobs SET status = 'QUEUED', claimed_by_token_id = NULL, claimed_at = NULL, plan_snapshot = NULL, + lease_until = NULL, retry_count = retry_count + 1 WHERE user_id = ${userId} - AND status = 'CLAIMED' - AND claimed_at < NOW() - INTERVAL '30 minutes' + AND status IN ('CLAIMED', 'RUNNING') AND retry_count < 2 - RETURNING id, task_id, product_id, retry_count + AND ( + lease_until < NOW() + OR (lease_until IS NULL AND claimed_at < NOW() - INTERVAL '30 minutes') + ) + RETURNING id, task_id, product_id, kind::text AS kind, sprint_run_id, branch, retry_count ` if (failedRows.length === 0 && requeuedRows.length === 0) return // PBI-9: release any product-worktree locks held by these stale jobs. - // No-op for jobs without registered locks (TASK_IMPLEMENTATION). for (const j of failedRows) await releaseLocksOnTerminal(j.id) for (const j of requeuedRows) await releaseLocksOnTerminal(j.id) + // PBI-50: voor stale FAILED SPRINT_IMPLEMENTATION jobs — push de branch + // zodat het werk niet verloren gaat (geen mark-ready / PR-promotie), + // en zet SprintRun.failure_reason met een verwijzing naar de laatst + // RUNNING execution voor diagnose. + for (const j of failedRows.filter((r) => r.kind === 'SPRINT_IMPLEMENTATION')) { + if (j.branch && j.product_id) { + const repoRoot = await resolveRepoRoot(j.product_id).catch(() => null) + if (repoRoot) { + const worktreeDir = getWorktreeRoot() + const worktreePath = path.join(worktreeDir, j.id) + try { + await pushBranchForJob({ worktreePath, branchName: j.branch }) + } catch (err) { + console.warn(`[stale-reset] push failed for stale sprint-job ${j.id}:`, err) + } + } + } + if (j.sprint_run_id) { + const lastRunning = await prisma.sprintTaskExecution.findFirst({ + where: { sprint_job_id: j.id, status: 'RUNNING' }, + orderBy: { order: 'desc' }, + select: { order: true, task_id: true }, + }) + const reasonSuffix = lastRunning + ? `, last execution: order ${lastRunning.order} task ${lastRunning.task_id}` + : '' + await prisma.sprintRun.update({ + where: { id: j.sprint_run_id }, + data: { + status: 'FAILED', + failure_reason: `stale: lease verlopen${reasonSuffix}`, + }, + }) + } + } + // Notify UI via SSE for each transition (best-effort) try { const pg = new Client({ connectionString: process.env.DATABASE_URL }) @@ -490,6 +543,177 @@ async function getFullJobContext(jobId: string) { } } + // PBI-50: SPRINT_IMPLEMENTATION — single-session sprint runner. + // Eén ClaudeJob per SprintRun handelt sequentieel alle TO_DO-tasks af. + // Bij claim: maak frozen scope-snapshot via SprintTaskExecution-rows, + // resolve worktree (verse branch of hergebruikt via previous_run_id), + // capture base_sha. Worker werkt uitsluitend op deze frozen snapshot. + if (job.kind === 'SPRINT_IMPLEMENTATION') { + if (!job.sprint_run_id) { + await rollbackClaim(job.id) + return null + } + const sprintRun = await prisma.sprintRun.findUnique({ + where: { id: job.sprint_run_id }, + include: { + sprint: { + include: { + product: true, + stories: { + where: { status: { not: 'DONE' } }, + include: { + pbi: { + select: { id: true, code: true, title: true, priority: true, sort_order: true, status: true }, + }, + tasks: { + where: { status: 'TO_DO' }, + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + }, + }, + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + }, + }, + }, + }, + }) + if (!sprintRun) { + await rollbackClaim(job.id) + return null + } + + const repoRoot = await resolveRepoRoot(sprintRun.sprint.product_id) + if (!repoRoot) { + await rollbackClaim(job.id) + return null + } + + // Branch resolution: previous_run_id + branch → reuse; anders verse. + const isResume = !!(sprintRun.previous_run_id && sprintRun.branch) + const branchName = isResume + ? sprintRun.branch! + : `feat/sprint-${job.sprint_run_id.slice(-8)}` + + let worktreePath: string + let baseSha: string + try { + const wt = await createWorktreeForJob({ + repoRoot, + jobId: job.id, + branchName, + reuseBranch: isResume, + }) + worktreePath = wt.worktreePath + + const { stdout: headSha } = await execFileP('git', ['rev-parse', 'HEAD'], { + cwd: worktreePath, + }) + baseSha = headSha.trim() + } catch (err) { + console.warn(`[wait-for-job] sprint-worktree setup failed for ${job.id}:`, err) + await rollbackClaim(job.id) + return null + } + + // Verzamel ordered tasks in flat list, behoud volgorde + const orderedTasks = sprintRun.sprint.stories.flatMap((s) => + s.tasks.map((t) => ({ ...t, story_pbi_id: s.pbi.id })), + ) + + // Persist branch + base_sha + scope-snapshot in één transactie + await prisma.$transaction([ + prisma.claudeJob.update({ + where: { id: job.id }, + data: { branch: branchName, base_sha: baseSha }, + }), + prisma.sprintTaskExecution.createMany({ + data: orderedTasks.map((t, idx) => ({ + sprint_job_id: job.id, + task_id: t.id, + order: idx, + plan_snapshot: t.implementation_plan ?? '', + verify_required_snapshot: t.verify_required, + verify_only_snapshot: t.verify_only, + base_sha: idx === 0 ? baseSha : null, + status: 'PENDING' as const, + })), + }), + prisma.sprintRun.update({ + where: { id: job.sprint_run_id }, + data: { branch: branchName }, + }), + ]) + + // Lookup execution_ids in volgorde voor de response + const executions = await prisma.sprintTaskExecution.findMany({ + where: { sprint_job_id: job.id }, + orderBy: { order: 'asc' }, + select: { id: true, task_id: true, order: true, base_sha: true }, + }) + const execIdByTaskId = new Map(executions.map((e) => [e.task_id, e.id])) + + // Dedupe PBIs uit de stories (één PBI kan meerdere stories hebben) + const pbiMap = new Map() + for (const s of sprintRun.sprint.stories) pbiMap.set(s.pbi.id, s.pbi) + + return { + job_id: job.id, + kind: job.kind, + status: 'claimed', + sprint: { + id: sprintRun.sprint.id, + sprint_goal: sprintRun.sprint.sprint_goal, + status: sprintRun.sprint.status, + }, + sprint_run: { + id: sprintRun.id, + pr_strategy: sprintRun.pr_strategy, + branch: branchName, + previous_run_id: sprintRun.previous_run_id, + }, + product: { + id: sprintRun.sprint.product.id, + name: sprintRun.sprint.product.name, + repo_url: sprintRun.sprint.product.repo_url, + definition_of_done: sprintRun.sprint.product.definition_of_done, + auto_pr: sprintRun.sprint.product.auto_pr, + }, + pbis: Array.from(pbiMap.values()).map((p) => ({ + id: p.id, + code: p.code, + title: p.title, + priority: p.priority, + sort_order: p.sort_order, + status: p.status, + })), + stories: sprintRun.sprint.stories.map((s) => ({ + id: s.id, + code: s.code, + title: s.title, + pbi_id: s.pbi_id, + priority: s.priority, + sort_order: s.sort_order, + status: s.status, + })), + task_executions: orderedTasks.map((t, idx) => ({ + execution_id: execIdByTaskId.get(t.id)!, + task_id: t.id, + code: t.code, + title: t.title, + story_id: t.story_id, + order: idx, + plan_snapshot: t.implementation_plan ?? '', + verify_required: t.verify_required, + verify_only: t.verify_only, + base_sha: idx === 0 ? baseSha : null, + })), + worktree_path: worktreePath, + branch_name: branchName, + repo_url: sprintRun.sprint.product.repo_url, + base_sha: baseSha, + heartbeat_interval_seconds: 60, + } + } + // TASK_IMPLEMENTATION (default) — bestaande gedrag onaangetast. const { task } = job if (!task) return null From 25ab68073a0cc32109965c0efca20dd39d5f01d8 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Thu, 7 May 2026 12:40:18 +0200 Subject: [PATCH 03/27] PBI-50 F3: nieuwe MCP-tools voor SPRINT_IMPLEMENTATION-flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/index.ts | 8 ++ src/lib/tasks-status-update.ts | 58 +++++++++-- src/tools/job-heartbeat.ts | 81 ++++++++++++++++ src/tools/update-task-execution.ts | 110 +++++++++++++++++++++ src/tools/update-task-status.ts | 79 +++++++++++++-- src/tools/verify-sprint-task.ts | 151 +++++++++++++++++++++++++++++ 6 files changed, 471 insertions(+), 16 deletions(-) create mode 100644 src/tools/job-heartbeat.ts create mode 100644 src/tools/update-task-execution.ts create mode 100644 src/tools/verify-sprint-task.ts diff --git a/src/index.ts b/src/index.ts index d05900c..2938c70 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,10 @@ import { registerUpdateIdeaPlanMdTool } from './tools/update-idea-plan-md.js' import { registerLogIdeaDecisionTool } from './tools/log-idea-decision.js' import { registerGetWorkerSettingsTool } from './tools/get-worker-settings.js' import { registerWorkerHeartbeatTool } from './tools/worker-heartbeat.js' +// PBI-50: SPRINT_IMPLEMENTATION-tools +import { registerVerifySprintTaskTool } from './tools/verify-sprint-task.js' +import { registerUpdateTaskExecutionTool } from './tools/update-task-execution.js' +import { registerJobHeartbeatTool } from './tools/job-heartbeat.js' import { registerImplementNextStoryPrompt } from './prompts/implement-next-story.js' import { getAuth } from './auth.js' import { registerWorker } from './presence/worker.js' @@ -92,6 +96,10 @@ async function main() { // M13: worker quota-gate tools registerGetWorkerSettingsTool(server) registerWorkerHeartbeatTool(server) + // PBI-50: SPRINT_IMPLEMENTATION-tools + registerVerifySprintTaskTool(server) + registerUpdateTaskExecutionTool(server) + registerJobHeartbeatTool(server) registerImplementNextStoryPrompt(server) // Presence bootstrap MUST run before server.connect — the stdio transport diff --git a/src/lib/tasks-status-update.ts b/src/lib/tasks-status-update.ts index 3549f3d..64e2ac6 100644 --- a/src/lib/tasks-status-update.ts +++ b/src/lib/tasks-status-update.ts @@ -38,6 +38,11 @@ export async function propagateStatusUpwards( taskId: string, newStatus: TaskStatus, client?: Prisma.TransactionClient, + // PBI-50: optionele expliciete sprint_run_id voor SPRINT_IMPLEMENTATION + // (waar geen ClaudeJob.task_id-koppeling bestaat). Wanneer afwezig valt + // de helper terug op de lookup via ClaudeJob.task_id, met als laatste + // fallback Story → Sprint → SprintRun.findFirst({ status: active }). + sprintRunId?: string, ): Promise { const run = async (tx: Prisma.TransactionClient): Promise => { const task = await tx.task.update({ @@ -151,18 +156,43 @@ export async function propagateStatusUpwards( } } - // SprintRun herevalueren — via ClaudeJob.sprint_run_id van deze task + // SprintRun herevalueren. Resolve sprint_run_id in volgorde: + // 1. Expliciete sprintRunId-arg (PBI-50: SPRINT_IMPLEMENTATION-pad). + // 2. ClaudeJob.task_id-lookup (PER_TASK-flow). + // 3. Story → Sprint → SprintRun.findFirst({ status: active }) (geen + // task-job, bv. handmatige task-statuswijziging via UI). let sprintRunChanged = false if (nextSprintStatus === 'FAILED' || nextSprintStatus === 'COMPLETED') { - const job = await tx.claudeJob.findFirst({ - where: { task_id: taskId, sprint_run_id: { not: null } }, - orderBy: { created_at: 'desc' }, - select: { id: true, sprint_run_id: true }, - }) + let resolvedRunId: string | null = sprintRunId ?? null + let cancelExceptJobId: string | null = null - if (job?.sprint_run_id) { + if (!resolvedRunId) { + const job = await tx.claudeJob.findFirst({ + where: { task_id: taskId, sprint_run_id: { not: null } }, + orderBy: { created_at: 'desc' }, + select: { id: true, sprint_run_id: true }, + }) + if (job?.sprint_run_id) { + resolvedRunId = job.sprint_run_id + cancelExceptJobId = job.id + } + } + + if (!resolvedRunId && story.sprint_id) { + const activeRun = await tx.sprintRun.findFirst({ + where: { + sprint_id: story.sprint_id, + status: { in: ['QUEUED', 'RUNNING', 'PAUSED'] }, + }, + orderBy: { created_at: 'desc' }, + select: { id: true }, + }) + if (activeRun) resolvedRunId = activeRun.id + } + + if (resolvedRunId) { const sprintRun = await tx.sprintRun.findUnique({ - where: { id: job.sprint_run_id }, + where: { id: resolvedRunId }, select: { id: true, status: true }, }) if ( @@ -180,11 +210,16 @@ export async function propagateStatusUpwards( failed_task_id: taskId, }, }) + // Cancel sibling-jobs binnen dezelfde SprintRun behalve de + // huidige task-job (als die er is). Voor SPRINT_IMPLEMENTATION + // is cancelExceptJobId null en hebben we geen siblings om te + // cancellen — de SPRINT-job zelf blijft actief en de worker + // detecteert dit via job_heartbeat. await tx.claudeJob.updateMany({ where: { sprint_run_id: sprintRun.id, status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] }, - id: { not: job.id }, + ...(cancelExceptJobId ? { id: { not: cancelExceptJobId } } : {}), }, data: { status: 'CANCELLED', @@ -230,14 +265,16 @@ export interface UpdateTaskStatusResult { task: PropagationResult['task'] storyStatusChange: StoryStatusChange storyId: string + sprintRunChanged: boolean } export async function updateTaskStatusWithStoryPromotion( taskId: string, newStatus: TaskStatus, client?: Prisma.TransactionClient, + sprintRunId?: string, ): Promise { - const result = await propagateStatusUpwards(taskId, newStatus, client) + const result = await propagateStatusUpwards(taskId, newStatus, client, sprintRunId) let storyStatusChange: StoryStatusChange = null if (result.storyChanged) { storyStatusChange = newStatus === 'DONE' ? 'promoted' : 'demoted' @@ -246,5 +283,6 @@ export async function updateTaskStatusWithStoryPromotion( task: result.task, storyStatusChange, storyId: result.storyId, + sprintRunChanged: result.sprintRunChanged, } } diff --git a/src/tools/job-heartbeat.ts b/src/tools/job-heartbeat.ts new file mode 100644 index 0000000..36c42a2 --- /dev/null +++ b/src/tools/job-heartbeat.ts @@ -0,0 +1,81 @@ +// PBI-50 F3-T3: job_heartbeat +// +// Verlengt ClaudeJob.lease_until met 5 min zodat resetStaleClaimedJobs een +// long-running job (bv. SPRINT_IMPLEMENTATION over 30+ min) niet ten onrechte +// als stale markt. Worker draait een achtergrond-loop elke 60s. +// +// Voor SPRINT-jobs: respons bevat sprint_run_status zodat de worker zijn +// loop kan breken bij ≠ RUNNING (bv. UI-side cancel of MERGE_CONFLICT-pause). + +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' + +const inputSchema = z.object({ + job_id: z.string().min(1), +}) + +export function registerJobHeartbeatTool(server: McpServer) { + server.registerTool( + 'job_heartbeat', + { + title: 'Job heartbeat', + description: + 'Extend the lease on a CLAIMED/RUNNING job by 5 minutes. Token must own the job. ' + + 'For SPRINT_IMPLEMENTATION jobs: response includes sprint_run_status so the worker ' + + 'can break its task-loop on UI-side cancel/pause without an extra query. ' + + 'Worker should call this every ~60s during long-running batches. ' + + 'Forbidden for demo accounts.', + inputSchema, + }, + async ({ job_id }) => + withToolErrors(async () => { + const auth = await requireWriteAccess() + + // Atomic conditional UPDATE so a non-owner / non-active job is rejected + // without a separate read. + const updated = await prisma.$queryRaw< + Array<{ id: string; lease_until: Date; kind: string; sprint_run_id: string | null }> + >` + UPDATE claude_jobs + SET lease_until = NOW() + INTERVAL '5 minutes' + WHERE id = ${job_id} + AND claimed_by_token_id = ${auth.tokenId} + AND status IN ('CLAIMED', 'RUNNING') + RETURNING id, lease_until, kind::text AS kind, sprint_run_id + ` + if (updated.length === 0) { + return toolError( + `Job ${job_id} not found, not claimed by your token, or in terminal state`, + ) + } + const row = updated[0] + + let sprint_run_status: string | null = null + let sprint_run_pause_reason: string | null = null + if (row.kind === 'SPRINT_IMPLEMENTATION' && row.sprint_run_id) { + const sprintRun = await prisma.sprintRun.findUnique({ + where: { id: row.sprint_run_id }, + select: { status: true, pause_context: true }, + }) + sprint_run_status = sprintRun?.status ?? null + // Extract pause_reason from pause_context Json (best-effort) + const ctx = sprintRun?.pause_context as + | { pause_reason?: string } + | null + | undefined + sprint_run_pause_reason = ctx?.pause_reason ?? null + } + + return toolJson({ + ok: true, + job_id: row.id, + lease_until: row.lease_until.toISOString(), + sprint_run_status, + sprint_run_pause_reason, + }) + }), + ) +} diff --git a/src/tools/update-task-execution.ts b/src/tools/update-task-execution.ts new file mode 100644 index 0000000..8b3213a --- /dev/null +++ b/src/tools/update-task-execution.ts @@ -0,0 +1,110 @@ +// PBI-50 F3-T2: update_task_execution +// +// SPRINT_IMPLEMENTATION-flow lifecycle-tool. Worker roept dit aan voor elke +// task in de batch om de SprintTaskExecution-row te muteren: +// PENDING → RUNNING → DONE/FAILED/SKIPPED +// Idempotent: dezelfde call kan veilig herhaald worden. + +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' + +const inputSchema = z.object({ + execution_id: z.string().min(1), + status: z.enum(['PENDING', 'RUNNING', 'DONE', 'FAILED', 'SKIPPED']), + base_sha: z.string().optional(), + head_sha: z.string().optional(), + skip_reason: z.string().max(2000).optional(), +}) + +export function registerUpdateTaskExecutionTool(server: McpServer) { + server.registerTool( + 'update_task_execution', + { + title: 'Update SprintTaskExecution status', + description: + 'Mutate a SprintTaskExecution row in a SPRINT_IMPLEMENTATION batch. ' + + 'Status: PENDING|RUNNING|DONE|FAILED|SKIPPED. Worker calls this for each ' + + 'task transition. Token must own the parent SPRINT_IMPLEMENTATION ClaudeJob. ' + + 'Idempotent — safe to retry. Schrijft started_at (RUNNING) en finished_at ' + + '(DONE/FAILED/SKIPPED). Forbidden for demo accounts.', + inputSchema, + }, + async ({ execution_id, status, base_sha, head_sha, skip_reason }) => + withToolErrors(async () => { + const auth = await requireWriteAccess() + + const execution = await prisma.sprintTaskExecution.findUnique({ + where: { id: execution_id }, + select: { + id: true, + sprint_job_id: 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}`, + ) + } + if ( + execution.sprint_job.status !== 'CLAIMED' && + execution.sprint_job.status !== 'RUNNING' + ) { + return toolError( + `Sprint job is in terminal state ${execution.sprint_job.status}`, + ) + } + + const now = new Date() + const updated = await prisma.sprintTaskExecution.update({ + where: { id: execution_id }, + data: { + status, + ...(base_sha !== undefined ? { base_sha } : {}), + ...(head_sha !== undefined ? { head_sha } : {}), + ...(skip_reason !== undefined ? { skip_reason } : {}), + ...(status === 'RUNNING' ? { started_at: now } : {}), + ...(status === 'DONE' || status === 'FAILED' || status === 'SKIPPED' + ? { finished_at: now } + : {}), + }, + select: { + id: true, + status: true, + base_sha: true, + head_sha: true, + verify_result: true, + verify_summary: true, + skip_reason: true, + started_at: true, + finished_at: true, + }, + }) + + return toolJson({ + execution_id: updated.id, + status: updated.status, + base_sha: updated.base_sha, + head_sha: updated.head_sha, + verify_result: updated.verify_result, + verify_summary: updated.verify_summary, + skip_reason: updated.skip_reason, + started_at: updated.started_at?.toISOString() ?? null, + finished_at: updated.finished_at?.toISOString() ?? null, + }) + }), + ) +} diff --git a/src/tools/update-task-status.ts b/src/tools/update-task-status.ts index d3756ce..8ac8463 100644 --- a/src/tools/update-task-status.ts +++ b/src/tools/update-task-status.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { prisma } from '../prisma.js' import { requireWriteAccess } from '../auth.js' import { userCanAccessTask } from '../access.js' import { toolError, toolJson, withToolErrors } from '../errors.js' @@ -9,6 +10,10 @@ import { updateTaskStatusWithStoryPromotion } from '../lib/tasks-status-update.j const inputSchema = z.object({ task_id: z.string().min(1), status: z.enum(TASK_STATUS_API_VALUES as [string, ...string[]]), + // PBI-50: optionele sprint_run_id voor SPRINT_IMPLEMENTATION-flow. + // Wanneer aanwezig: server valideert dat task in deze sprint zit, run + // actief is, en de huidige token een ClaudeJob in deze run heeft geclaimt. + sprint_run_id: z.string().min(1).optional(), }) export function registerUpdateTaskStatusTool(server: McpServer) { @@ -17,11 +22,14 @@ export function registerUpdateTaskStatusTool(server: McpServer) { { title: 'Update task status', description: - 'Set the status of a task. Allowed values: todo, in_progress, review, done. ' + + 'Set the status of a task. Allowed values: todo, in_progress, review, done, failed. ' + + 'Optional sprint_run_id binds the update to a SPRINT_IMPLEMENTATION run for ' + + 'cascade-propagation; the server validates that the task belongs to the sprint ' + + 'and that the calling token has claimed a job in that run. ' + 'Forbidden for demo accounts.', inputSchema, }, - async ({ task_id, status }) => + async ({ task_id, status, sprint_run_id }) => withToolErrors(async () => { const auth = await requireWriteAccess() const dbStatus = taskStatusFromApi(status) @@ -31,15 +39,74 @@ export function registerUpdateTaskStatusTool(server: McpServer) { if (!(await userCanAccessTask(task_id, auth.userId))) { return toolError(`Task ${task_id} not found or not accessible`) } - const { task, storyStatusChange } = await updateTaskStatusWithStoryPromotion( - task_id, - dbStatus, - ) + + // PBI-50: validate explicit sprint_run_id binding. + if (sprint_run_id) { + const sprintRun = await prisma.sprintRun.findUnique({ + where: { id: sprint_run_id }, + select: { id: true, status: true, sprint_id: true }, + }) + if (!sprintRun) { + return toolError(`SprintRun ${sprint_run_id} not found`) + } + if ( + sprintRun.status !== 'QUEUED' && + sprintRun.status !== 'RUNNING' && + sprintRun.status !== 'PAUSED' + ) { + return toolError( + `SprintRun ${sprint_run_id} is in terminal state ${sprintRun.status}; cannot update task status against it`, + ) + } + + // Task moet in deze sprint zitten + const task = await prisma.task.findUnique({ + where: { id: task_id }, + select: { story: { select: { sprint_id: true } } }, + }) + if (!task || task.story.sprint_id !== sprintRun.sprint_id) { + return toolError( + `Task ${task_id} is not in sprint ${sprintRun.sprint_id} (sprint_run ${sprint_run_id})`, + ) + } + + // Token-coupling: huidige token moet een actieve ClaudeJob in deze + // SprintRun hebben geclaimt (typisch de SPRINT_IMPLEMENTATION-job). + const tokenJob = await prisma.claudeJob.findFirst({ + where: { + sprint_run_id, + claimed_by_token_id: auth.tokenId, + status: { in: ['CLAIMED', 'RUNNING'] }, + }, + select: { id: true }, + }) + if (!tokenJob) { + return toolError( + `Forbidden: current token has no active claim in sprint_run ${sprint_run_id}`, + ) + } + } + + const { task, storyStatusChange, sprintRunChanged } = + await updateTaskStatusWithStoryPromotion(task_id, dbStatus, undefined, sprint_run_id) + + // Voor SPRINT-flow: stuur expliciete sprint_run_status mee zodat + // worker zijn loop kan breken bij FAILED/PAUSED zonder extra query. + let sprintRunStatusChange: string | null = null + if (sprintRunChanged && sprint_run_id) { + const updated = await prisma.sprintRun.findUnique({ + where: { id: sprint_run_id }, + select: { status: true }, + }) + sprintRunStatusChange = updated?.status ?? null + } + return toolJson({ id: task.id, status: taskStatusToApi(task.status), implementation_plan: task.implementation_plan, story_status_change: storyStatusChange, + sprint_run_status_change: sprintRunStatusChange, }) }), ) diff --git a/src/tools/verify-sprint-task.ts b/src/tools/verify-sprint-task.ts new file mode 100644 index 0000000..fbd62d2 --- /dev/null +++ b/src/tools/verify-sprint-task.ts @@ -0,0 +1,151 @@ +// 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 ...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, + }) + }), + ) +} From 876a7ad5d94271f32a44f7b34a4004cbea71b235 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Thu, 7 May 2026 12:48:04 +0200 Subject: [PATCH 04/27] PBI-50 F4: SPRINT_IMPLEMENTATION DONE/FAILED-paden + quota-pause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - checkSprintVerifyGate: aggregate verify-gate via SprintTaskExecution. Per row: DONE → checkVerifyGate met snapshot-velden, SKIPPED → alleen toegestaan bij verify_required=ANY, FAILED/PENDING/RUNNING → blocker. Toolerror met opsomming bij faal. - finalizeSprintRunOnDone: idempotent SprintRun → DONE wanneer alle stories DONE/FAILED zijn. - maybeCreateSprintBatchPr: één draft-PR per sprint met sprint_goal als title. Hergebruikt bestaande PR via SprintRunChain bij resume. - DONE-pad: na update markPullRequestReady wanneer SprintRun DONE. - FAILED-pad: detect QUOTA_PAUSE: prefix → SprintRun PAUSED met pause_context (resume-instructions + last-completed-task); anders → FAILED met failure_reason + failed_task_id (uit error-string). - cancelPbiOnFailure overslaan voor SPRINT-jobs (geen task_id). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tools/update-job-status.ts | 273 +++++++++++++++++++++++++++++++++ 1 file changed, 273 insertions(+) diff --git a/src/tools/update-job-status.ts b/src/tools/update-job-status.ts index 9f60006..d03336e 100644 --- a/src/tools/update-job-status.ts +++ b/src/tools/update-job-status.ts @@ -225,6 +225,106 @@ export function checkVerifyGate( return { allowed: true } } +// PBI-50 F4-T1: aggregate verify-gate voor SPRINT_IMPLEMENTATION DONE. +// Bron: alleen SprintTaskExecution-rows voor deze job. Per row: +// DONE → checkVerifyGate met snapshot-velden (gate per row) +// SKIPPED → alleen toegestaan als verify_required_snapshot === 'ANY' +// FAILED/PENDING/RUNNING → blocker (sprint mag niet DONE met openstaand werk) +// Bij overall pass → { allowed: true }; anders error met opsomming. +export async function checkSprintVerifyGate( + sprintJobId: string, +): Promise<{ allowed: true } | { allowed: false; error: string }> { + const executions = await prisma.sprintTaskExecution.findMany({ + where: { sprint_job_id: sprintJobId }, + orderBy: { order: 'asc' }, + select: { + id: true, + task_id: true, + order: true, + status: true, + verify_result: true, + verify_summary: true, + verify_required_snapshot: true, + verify_only_snapshot: true, + task: { select: { code: true, title: true } }, + }, + }) + if (executions.length === 0) { + return { + allowed: false, + error: + 'Sprint-job heeft geen SprintTaskExecution-rows. ' + + 'Dit duidt op een claim-bug; reclaim de sprint.', + } + } + + const blockers: string[] = [] + for (const exec of executions) { + const taskLabel = `${exec.task.code}: ${exec.task.title}` + if (exec.status === 'PENDING' || exec.status === 'RUNNING') { + blockers.push(`[${exec.status}] ${taskLabel} — onafgemaakt werk`) + continue + } + if (exec.status === 'FAILED') { + blockers.push(`[FAILED] ${taskLabel}`) + continue + } + if (exec.status === 'SKIPPED') { + if (exec.verify_required_snapshot !== 'ANY') { + blockers.push( + `[SKIPPED] ${taskLabel} — alleen toegestaan bij verify_required=ANY`, + ) + } + continue + } + // DONE: per-row gate + const gate = checkVerifyGate( + exec.verify_result, + exec.verify_only_snapshot, + exec.verify_required_snapshot, + exec.verify_summary ?? undefined, + ) + if (!gate.allowed) { + blockers.push(`[DONE-gate] ${taskLabel}: ${gate.error}`) + } + } + + if (blockers.length === 0) return { allowed: true } + return { + allowed: false, + error: + `Sprint kan niet DONE — ${blockers.length} task(s) blokkeren:\n` + + blockers.map((b) => ` - ${b}`).join('\n'), + } +} + +// PBI-50 F4-T2: idempotent SprintRun-finalisering. +// Invariant: alleen aanroepen wanneer alle stories in de sprint status +// DONE/FAILED/CANCELLED hebben. Effect: SprintRun.status → DONE + +// finished_at = NOW(). Idempotent — bij al-DONE: no-op. +export async function finalizeSprintRunOnDone(sprintRunId: string): Promise { + const sprintRun = await prisma.sprintRun.findUnique({ + where: { id: sprintRunId }, + select: { id: true, status: true, sprint_id: true }, + }) + if (!sprintRun) return + if (sprintRun.status === 'DONE') return // idempotent + + // Check alle stories in deze sprint zijn klaar + const openStories = await prisma.story.count({ + where: { + sprint_id: sprintRun.sprint_id, + status: { notIn: ['DONE', 'FAILED'] }, + }, + }) + if (openStories > 0) return // nog werk over — niet finaliseren + + await prisma.sprintRun.update({ + where: { id: sprintRunId }, + data: { status: 'DONE', finished_at: new Date() }, + }) +} + const DB_STATUS_MAP = { running: 'RUNNING', done: 'DONE', @@ -332,6 +432,68 @@ export async function maybeCreateAutoPr(opts: { return null } +// PBI-50 F4-T2: SPRINT_BATCH PR-flow. Eén draft-PR voor de hele sprint, +// title = sprint.sprint_goal. Mens reviewt + mergt zelf — geen auto-merge. +// Lijkt op de SPRINT-mode van maybeCreateAutoPr maar zonder task-context. +export async function maybeCreateSprintBatchPr(opts: { + jobId: string + productId: string + worktreePath: string + branchName: string + summary: string | undefined +}): Promise { + const { jobId, productId, worktreePath, branchName, summary } = opts + + const product = await prisma.product.findUnique({ + where: { id: productId }, + select: { auto_pr: true }, + }) + if (!product?.auto_pr) return null + + const job = await prisma.claudeJob.findUnique({ + where: { id: jobId }, + select: { + sprint_run_id: true, + sprint_run: { + select: { id: true, sprint: { select: { sprint_goal: true } } }, + }, + }, + }) + if (!job?.sprint_run) return null + + // Resume-pad: oude SprintRun heeft mogelijk al een PR via vorige run-job. + // Lookup via SprintRunChain (previous_run_id) of via sibling-SPRINT-job. + const previousRun = await prisma.sprintRun.findUnique({ + where: { id: job.sprint_run.id }, + select: { previous_run_id: true }, + }) + if (previousRun?.previous_run_id) { + const prevPr = await prisma.claudeJob.findFirst({ + where: { sprint_run_id: previousRun.previous_run_id, pr_url: { not: null } }, + select: { pr_url: true }, + }) + if (prevPr?.pr_url) return prevPr.pr_url + } + + const goal = job.sprint_run.sprint.sprint_goal + const sprintTitle = `Sprint: ${goal}`.slice(0, 200) + const body = summary + ? `${summary}\n\n---\n\n*Draft PR voor sprint-batch \`${job.sprint_run.id}\` (single-session). Wordt ready-for-review zodra alle tasks DONE zijn.*` + : `*Draft PR voor sprint-batch \`${job.sprint_run.id}\` (single-session). Wordt ready-for-review zodra alle tasks DONE zijn.*` + + const result = await createPullRequest({ + worktreePath, + branchName, + title: sprintTitle, + body, + draft: true, + enableAutoMerge: false, + }) + if ('url' in result) return result.url + console.warn(`[update_job_status] sprint-batch draft-PR skipped for job ${jobId}:`, result.error) + return null +} + export function registerUpdateJobStatusTool(server: McpServer) { server.registerTool( 'update_job_status', @@ -379,6 +541,7 @@ export function registerUpdateJobStatusTool(server: McpServer) { product_id: true, task_id: true, idea_id: true, + sprint_run_id: true, kind: true, verify_result: true, task: { select: { verify_only: true, verify_required: true } }, @@ -419,6 +582,19 @@ export function registerUpdateJobStatusTool(server: McpServer) { actualStatus = 'done' // pushedAt blijft undefined, branch/error overrides ook skipWorktreeCleanup = true + } else if (job.kind === 'SPRINT_IMPLEMENTATION') { + // PBI-50 F4-T2: aggregate verify-gate via SprintTaskExecution-rows. + // Geen single-task verify_result op de SPRINT-job zelf. + const gate = await checkSprintVerifyGate(job_id) + if (!gate.allowed) return toolError(gate.error) + + const plan = await prepareDoneUpdate(job_id, branch) + actualStatus = plan.dbStatus === 'DONE' ? 'done' : 'failed' + pushedAt = plan.pushedAt + if (plan.branchOverride !== undefined) branchToWrite = plan.branchOverride + if (plan.errorOverride !== undefined) errorToWrite = plan.errorOverride + skipWorktreeCleanup = plan.skipWorktreeCleanup + headShaToWrite = plan.headSha } else { const gate = checkVerifyGate( job.verify_result ?? null, @@ -440,6 +616,7 @@ export function registerUpdateJobStatusTool(server: McpServer) { // Auto-PR: best-effort, only when push actually happened. // M12: idee-jobs hebben geen task_id en geen branch — skip auto-PR. + // PBI-50: SPRINT_IMPLEMENTATION krijgt een eigen PR-flow (sprint-goal als title). let prUrl: string | null = null if ( actualStatus === 'done' && @@ -460,6 +637,23 @@ export function registerUpdateJobStatusTool(server: McpServer) { console.warn(`[update_job_status] auto-PR error for job ${job_id}:`, err) return null }) + } else if ( + actualStatus === 'done' && + pushedAt && + branchToWrite && + job.kind === 'SPRINT_IMPLEMENTATION' + ) { + const worktreeDir = getWorktreeRoot() + prUrl = await maybeCreateSprintBatchPr({ + jobId: job_id, + productId: job.product_id, + worktreePath: path.join(worktreeDir, job_id), + branchName: branchToWrite, + summary, + }).catch((err) => { + console.warn(`[update_job_status] sprint-batch PR error for job ${job_id}:`, err) + return null + }) } const dbStatus = DB_STATUS_MAP[actualStatus as keyof typeof DB_STATUS_MAP] @@ -493,6 +687,7 @@ export function registerUpdateJobStatusTool(server: McpServer) { error: true, started_at: true, finished_at: true, + head_sha: true, }, }) @@ -694,10 +889,88 @@ export function registerUpdateJobStatusTool(server: McpServer) { // cancel all queued/claimed/running siblings under the same PBI and // undo any pushed commits (close open PRs / open revert-PRs for // already-merged ones). Idempotent + non-blocking — never throws. + // PBI-50: SPRINT_IMPLEMENTATION SKIPS this — cascade naar tasks/stories/ + // PBIs is al gebeurd via per-task update_task_status('failed')-calls + // van de worker. Sprint-job heeft geen task_id; cancelPbi-flow past niet. if (actualStatus === 'failed' && job.kind === 'TASK_IMPLEMENTATION' && job.task_id) { await cancelPbiOnFailure(job_id) } + // PBI-50 F4-T2: SPRINT_IMPLEMENTATION DONE → finalize SprintRun. + if ( + actualStatus === 'done' && + job.kind === 'SPRINT_IMPLEMENTATION' && + job.sprint_run_id + ) { + try { + await finalizeSprintRunOnDone(job.sprint_run_id) + // Mark draft-PR ready-for-review als de SprintRun nu DONE is + const finalRun = await prisma.sprintRun.findUnique({ + where: { id: job.sprint_run_id }, + select: { status: true }, + }) + if (finalRun?.status === 'DONE' && updated.pr_url) { + try { + const ready = await markPullRequestReady({ prUrl: updated.pr_url }) + if ('error' in ready) { + console.warn( + `[update_job_status] sprint-batch markPullRequestReady failed for ${updated.pr_url}: ${ready.error}`, + ) + } + } catch (err) { + console.warn(`[update_job_status] sprint-batch markPullRequestReady error:`, err) + } + } + } catch (err) { + console.warn(`[update_job_status] finalizeSprintRunOnDone error:`, err) + } + } + + // PBI-50 F4-T3: SPRINT_IMPLEMENTATION FAILED → + // - Detect QUOTA_PAUSE: error-prefix → PAUSED met pause_context. + // - Anders: vul SprintRun.failure_reason + failed_task_id (uit error). + if (actualStatus === 'failed' && job.kind === 'SPRINT_IMPLEMENTATION' && job.sprint_run_id) { + const isQuotaPause = (errorToWrite ?? '').startsWith('QUOTA_PAUSE:') + if (isQuotaPause) { + // Vind laatst-DONE execution voor pause-context + const lastDone = await prisma.sprintTaskExecution.findFirst({ + where: { sprint_job_id: job_id, status: 'DONE' }, + orderBy: { order: 'desc' }, + select: { id: true, order: true, task_id: true }, + }) + await prisma.sprintRun.update({ + where: { id: job.sprint_run_id }, + data: { + status: 'PAUSED', + pause_context: { + pause_reason: 'QUOTA_DEPLETED', + paused_at: new Date().toISOString(), + resume_instructions: + 'Wacht tot quota is gereset, dan resume de SprintRun via de UI. Een nieuwe SprintRun wordt gemaakt met previous_run_id en branch hergebruik.', + last_completed_execution_id: lastDone?.id ?? null, + last_completed_order: lastDone?.order ?? null, + last_completed_task_id: lastDone?.task_id ?? null, + pr_url: updated.pr_url ?? null, + pr_head_sha: updated.head_sha ?? null, + conflict_files: [], + claude_question_id: '', + } as any, + }, + }) + } else { + const failedTaskId = (errorToWrite ?? '').match(/task[:\s]+([a-z0-9]+)/i)?.[1] ?? null + await prisma.sprintRun.update({ + where: { id: job.sprint_run_id }, + data: { + status: 'FAILED', + failure_reason: errorToWrite?.slice(0, 500) ?? null, + failed_task_id: failedTaskId, + finished_at: new Date(), + }, + }) + } + } + // PBI-9: release product-worktree locks on terminal transitions. // No-op for jobs without registered locks (i.e. TASK_IMPLEMENTATION). if (actualStatus === 'done' || actualStatus === 'failed') { From b80264c26ceb4cdd592cab5d987e4507f919fc67 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Thu, 7 May 2026 12:53:04 +0200 Subject: [PATCH 05/27] PBI-50 F5: tests voor SPRINT_IMPLEMENTATION-tools - update-job-status-sprint-gate: checkSprintVerifyGate per-row blockers, SKIPPED-policy, finalizeSprintRunOnDone idempotentie. - update-task-execution: token-coupling, lifecycle (RUNNING zet started_at, DONE/FAILED/SKIPPED zet finished_at), skip_reason. - job-heartbeat: token-mismatch error, non-SPRINT vs SPRINT response-shape, tolerantie voor pause_context=null. - verify-sprint-task: PARTIAL+summary gate-pass, PARTIAL zonder summary gate-fail, DIVERGENT met ALIGNED gate-fail, base_sha auto-fill via vorige DONE execution head_sha + persistence, MISSING_BASE_SHA error. Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/job-heartbeat.test.ts | 137 +++++++++++ .../update-job-status-sprint-gate.test.ts | 192 ++++++++++++++++ __tests__/update-task-execution.test.ts | 199 ++++++++++++++++ __tests__/verify-sprint-task.test.ts | 216 ++++++++++++++++++ 4 files changed, 744 insertions(+) create mode 100644 __tests__/job-heartbeat.test.ts create mode 100644 __tests__/update-job-status-sprint-gate.test.ts create mode 100644 __tests__/update-task-execution.test.ts create mode 100644 __tests__/verify-sprint-task.test.ts diff --git a/__tests__/job-heartbeat.test.ts b/__tests__/job-heartbeat.test.ts new file mode 100644 index 0000000..896f317 --- /dev/null +++ b/__tests__/job-heartbeat.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../src/prisma.js', () => ({ + prisma: { + $queryRaw: vi.fn(), + sprintRun: { findUnique: vi.fn() }, + }, +})) + +vi.mock('../src/auth.js', async (importOriginal) => { + const original = await importOriginal() + return { ...original, requireWriteAccess: vi.fn() } +}) + +import { prisma } from '../src/prisma.js' +import { requireWriteAccess } from '../src/auth.js' +import { registerJobHeartbeatTool } from '../src/tools/job-heartbeat.js' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' + +const mockPrisma = prisma as unknown as { + $queryRaw: ReturnType + sprintRun: { findUnique: ReturnType } +} +const mockAuth = requireWriteAccess as ReturnType + +const TOKEN_ID = 'tok-owner' + +function makeServer() { + let handler: (args: Record) => Promise + const server = { + registerTool: vi.fn((_name: string, _meta: unknown, fn: typeof handler) => { + handler = fn + }), + call: (args: Record) => handler(args), + } + registerJobHeartbeatTool(server as unknown as McpServer) + return server +} + +beforeEach(() => { + vi.clearAllMocks() + mockAuth.mockResolvedValue({ + userId: 'u-1', + tokenId: TOKEN_ID, + username: 'agent', + isDemo: false, + }) +}) + +describe('job_heartbeat', () => { + it('returns 403-style error when no row matched (token mismatch / terminal)', async () => { + mockPrisma.$queryRaw.mockResolvedValue([]) + const server = makeServer() + const result = (await server.call({ job_id: 'job-x' })) as { + content: { text: string }[] + isError?: boolean + } + expect(result.isError).toBe(true) + expect(result.content[0].text).toMatch(/not found|terminal|claimed_by/i) + }) + + it('non-SPRINT job returns ok + lease_until without sprint fields', async () => { + const lease = new Date() + mockPrisma.$queryRaw.mockResolvedValue([ + { + id: 'job-1', + lease_until: lease, + kind: 'TASK_IMPLEMENTATION', + sprint_run_id: null, + }, + ]) + const server = makeServer() + const result = (await server.call({ job_id: 'job-1' })) as { + content: { text: string }[] + } + const body = JSON.parse(result.content[0].text) + expect(body).toEqual({ + ok: true, + job_id: 'job-1', + lease_until: lease.toISOString(), + sprint_run_status: null, + sprint_run_pause_reason: null, + }) + expect(mockPrisma.sprintRun.findUnique).not.toHaveBeenCalled() + }) + + it('SPRINT job returns sprint_run_status from sibling lookup', async () => { + const lease = new Date() + mockPrisma.$queryRaw.mockResolvedValue([ + { + id: 'job-2', + lease_until: lease, + kind: 'SPRINT_IMPLEMENTATION', + sprint_run_id: 'sr-1', + }, + ]) + mockPrisma.sprintRun.findUnique.mockResolvedValue({ + status: 'PAUSED', + pause_context: { pause_reason: 'QUOTA_DEPLETED' }, + }) + + const server = makeServer() + const result = (await server.call({ job_id: 'job-2' })) as { + content: { text: string }[] + } + const body = JSON.parse(result.content[0].text) + expect(body).toMatchObject({ + ok: true, + sprint_run_status: 'PAUSED', + sprint_run_pause_reason: 'QUOTA_DEPLETED', + }) + }) + + it('SPRINT job tolerates missing pause_context', async () => { + const lease = new Date() + mockPrisma.$queryRaw.mockResolvedValue([ + { + id: 'job-3', + lease_until: lease, + kind: 'SPRINT_IMPLEMENTATION', + sprint_run_id: 'sr-2', + }, + ]) + mockPrisma.sprintRun.findUnique.mockResolvedValue({ + status: 'RUNNING', + pause_context: null, + }) + + const server = makeServer() + const result = (await server.call({ job_id: 'job-3' })) as { + content: { text: string }[] + } + const body = JSON.parse(result.content[0].text) + expect(body.sprint_run_status).toBe('RUNNING') + expect(body.sprint_run_pause_reason).toBeNull() + }) +}) diff --git a/__tests__/update-job-status-sprint-gate.test.ts b/__tests__/update-job-status-sprint-gate.test.ts new file mode 100644 index 0000000..e96b94a --- /dev/null +++ b/__tests__/update-job-status-sprint-gate.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../src/prisma.js', () => ({ + prisma: { + sprintTaskExecution: { + findMany: vi.fn(), + }, + sprintRun: { + findUnique: vi.fn(), + update: vi.fn(), + }, + story: { + count: vi.fn(), + }, + }, +})) + +import { prisma } from '../src/prisma.js' +import { + checkSprintVerifyGate, + finalizeSprintRunOnDone, +} from '../src/tools/update-job-status.js' + +type MockedPrisma = { + sprintTaskExecution: { findMany: ReturnType } + sprintRun: { + findUnique: ReturnType + update: ReturnType + } + story: { count: ReturnType } +} + +const mocked = prisma as unknown as MockedPrisma + +const LONG_SUMMARY = 'Refactor touched extra files for type narrowing.' + +function execRow(overrides: Record) { + return { + id: 'exec-' + Math.random().toString(36).slice(2, 8), + task_id: 't1', + order: 0, + status: 'DONE', + verify_result: 'ALIGNED', + verify_summary: null, + verify_required_snapshot: 'ALIGNED_OR_PARTIAL', + verify_only_snapshot: false, + task: { code: 'TASK-1', title: 'Sample task' }, + ...overrides, + } +} + +describe('checkSprintVerifyGate', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('rejects when no executions exist (claim-bug)', async () => { + mocked.sprintTaskExecution.findMany.mockResolvedValue([]) + const r = await checkSprintVerifyGate('job-x') + expect(r.allowed).toBe(false) + if (!r.allowed) expect(r.error).toMatch(/geen SprintTaskExecution-rows/i) + }) + + it('blocks PENDING/RUNNING executions', async () => { + mocked.sprintTaskExecution.findMany.mockResolvedValue([ + execRow({ status: 'PENDING' }), + execRow({ status: 'RUNNING' }), + ]) + const r = await checkSprintVerifyGate('job-x') + expect(r.allowed).toBe(false) + if (!r.allowed) { + expect(r.error).toMatch(/PENDING/) + expect(r.error).toMatch(/RUNNING/) + } + }) + + it('blocks FAILED executions', async () => { + mocked.sprintTaskExecution.findMany.mockResolvedValue([ + execRow({ status: 'FAILED' }), + ]) + const r = await checkSprintVerifyGate('job-x') + expect(r.allowed).toBe(false) + if (!r.allowed) expect(r.error).toMatch(/FAILED/) + }) + + it('blocks SKIPPED unless verify_required_snapshot=ANY', async () => { + mocked.sprintTaskExecution.findMany.mockResolvedValue([ + execRow({ status: 'SKIPPED', verify_required_snapshot: 'ALIGNED' }), + ]) + const r = await checkSprintVerifyGate('job-x') + expect(r.allowed).toBe(false) + if (!r.allowed) expect(r.error).toMatch(/SKIPPED/) + }) + + it('allows SKIPPED when verify_required_snapshot=ANY', async () => { + mocked.sprintTaskExecution.findMany.mockResolvedValue([ + execRow({ status: 'SKIPPED', verify_required_snapshot: 'ANY' }), + ]) + expect((await checkSprintVerifyGate('job-x')).allowed).toBe(true) + }) + + it('runs per-row gate for DONE executions', async () => { + // PARTIAL zonder summary onder ALIGNED_OR_PARTIAL → blocker + mocked.sprintTaskExecution.findMany.mockResolvedValue([ + execRow({ + status: 'DONE', + verify_result: 'PARTIAL', + verify_summary: null, + verify_required_snapshot: 'ALIGNED_OR_PARTIAL', + }), + ]) + const r = await checkSprintVerifyGate('job-x') + expect(r.allowed).toBe(false) + if (!r.allowed) expect(r.error).toMatch(/DONE-gate/) + }) + + it('passes when all DONE rows pass per-row gate', async () => { + mocked.sprintTaskExecution.findMany.mockResolvedValue([ + execRow({ verify_result: 'ALIGNED' }), + execRow({ + verify_result: 'PARTIAL', + verify_summary: LONG_SUMMARY, + verify_required_snapshot: 'ALIGNED_OR_PARTIAL', + }), + ]) + expect((await checkSprintVerifyGate('job-x')).allowed).toBe(true) + }) + + it('aggregates multiple blockers in one error message', async () => { + mocked.sprintTaskExecution.findMany.mockResolvedValue([ + execRow({ status: 'FAILED', task: { code: 'A', title: 'a' } }), + execRow({ status: 'PENDING', task: { code: 'B', title: 'b' } }), + ]) + const r = await checkSprintVerifyGate('job-x') + expect(r.allowed).toBe(false) + if (!r.allowed) { + expect(r.error).toMatch(/2 task\(s\) blokkeren/) + expect(r.error).toMatch(/A: a/) + expect(r.error).toMatch(/B: b/) + } + }) +}) + +describe('finalizeSprintRunOnDone', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('no-op when SprintRun already DONE (idempotent)', async () => { + mocked.sprintRun.findUnique.mockResolvedValue({ + id: 'sr-1', + status: 'DONE', + sprint_id: 's1', + }) + await finalizeSprintRunOnDone('sr-1') + expect(mocked.sprintRun.update).not.toHaveBeenCalled() + }) + + it('no-op when SprintRun does not exist', async () => { + mocked.sprintRun.findUnique.mockResolvedValue(null) + await finalizeSprintRunOnDone('sr-x') + expect(mocked.sprintRun.update).not.toHaveBeenCalled() + }) + + it('no-op when stories still open', async () => { + mocked.sprintRun.findUnique.mockResolvedValue({ + id: 'sr-1', + status: 'RUNNING', + sprint_id: 's1', + }) + mocked.story.count.mockResolvedValue(2) + await finalizeSprintRunOnDone('sr-1') + expect(mocked.sprintRun.update).not.toHaveBeenCalled() + }) + + it('sets SprintRun → DONE when all stories DONE/FAILED', async () => { + mocked.sprintRun.findUnique.mockResolvedValue({ + id: 'sr-1', + status: 'RUNNING', + sprint_id: 's1', + }) + mocked.story.count.mockResolvedValue(0) + await finalizeSprintRunOnDone('sr-1') + expect(mocked.sprintRun.update).toHaveBeenCalledWith({ + where: { id: 'sr-1' }, + data: expect.objectContaining({ + status: 'DONE', + finished_at: expect.any(Date), + }), + }) + }) +}) diff --git a/__tests__/update-task-execution.test.ts b/__tests__/update-task-execution.test.ts new file mode 100644 index 0000000..a893650 --- /dev/null +++ b/__tests__/update-task-execution.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../src/prisma.js', () => ({ + prisma: { + sprintTaskExecution: { + findUnique: vi.fn(), + update: vi.fn(), + }, + }, +})) + +vi.mock('../src/auth.js', async (importOriginal) => { + const original = await importOriginal() + return { ...original, requireWriteAccess: vi.fn() } +}) + +import { prisma } from '../src/prisma.js' +import { requireWriteAccess } from '../src/auth.js' +import { registerUpdateTaskExecutionTool } from '../src/tools/update-task-execution.js' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' + +const mockPrisma = prisma as unknown as { + sprintTaskExecution: { + findUnique: ReturnType + update: ReturnType + } +} +const mockAuth = requireWriteAccess as ReturnType + +const TOKEN_ID = 'tok-owner' + +function makeServer() { + let handler: (args: Record) => Promise + const server = { + registerTool: vi.fn((_name: string, _meta: unknown, fn: typeof handler) => { + handler = fn + }), + call: (args: Record) => handler(args), + } + registerUpdateTaskExecutionTool(server as unknown as McpServer) + return server +} + +function execRecord(overrides: Record = {}) { + return { + id: 'exec-1', + sprint_job_id: 'job-1', + sprint_job: { + claimed_by_token_id: TOKEN_ID, + status: 'CLAIMED', + kind: 'SPRINT_IMPLEMENTATION', + }, + ...overrides, + } +} + +beforeEach(() => { + vi.clearAllMocks() + mockAuth.mockResolvedValue({ + userId: 'u-1', + tokenId: TOKEN_ID, + username: 'agent', + isDemo: false, + }) +}) + +describe('update_task_execution', () => { + it('rejects when execution not found', async () => { + mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(null) + const server = makeServer() + const result = (await server.call({ + execution_id: 'missing', + status: 'RUNNING', + })) as { content: { text: string }[]; isError?: boolean } + expect(result.isError).toBe(true) + expect(result.content[0].text).toMatch(/not found/i) + }) + + it('rejects wrong job-kind', async () => { + mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue( + execRecord({ + sprint_job: { claimed_by_token_id: TOKEN_ID, status: 'CLAIMED', kind: 'TASK_IMPLEMENTATION' }, + }), + ) + const server = makeServer() + const result = (await server.call({ + execution_id: 'exec-1', + status: 'RUNNING', + })) as { content: { text: string }[]; isError?: boolean } + expect(result.isError).toBe(true) + expect(result.content[0].text).toMatch(/SPRINT_IMPLEMENTATION/) + }) + + it('rejects when token does not own the job', async () => { + mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue( + execRecord({ + sprint_job: { claimed_by_token_id: 'other-token', status: 'CLAIMED', kind: 'SPRINT_IMPLEMENTATION' }, + }), + ) + const server = makeServer() + const result = (await server.call({ + execution_id: 'exec-1', + status: 'RUNNING', + })) as { content: { text: string }[]; isError?: boolean } + expect(result.isError).toBe(true) + expect(result.content[0].text).toMatch(/Forbidden/) + }) + + it('rejects when job is in terminal state', async () => { + mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue( + execRecord({ + sprint_job: { claimed_by_token_id: TOKEN_ID, status: 'DONE', kind: 'SPRINT_IMPLEMENTATION' }, + }), + ) + const server = makeServer() + const result = (await server.call({ + execution_id: 'exec-1', + status: 'DONE', + })) as { content: { text: string }[]; isError?: boolean } + expect(result.isError).toBe(true) + expect(result.content[0].text).toMatch(/terminal/) + }) + + it('writes started_at on RUNNING', async () => { + mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(execRecord()) + mockPrisma.sprintTaskExecution.update.mockResolvedValue({ + id: 'exec-1', + status: 'RUNNING', + base_sha: null, + head_sha: null, + verify_result: null, + verify_summary: null, + skip_reason: null, + started_at: new Date(), + finished_at: null, + }) + + const server = makeServer() + await server.call({ execution_id: 'exec-1', status: 'RUNNING' }) + + const updateCall = mockPrisma.sprintTaskExecution.update.mock.calls[0][0] + expect(updateCall.data.status).toBe('RUNNING') + expect(updateCall.data.started_at).toBeInstanceOf(Date) + expect(updateCall.data.finished_at).toBeUndefined() + }) + + it('writes finished_at on DONE/FAILED/SKIPPED', async () => { + mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(execRecord()) + mockPrisma.sprintTaskExecution.update.mockResolvedValue({ + id: 'exec-1', + status: 'DONE', + base_sha: 'sha-base', + head_sha: 'sha-head', + verify_result: null, + verify_summary: null, + skip_reason: null, + started_at: new Date(), + finished_at: new Date(), + }) + + const server = makeServer() + await server.call({ + execution_id: 'exec-1', + status: 'DONE', + head_sha: 'sha-head', + }) + + const updateCall = mockPrisma.sprintTaskExecution.update.mock.calls[0][0] + expect(updateCall.data.status).toBe('DONE') + expect(updateCall.data.finished_at).toBeInstanceOf(Date) + expect(updateCall.data.head_sha).toBe('sha-head') + }) + + it('persists skip_reason on SKIPPED', async () => { + mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(execRecord()) + mockPrisma.sprintTaskExecution.update.mockResolvedValue({ + id: 'exec-1', + status: 'SKIPPED', + base_sha: null, + head_sha: null, + verify_result: null, + verify_summary: null, + skip_reason: 'no-op task', + started_at: null, + finished_at: new Date(), + }) + + const server = makeServer() + await server.call({ + execution_id: 'exec-1', + status: 'SKIPPED', + skip_reason: 'no-op task', + }) + + const updateCall = mockPrisma.sprintTaskExecution.update.mock.calls[0][0] + expect(updateCall.data.skip_reason).toBe('no-op task') + expect(updateCall.data.finished_at).toBeInstanceOf(Date) + }) +}) diff --git a/__tests__/verify-sprint-task.test.ts b/__tests__/verify-sprint-task.test.ts new file mode 100644 index 0000000..77bbc1b --- /dev/null +++ b/__tests__/verify-sprint-task.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../src/prisma.js', () => ({ + prisma: { + sprintTaskExecution: { + findUnique: vi.fn(), + findFirst: vi.fn(), + update: vi.fn(), + }, + }, +})) + +vi.mock('../src/auth.js', async (importOriginal) => { + const original = await importOriginal() + return { ...original, requireWriteAccess: vi.fn() } +}) + +vi.mock('../src/verify/classify.js', () => ({ + classifyDiffAgainstPlan: vi.fn(), +})) + +vi.mock('node:child_process', () => ({ + execFile: vi.fn(), +})) + +import { prisma } from '../src/prisma.js' +import { requireWriteAccess } from '../src/auth.js' +import { classifyDiffAgainstPlan } from '../src/verify/classify.js' +import { execFile } from 'node:child_process' +import { registerVerifySprintTaskTool } from '../src/tools/verify-sprint-task.js' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' + +const mockPrisma = prisma as unknown as { + sprintTaskExecution: { + findUnique: ReturnType + findFirst: ReturnType + update: ReturnType + } +} +const mockAuth = requireWriteAccess as ReturnType +const mockClassify = classifyDiffAgainstPlan as ReturnType +const mockExecFile = execFile as unknown as ReturnType + +const TOKEN_ID = 'tok-owner' + +function makeServer() { + let handler: (args: Record) => Promise + const server = { + registerTool: vi.fn((_name: string, _meta: unknown, fn: typeof handler) => { + handler = fn + }), + call: (args: Record) => handler(args), + } + registerVerifySprintTaskTool(server as unknown as McpServer) + return server +} + +function stubGitDiff(stdout: string) { + // promisify(execFile) calls (cmd, args, opts, cb) + mockExecFile.mockImplementation( + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: null, result: { stdout: string; stderr: string }) => void, + ) => { + cb(null, { stdout, stderr: '' }) + }, + ) +} + +function execRecord(overrides: Record = {}) { + return { + id: 'exec-1', + sprint_job_id: 'job-1', + order: 0, + base_sha: 'sha-base', + plan_snapshot: 'frozen plan', + verify_required_snapshot: 'ALIGNED_OR_PARTIAL', + verify_only_snapshot: false, + sprint_job: { + claimed_by_token_id: TOKEN_ID, + status: 'CLAIMED', + kind: 'SPRINT_IMPLEMENTATION', + }, + ...overrides, + } +} + +beforeEach(() => { + vi.clearAllMocks() + mockAuth.mockResolvedValue({ + userId: 'u-1', + tokenId: TOKEN_ID, + username: 'agent', + isDemo: false, + }) +}) + +describe('verify_sprint_task', () => { + it('rejects when execution not found', async () => { + mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(null) + const server = makeServer() + const result = (await server.call({ + execution_id: 'missing', + worktree_path: '/tmp/wt', + })) as { content: { text: string }[]; isError?: boolean } + expect(result.isError).toBe(true) + expect(result.content[0].text).toMatch(/not found/i) + }) + + it('rejects wrong token', async () => { + mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue( + execRecord({ + sprint_job: { claimed_by_token_id: 'other', status: 'CLAIMED', kind: 'SPRINT_IMPLEMENTATION' }, + }), + ) + const server = makeServer() + const result = (await server.call({ + execution_id: 'exec-1', + worktree_path: '/tmp/wt', + })) as { content: { text: string }[]; isError?: boolean } + expect(result.isError).toBe(true) + expect(result.content[0].text).toMatch(/Forbidden/) + }) + + it('PARTIAL with summary returns allowed_for_done=true under ALIGNED_OR_PARTIAL', async () => { + mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(execRecord()) + stubGitDiff('diff --git a/x b/x\n+ change\n') + mockClassify.mockReturnValue({ result: 'PARTIAL', reasoning: 'extra files' }) + + const server = makeServer() + const result = (await server.call({ + execution_id: 'exec-1', + worktree_path: '/tmp/wt', + summary: 'Refactor touched extra files for type narrowing.', + })) as { content: { text: string }[] } + const body = JSON.parse(result.content[0].text) + expect(body.result).toBe('partial') + expect(body.allowed_for_done).toBe(true) + expect(body.reason).toBeNull() + }) + + it('PARTIAL without summary returns allowed_for_done=false', async () => { + mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(execRecord()) + stubGitDiff('diff --git a/x b/x\n') + mockClassify.mockReturnValue({ result: 'PARTIAL', reasoning: 'r' }) + + const server = makeServer() + const result = (await server.call({ + execution_id: 'exec-1', + worktree_path: '/tmp/wt', + })) as { content: { text: string }[] } + const body = JSON.parse(result.content[0].text) + expect(body.result).toBe('partial') + expect(body.allowed_for_done).toBe(false) + expect(body.reason).toMatch(/summary/i) + }) + + it('DIVERGENT with strict ALIGNED returns allowed_for_done=false', async () => { + mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue( + execRecord({ verify_required_snapshot: 'ALIGNED' }), + ) + stubGitDiff('diff --git a/x b/x\n') + mockClassify.mockReturnValue({ result: 'DIVERGENT', reasoning: 'r' }) + + const server = makeServer() + const result = (await server.call({ + execution_id: 'exec-1', + worktree_path: '/tmp/wt', + summary: 'Long enough summary describing the deviation rationale clearly.', + })) as { content: { text: string }[] } + const body = JSON.parse(result.content[0].text) + expect(body.allowed_for_done).toBe(false) + expect(body.reason).toMatch(/ALIGNED/) + }) + + it('auto-fills base_sha from previous DONE execution head_sha', async () => { + mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue( + execRecord({ order: 1, base_sha: null }), + ) + mockPrisma.sprintTaskExecution.findFirst.mockResolvedValue({ + head_sha: 'prev-head-sha', + }) + stubGitDiff('diff\n') + mockClassify.mockReturnValue({ result: 'ALIGNED', reasoning: 'ok' }) + + const server = makeServer() + const result = (await server.call({ + execution_id: 'exec-1', + worktree_path: '/tmp/wt', + })) as { content: { text: string }[] } + const body = JSON.parse(result.content[0].text) + expect(body.base_sha).toBe('prev-head-sha') + + // Persisted back to row + const updateCalls = mockPrisma.sprintTaskExecution.update.mock.calls + const baseShaPersist = updateCalls.find((c) => c[0].data.base_sha === 'prev-head-sha') + expect(baseShaPersist).toBeDefined() + }) + + it('errors when base_sha cannot be derived (no prior DONE)', async () => { + mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue( + execRecord({ order: 2, base_sha: null }), + ) + mockPrisma.sprintTaskExecution.findFirst.mockResolvedValue(null) + + const server = makeServer() + const result = (await server.call({ + execution_id: 'exec-1', + worktree_path: '/tmp/wt', + })) as { content: { text: string }[]; isError?: boolean } + expect(result.isError).toBe(true) + expect(result.content[0].text).toMatch(/MISSING_BASE_SHA/) + }) +}) From 98786f763f3e1ceff61b3bdb1ab7702308c68a76 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Thu, 7 May 2026 12:56:22 +0200 Subject: [PATCH 06/27] =?UTF-8?q?PBI-50=20F5:=20README=20=E2=80=94=20verif?= =?UTF-8?q?y=5Fsprint=5Ftask,=20update=5Ftask=5Fexecution,=20job=5Fheartbe?= =?UTF-8?q?at?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drie nieuwe tools voor SPRINT_IMPLEMENTATION-flow toegevoegd aan tool-tabel. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index af91dbd..fb20e38 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,9 @@ activity and create todos via native tool calls instead of curl. | `check_queue_empty` | Synchronous, non-blocking count of active jobs (QUEUED/CLAIMED/RUNNING); optional `product_id` scope | no | | `set_pbi_pr` | Write `pr_url` on a PBI and clear `pr_merged_at`. Idempotent: re-calling overwrites `pr_url` and resets `pr_merged_at` to null | no | | `mark_pbi_pr_merged` | Set `pr_merged_at = now()` on a PBI. Requires `pr_url` to already be set. Idempotent: re-calling overwrites the timestamp | no | +| `verify_sprint_task` | SPRINT_IMPLEMENTATION-flow: compare a `SprintTaskExecution`'s frozen `plan_snapshot` against `git diff ...HEAD`. Returns `verify_result` + `allowed_for_done`. For `task[1..N]` zonder base_sha vult de tool die in op basis van de head_sha van de vorige DONE-execution | yes (read-only) | +| `update_task_execution` | SPRINT_IMPLEMENTATION-flow: mutate `SprintTaskExecution.status` (PENDING/RUNNING/DONE/FAILED/SKIPPED). Token must own the parent SPRINT-job. Idempotent | no | +| `job_heartbeat` | Extend `claude_jobs.lease_until` by 5 min. For SPRINT-jobs: response includes `sprint_run_status` + `sprint_run_pause_reason` so the worker can break its task-loop on UI-side cancel/pause | no | Demo accounts may read but writes return `PERMISSION_DENIED`. From 458b7a7d450899d6873bcd4cc9cad62c7e17975e Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Thu, 7 May 2026 17:10:02 +0200 Subject: [PATCH 07/27] PBI-57: 'skipped' no-op exit + cascade preserves original error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When verify_task_against_plan returns EMPTY because the requested changes already live in origin/main (parallel work, earlier PR, race between siblings), the worker had no clean exit: update_job_status only accepted running|done|failed. 'failed' triggered the PBI fail-cascade which then overwrote the error column with 'cancelled_by_self' and cancelled all sibling tasks of the PBI — see Scrum4Me job cmovkur8 / T-695 for the reference incident. This change introduces a fourth status and tightens the cascade: ST-1273 — 'skipped' exit in update_job_status (T-706 + T-707) - src/tools/update-job-status.ts: status enum + DB_STATUS_MAP + resolveNextAction now include 'skipped'. cleanupWorktreeForTerminalStatus signature widened to ('done'|'failed'|'skipped'); SKIPPED uses keepBranch semantics identical to FAILED (no push, no branch keep). New input guard: 'skipped' is only valid for TASK_IMPLEMENTATION jobs and requires a non-empty error (≥10 chars) explaining the reason — it bypasses the verify-gate, the auto-PR, the SprintRun finalize/fail paths and the PBI fail-cascade. Locks are still released on terminal exit. - Tool description spells out when to pick 'skipped' so MCP clients see it. - New __tests__/update-job-status-skipped.test.ts: resolveNextAction with 'skipped' (wait_for_job_again / queue_empty), and cleanupWorktreeForTerminalStatus with status='skipped' (keepBranch=false even with a branch reported, defers cleanup with active siblings). ST-1274 — cascade ignores SKIPPED + appends trace (T-708 + T-709) - src/cancel/pbi-cascade.ts: runCascade reads job.status, returns EMPTY when status === 'SKIPPED' (no sibling cancel). Trace persistence now reads the current error first and writes `${original}\n---\n${trace}` (truncated at 1900 chars), so the original failure cause is preserved for forensics instead of being overwritten. - New cases in __tests__/cancel-pbi-cascade.test.ts: SKIPPED entry-guard (no findMany / updateMany / update), original error preserved with trace appended after '---', trace-only fallback when no original error, 1900-char truncation keeps the head of the original. All 282 scrum4me-mcp tests pass; tsc build clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/cancel-pbi-cascade.test.ts | 62 ++++++++++++++ __tests__/update-job-status-skipped.test.ts | 95 +++++++++++++++++++++ src/cancel/pbi-cascade.ts | 14 ++- src/tools/update-job-status.ts | 50 +++++++++-- 4 files changed, 212 insertions(+), 9 deletions(-) create mode 100644 __tests__/update-job-status-skipped.test.ts diff --git a/__tests__/cancel-pbi-cascade.test.ts b/__tests__/cancel-pbi-cascade.test.ts index 8b55688..e884c9f 100644 --- a/__tests__/cancel-pbi-cascade.test.ts +++ b/__tests__/cancel-pbi-cascade.test.ts @@ -285,4 +285,66 @@ describe('cancelPbiOnFailure', () => { expect(out.warnings.some((w) => w.includes('boom'))).toBe(true) }) + + it('no-ops when failed job has status SKIPPED (no-op exit, niet een echte fail)', async () => { + mockPrisma.claudeJob.findUnique.mockResolvedValue({ ...FAILED_JOB, status: 'SKIPPED' }) + + const out = await cancelPbiOnFailure('job-failed') + + expect(out.cancelled_job_ids).toEqual([]) + expect(mockPrisma.claudeJob.findMany).not.toHaveBeenCalled() + expect(mockPrisma.claudeJob.updateMany).not.toHaveBeenCalled() + expect(mockPrisma.claudeJob.update).not.toHaveBeenCalled() + }) + + it('appends the cascade trace to an existing error (preserves original cause)', async () => { + // findUnique wordt twee keer aangeroepen: eerst voor failedJob (status FAILED + originele error), + // daarna door de append-trace om de huidige error te lezen vóór update. + mockPrisma.claudeJob.findUnique + .mockResolvedValueOnce({ ...FAILED_JOB, status: 'FAILED' }) + .mockResolvedValueOnce({ error: 'timeout: agent died after 5min' }) + mockPrisma.claudeJob.findMany.mockResolvedValue([]) + + await cancelPbiOnFailure('job-failed') + + expect(mockPrisma.claudeJob.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'job-failed' }, + data: expect.objectContaining({ + error: expect.stringMatching(/timeout: agent died after 5min[\s\S]*---[\s\S]*cancelled_by_self/), + }), + }), + ) + }) + + it('falls back to trace-only when there is no existing error', async () => { + mockPrisma.claudeJob.findUnique + .mockResolvedValueOnce({ ...FAILED_JOB, status: 'FAILED' }) + .mockResolvedValueOnce({ error: null }) + mockPrisma.claudeJob.findMany.mockResolvedValue([]) + + await cancelPbiOnFailure('job-failed') + + const updateCall = mockPrisma.claudeJob.update.mock.calls[0]?.[0] as + | { data: { error: string } } + | undefined + expect(updateCall?.data.error).toMatch(/^cancelled_by_self/) + expect(updateCall?.data.error).not.toContain('---') + }) + + it('truncates the merged error at 1900 chars while preserving the head of the original', async () => { + const longOriginal = 'X'.repeat(1800) + mockPrisma.claudeJob.findUnique + .mockResolvedValueOnce({ ...FAILED_JOB, status: 'FAILED' }) + .mockResolvedValueOnce({ error: longOriginal }) + mockPrisma.claudeJob.findMany.mockResolvedValue([]) + + await cancelPbiOnFailure('job-failed') + + const updateCall = mockPrisma.claudeJob.update.mock.calls[0]?.[0] as + | { data: { error: string } } + | undefined + expect(updateCall?.data.error.length).toBeLessThanOrEqual(1900) + expect(updateCall?.data.error.startsWith('X')).toBe(true) + }) }) diff --git a/__tests__/update-job-status-skipped.test.ts b/__tests__/update-job-status-skipped.test.ts new file mode 100644 index 0000000..53e745c --- /dev/null +++ b/__tests__/update-job-status-skipped.test.ts @@ -0,0 +1,95 @@ +// Unit-tests voor de no-op SKIPPED exit-route in update_job_status (PBI-57 ST-1273). +// Volle handler-integratie wordt niet hier getest — die hangt aan tientallen +// MCP/Prisma-mocks. Wel testen we de geëxporteerde helpers die expliciet +// SKIPPED-aware zijn gemaakt: resolveNextAction en cleanupWorktreeForTerminalStatus. + +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../src/prisma.js', () => ({ + prisma: { + claudeJob: { findUnique: vi.fn(), count: vi.fn() }, + }, +})) + +vi.mock('../src/git/worktree.js', () => ({ + removeWorktreeForJob: vi.fn(), +})) + +vi.mock('../src/tools/wait-for-job.js', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + resolveRepoRoot: vi.fn(), + } +}) + +import { prisma } from '../src/prisma.js' +import { removeWorktreeForJob } from '../src/git/worktree.js' +import { resolveRepoRoot } from '../src/tools/wait-for-job.js' +import { + cleanupWorktreeForTerminalStatus, + resolveNextAction, +} from '../src/tools/update-job-status.js' + +const mockRemove = removeWorktreeForJob as ReturnType +const mockResolve = resolveRepoRoot as ReturnType +const mockPrisma = prisma as unknown as { + claudeJob: { + findUnique: ReturnType + count: ReturnType + } +} + +beforeEach(() => { + vi.clearAllMocks() + mockPrisma.claudeJob.findUnique.mockResolvedValue({ task: { story_id: 'story-default' } }) + mockPrisma.claudeJob.count.mockResolvedValue(0) +}) + +describe('resolveNextAction — skipped pad', () => { + it('returns wait_for_job_again when queue has jobs after skipped', () => { + expect(resolveNextAction(2, 'skipped')).toBe('wait_for_job_again') + }) + + it('returns queue_empty when queue is empty after skipped', () => { + expect(resolveNextAction(0, 'skipped')).toBe('queue_empty') + }) +}) + +describe('cleanupWorktreeForTerminalStatus — skipped pad', () => { + it('calls removeWorktreeForJob with keepBranch=false when skipped (no push happened)', async () => { + mockResolve.mockResolvedValue('/repos/my-project') + mockRemove.mockResolvedValue({ removed: true }) + + await cleanupWorktreeForTerminalStatus('prod-001', 'job-skip', 'skipped', undefined) + + expect(mockRemove).toHaveBeenCalledWith({ + repoRoot: '/repos/my-project', + jobId: 'job-skip', + keepBranch: false, + }) + }) + + it('keeps keepBranch=false when skipped even if a branch is reported', async () => { + mockResolve.mockResolvedValue('/repos/my-project') + mockRemove.mockResolvedValue({ removed: true }) + + await cleanupWorktreeForTerminalStatus('prod-001', 'job-skip', 'skipped', 'feat/job-skip') + + expect(mockRemove).toHaveBeenCalledWith({ + repoRoot: '/repos/my-project', + jobId: 'job-skip', + keepBranch: false, + }) + }) + + it('defers cleanup when sibling jobs in same story are still active (skipped path)', async () => { + mockResolve.mockResolvedValue('/repos/my-project') + mockPrisma.claudeJob.findUnique.mockResolvedValue({ task: { story_id: 'story-shared' } }) + mockPrisma.claudeJob.count.mockResolvedValue(1) + + await cleanupWorktreeForTerminalStatus('prod-001', 'job-skip', 'skipped', undefined) + + expect(mockRemove).not.toHaveBeenCalled() + }) +}) diff --git a/src/cancel/pbi-cascade.ts b/src/cancel/pbi-cascade.ts index d7e4a61..19b1b0e 100644 --- a/src/cancel/pbi-cascade.ts +++ b/src/cancel/pbi-cascade.ts @@ -47,6 +47,7 @@ async function runCascade(failedJobId: string): Promise { select: { id: true, kind: true, + status: true, product_id: true, task_id: true, branch: true, @@ -65,6 +66,8 @@ async function runCascade(failedJobId: string): Promise { if (!failedJob) return EMPTY if (failedJob.kind !== 'TASK_IMPLEMENTATION') return EMPTY + // SKIPPED is een no-op exit (zie update_job_status). Geen cascade naar siblings. + if (failedJob.status === 'SKIPPED') return EMPTY const pbi = failedJob.task?.story?.pbi if (!pbi) return EMPTY @@ -194,12 +197,21 @@ async function runCascade(failedJobId: string): Promise { // 4. Persist a trace on the failed-job's error field so the operator can // follow up. Use a structured one-liner to keep the column readable. + // Append to the existing error (separated by '\n---\n') so the original + // failure reason is preserved instead of being overwritten by the trace. const trace = formatTrace(outcome) if (trace) { try { + const fresh = await prisma.claudeJob.findUnique({ + where: { id: failedJobId }, + select: { error: true }, + }) + const merged = fresh?.error + ? `${fresh.error}\n---\n${trace}`.slice(0, 1900) + : trace.slice(0, 1900) await prisma.claudeJob.update({ where: { id: failedJobId }, - data: { error: trace.slice(0, 1900) }, + data: { error: merged }, }) } catch (err) { console.warn(`[pbi-cascade] failed to persist trace for ${failedJobId}:`, err) diff --git a/src/tools/update-job-status.ts b/src/tools/update-job-status.ts index d03336e..5d35399 100644 --- a/src/tools/update-job-status.ts +++ b/src/tools/update-job-status.ts @@ -1,6 +1,12 @@ -// update_job_status — agent rapporteert voortgang: running | done | failed. +// update_job_status — agent rapporteert voortgang: running | done | failed | skipped. // Auth: Bearer-token moet matchen claimed_by_token_id van de job. // Triggert automatisch een SSE-event naar de UI via pg_notify. +// +// 'skipped' is de no-op exit voor TASK_IMPLEMENTATION jobs waar verify_task_against_plan +// EMPTY oplevert omdat de wijzigingen al in origin/main staan (parallel werk, eerdere +// PR, race tussen siblings). Geen verify-gate, geen PR, geen cascade. De worker moet +// de bijbehorende task apart op DONE zetten via update_task_status als de inhoudelijke +// vereisten al zijn voldaan. import { z } from 'zod' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' @@ -38,7 +44,7 @@ async function fetchConflictFiles(prUrl: string): Promise { const inputSchema = z.object({ job_id: z.string().min(1), - status: z.enum(['running', 'done', 'failed']), + status: z.enum(['running', 'done', 'failed', 'skipped']), branch: z.string().min(1).optional(), summary: z.string().max(1_000).optional(), error: z.string().max(2_000).optional(), @@ -52,7 +58,7 @@ const inputSchema = z.object({ export async function cleanupWorktreeForTerminalStatus( productId: string, jobId: string, - status: 'done' | 'failed', + status: 'done' | 'failed' | 'skipped', branch: string | undefined, ): Promise { const repoRoot = await resolveRepoRoot(productId) @@ -329,11 +335,12 @@ const DB_STATUS_MAP = { running: 'RUNNING', done: 'DONE', failed: 'FAILED', + skipped: 'SKIPPED', } as const export function resolveNextAction( queueCount: number, - status: 'running' | 'done' | 'failed', + status: 'running' | 'done' | 'failed' | 'skipped', ): 'wait_for_job_again' | 'queue_empty' | 'idle' { if (status === 'running') return 'idle' return queueCount > 0 ? 'wait_for_job_again' : 'queue_empty' @@ -501,13 +508,18 @@ export function registerUpdateJobStatusTool(server: McpServer) { title: 'Update job status', description: 'Report progress on a claimed ClaudeJob. Allowed transitions from CLAIMED/RUNNING: ' + - 'running (start), done (finished), failed (error). ' + + 'running (start), done (finished), failed (error), skipped (no-op exit). ' + '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, EMPTY (unless task.verify_only is true), or when the verify level ' + 'doesn’t meet task.verify_required: ALIGNED-only is strict; ALIGNED_OR_PARTIAL accepts ' + 'PARTIAL/DIVERGENT but requires a non-empty summary (≥20 chars) explaining the drift; ANY ' + 'accepts everything. ' + + "Use 'skipped' for TASK_IMPLEMENTATION when verify_task_against_plan returns EMPTY because " + + 'the requested changes are already present in origin/main (parallel work, earlier PR, race ' + + "between siblings). 'skipped' requires a non-empty error (≥10 chars) describing the reason " + + "(e.g. 'no_op_changes_already_in_main') and skips the verify-gate, auto-PR and PBI fail-cascade. " + + 'Mark the underlying task DONE separately via update_task_status if its requirements are met. ' + 'Automatically emits an SSE event so the Scrum4Me UI updates in real time. ' + 'Optionally accepts token-usage fields (model_id + input/output/cache_read/cache_write tokens) ' + 'for cost tracking — typically populated by a PostToolUse hook from the local Claude Code transcript, ' + @@ -565,6 +577,23 @@ export function registerUpdateJobStatusTool(server: McpServer) { return toolError(`Job is already in terminal state: ${job.status.toLowerCase()}`) } + // 'skipped' = no-op exit. Only valid for TASK_IMPLEMENTATION (verify=EMPTY + // patroon) en vereist een non-empty error met ≥10 chars uitleg, zoals + // 'no_op_changes_already_in_main'. Geen verify-gate, geen PR, geen + // PBI fail-cascade, geen propagation naar task/story/PBI. + if (status === 'skipped') { + if (job.kind !== 'TASK_IMPLEMENTATION') { + return toolError( + `'skipped' is alleen toegestaan voor TASK_IMPLEMENTATION (kind=${job.kind})`, + ) + } + if (!error || error.trim().length < 10) { + return toolError( + "'skipped' vereist non-empty error met reden (≥10 chars), bv. 'no_op_changes_already_in_main'", + ) + } + } + // For DONE: push first, adjust DB status based on result let actualStatus = status let pushedAt: Date | undefined @@ -663,7 +692,9 @@ export function registerUpdateJobStatusTool(server: McpServer) { data: { status: dbStatus, ...(actualStatus === 'running' ? { started_at: now } : {}), - ...(actualStatus === 'done' || actualStatus === 'failed' ? { finished_at: now } : {}), + ...(actualStatus === 'done' || actualStatus === 'failed' || actualStatus === 'skipped' + ? { finished_at: now } + : {}), ...(branchToWrite !== undefined ? { branch: branchToWrite } : {}), ...(pushedAt !== undefined ? { pushed_at: pushedAt } : {}), ...(summary !== undefined ? { summary } : {}), @@ -881,7 +912,10 @@ export function registerUpdateJobStatusTool(server: McpServer) { } // Best-effort worktree cleanup on terminal transitions (skip if push failed — worktree preserved) - if ((actualStatus === 'done' || actualStatus === 'failed') && !skipWorktreeCleanup) { + if ( + (actualStatus === 'done' || actualStatus === 'failed' || actualStatus === 'skipped') && + !skipWorktreeCleanup + ) { await cleanupWorktreeForTerminalStatus(job.product_id, job_id, actualStatus, branchToWrite) } @@ -973,7 +1007,7 @@ export function registerUpdateJobStatusTool(server: McpServer) { // PBI-9: release product-worktree locks on terminal transitions. // No-op for jobs without registered locks (i.e. TASK_IMPLEMENTATION). - if (actualStatus === 'done' || actualStatus === 'failed') { + if (actualStatus === 'done' || actualStatus === 'failed' || actualStatus === 'skipped') { await releaseLocksOnTerminal(job_id) } From 18c34b63de8f102caa35985396f1763cc351b754 Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Thu, 7 May 2026 21:34:41 +0200 Subject: [PATCH 08/27] =?UTF-8?q?PBI-55:=20src/lib/push-trigger.ts=20?= =?UTF-8?q?=E2=80=93=20fire-and-forget=20push=20helper=20with=205s=20Abort?= =?UTF-8?q?Controller=20timeout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 4 ++++ src/lib/push-trigger.ts | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 src/lib/push-trigger.ts diff --git a/.env.example b/.env.example index 6a3e89c..4a99af7 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,7 @@ DATABASE_URL="postgresql://user:pass@host:5432/dbname" # API token from Scrum4Me → /settings/tokens SCRUM4ME_TOKEN="" + +# Internal push endpoint (main-app) for web-push notifications +INTERNAL_PUSH_URL="" +INTERNAL_PUSH_SECRET="" diff --git a/src/lib/push-trigger.ts b/src/lib/push-trigger.ts new file mode 100644 index 0000000..fb0434a --- /dev/null +++ b/src/lib/push-trigger.ts @@ -0,0 +1,22 @@ +export type PushPayload = { title: string; body: string; url: string; tag?: string }; + +export async function triggerPush(userId: string, payload: PushPayload): Promise { + const url = process.env.INTERNAL_PUSH_URL; + const secret = process.env.INTERNAL_PUSH_SECRET; + if (!url || !secret) return; // feature-gated + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${secret}` }, + body: JSON.stringify({ userId, payload }), + signal: controller.signal, + }); + if (!res.ok) console.warn('[push-trigger] non-2xx', res.status); + } catch (err) { + console.error('[push-trigger]', err); + } finally { + clearTimeout(timeout); + } +} From 4c476464ec3284b03971d945b46f78d117f5f120 Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Thu, 7 May 2026 21:37:20 +0200 Subject: [PATCH 09/27] =?UTF-8?q?PBI-55:=20ask-user-question=20=E2=80=93?= =?UTF-8?q?=20triggerPush=20na=20claudeQuestion.create?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/tools/ask-user-question.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/tools/ask-user-question.ts b/src/tools/ask-user-question.ts index b4d5a59..3618b5e 100644 --- a/src/tools/ask-user-question.ts +++ b/src/tools/ask-user-question.ts @@ -10,6 +10,7 @@ import { prisma } from '../prisma.js' import { requireWriteAccess } from '../auth.js' import { userCanAccessStory, userOwnsIdea } from '../access.js' import { toolError, toolJson, withToolErrors } from '../errors.js' +import { triggerPush } from '../lib/push-trigger.js' const PENDING_TTL_HOURS = 24 const POLL_INTERVAL_MS = 2_000 @@ -127,6 +128,13 @@ export function registerAskUserQuestionTool(server: McpServer) { }, }) + void triggerPush(auth.userId, { + title: 'Claude heeft een vraag', + body: question.slice(0, 120), + url: '/notifications', + tag: `claude-q-${created.id}`, + }) + // Async-mode (default): return direct. if (!wait_seconds || wait_seconds === 0) { return toolJson(summarize(created)) From ab32a72ce055d9ca59a035bceca4f61289584085 Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Thu, 7 May 2026 21:42:19 +0200 Subject: [PATCH 10/27] =?UTF-8?q?PBI-55:=20update-job-status=20=E2=80=93?= =?UTF-8?q?=20NOTIFY=20payload-fix=20(kind/idea=5Fid)=20+=20triggerPush=20?= =?UTF-8?q?on=20done/failed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/tools/update-job-status.ts | 49 +++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/src/tools/update-job-status.ts b/src/tools/update-job-status.ts index 5d35399..8fcb83e 100644 --- a/src/tools/update-job-status.ts +++ b/src/tools/update-job-status.ts @@ -24,6 +24,7 @@ import { pushBranchForJob } from '../git/push.js' import { createPullRequest, markPullRequestReady } from '../git/pr.js' import { cancelPbiOnFailure } from '../cancel/pbi-cascade.js' import { propagateStatusUpwards } from '../lib/tasks-status-update.js' +import { triggerPush } from '../lib/push-trigger.js' import { transition as prFlowTransition } from '../flow/pr-flow.js' import { transition as sprintRunTransition } from '../flow/sprint-run.js' import { executeEffects } from '../flow/effects.js' @@ -887,30 +888,40 @@ export function registerUpdateJobStatusTool(server: McpServer) { try { const pg = new Client({ connectionString: process.env.DATABASE_URL }) await pg.connect() - await pg.query( - `SELECT pg_notify('scrum4me_changes', $1)`, - [ - JSON.stringify({ - type: 'claude_job_status', - job_id: updated.id, - task_id: job.task_id, - user_id: job.user_id, - product_id: job.product_id, - status: actualStatus, - 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, - }), - ], - ) + const notifyPayload: Record = { + type: 'claude_job_status', + job_id: updated.id, + user_id: job.user_id, + product_id: job.product_id, + status: actualStatus, + 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, + } + if (job.task_id) notifyPayload.task_id = job.task_id + if (job.idea_id) { + notifyPayload.idea_id = job.idea_id + notifyPayload.kind = job.kind + } + await pg.query(`SELECT pg_notify('scrum4me_changes', $1)`, [JSON.stringify(notifyPayload)]) await pg.end() } catch { // non-fatal — status is already persisted } + if (actualStatus === 'failed' || actualStatus === 'done') { + const isFailed = actualStatus === 'failed' + void triggerPush(job.user_id, { + title: isFailed ? 'Job gefaald' : 'Job klaar', + body: (updated.summary ?? updated.error ?? `Job ${updated.id}`).slice(0, 120), + url: updated.pr_url ?? '/dashboard', + tag: `job-${updated.id}`, + }) + } + // Best-effort worktree cleanup on terminal transitions (skip if push failed — worktree preserved) if ( (actualStatus === 'done' || actualStatus === 'failed' || actualStatus === 'skipped') && From 6aa43ff7dd19f593283a4cf8385b15606fc9a7dc Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Thu, 7 May 2026 21:44:41 +0200 Subject: [PATCH 11/27] PBI-55: .env.example descriptive push placeholders + README push-integration section Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 6 ++++-- README.md | 4 ++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 4a99af7..62b28f7 100644 --- a/.env.example +++ b/.env.example @@ -5,5 +5,7 @@ DATABASE_URL="postgresql://user:pass@host:5432/dbname" SCRUM4ME_TOKEN="" # Internal push endpoint (main-app) for web-push notifications -INTERNAL_PUSH_URL="" -INTERNAL_PUSH_SECRET="" +# Set to the main-app /api/internal/push/send URL; leave empty to disable push (feature-gated). +INTERNAL_PUSH_URL="https://scrum4me.example.com/api/internal/push/send" +# Shared secret (≥32 chars) — must match INTERNAL_PUSH_SECRET in the main-app env. +INTERNAL_PUSH_SECRET="" diff --git a/README.md b/README.md index fb20e38..793cc07 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,10 @@ Minimale agent-prompt (geen CLAUDE.md-context nodig): > *Pak de volgende job uit de Scrum4Me-queue.* +## Web-push integration + +When `INTERNAL_PUSH_URL` and `INTERNAL_PUSH_SECRET` are set, the MCP server fires a fire-and-forget push notification to the main-app's internal endpoint (`/api/internal/push/send`) on two events: when `ask_user_question` creates a new question (tag `claude-q-`), and when `update_job_status` transitions a job to `done` or `failed` (tag `job-`). Both calls are wrapped in a 5 s `AbortController` timeout and a `try/catch` so a push failure never interrupts the tool response. Omitting the env vars disables the feature entirely. The `INTERNAL_PUSH_SECRET` value must match the one configured in the main-app; generate a fresh secret with `openssl rand -hex 32`. + ## Schema sync The Prisma schema is the source of truth in the upstream Scrum4Me From 070c03974063055f871a78f07fc274c7fd808eec Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Fri, 8 May 2026 11:03:15 +0200 Subject: [PATCH 12/27] feat(PBI-67/ST-1298): job-config resolver + kind-default-matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nieuwe centrale resolver `resolveJobConfig(job, product, task?)` die per ClaudeJob bepaalt welk model + thinking-budget + permission-mode + max_turns + allowed_tools de worker moet gebruiken. Override-cascade (eerste match wint): task.requires_opus → job.requested_* → product.preferred_* → kind-default Kind-defaults: IDEA_GRILL sonnet-4-6 thinking 12k plan IDEA_MAKE_PLAN opus-4-7 thinking 24k plan PLAN_CHAT sonnet-4-6 thinking 6k plan (max 5 turns) TASK_IMPLEMENTATION sonnet-4-6 thinking 6k bypassPermissions SPRINT_IMPLEMENTATION sonnet-4-6 thinking 6k bypassPermissions 19 unit tests (alle 5 kinds × cascade-niveaus). Geen externe deps. Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/job-config.test.ts | 97 ++++++++++++++++++++++++++++ src/lib/job-config.ts | 120 +++++++++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 __tests__/job-config.test.ts create mode 100644 src/lib/job-config.ts diff --git a/__tests__/job-config.test.ts b/__tests__/job-config.test.ts new file mode 100644 index 0000000..3a7af58 --- /dev/null +++ b/__tests__/job-config.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'vitest' +import { getKindDefault, resolveJobConfig } from '../src/lib/job-config.js' + +const KIND_EXPECTED = { + IDEA_GRILL: { model: 'claude-sonnet-4-6', thinking_budget: 12000, permission_mode: 'plan', max_turns: 15 }, + IDEA_MAKE_PLAN: { model: 'claude-opus-4-7', thinking_budget: 24000, permission_mode: 'plan', max_turns: 20 }, + PLAN_CHAT: { model: 'claude-sonnet-4-6', thinking_budget: 6000, permission_mode: 'plan', max_turns: 5 }, + TASK_IMPLEMENTATION: { model: 'claude-sonnet-4-6', thinking_budget: 6000, permission_mode: 'bypassPermissions', max_turns: 50 }, + SPRINT_IMPLEMENTATION: { model: 'claude-sonnet-4-6', thinking_budget: 6000, permission_mode: 'bypassPermissions', max_turns: null }, +} as const + +describe('getKindDefault', () => { + for (const [kind, expected] of Object.entries(KIND_EXPECTED)) { + it(`returnt de juiste defaults voor ${kind}`, () => { + const cfg = getKindDefault(kind) + expect(cfg.model).toBe(expected.model) + expect(cfg.thinking_budget).toBe(expected.thinking_budget) + expect(cfg.permission_mode).toBe(expected.permission_mode) + expect(cfg.max_turns).toBe(expected.max_turns) + }) + } + + it('valt terug op een veilige fallback voor onbekende kinds', () => { + const cfg = getKindDefault('SOMETHING_NEW') + expect(cfg.model).toBe('claude-sonnet-4-6') + expect(cfg.permission_mode).toBe('default') + }) +}) + +describe('resolveJobConfig — geen overrides', () => { + for (const kind of Object.keys(KIND_EXPECTED)) { + it(`returnt kind-default voor ${kind} zonder overrides`, () => { + const cfg = resolveJobConfig({ kind }, {}) + expect(cfg).toEqual(getKindDefault(kind)) + }) + } +}) + +describe('resolveJobConfig — cascade', () => { + it('product.preferred_model overrult kind-default', () => { + const cfg = resolveJobConfig({ kind: 'TASK_IMPLEMENTATION' }, { preferred_model: 'claude-haiku-4-5-20251001' }) + expect(cfg.model).toBe('claude-haiku-4-5-20251001') + }) + + it('job.requested_model overrult product.preferred_model', () => { + const cfg = resolveJobConfig( + { kind: 'TASK_IMPLEMENTATION', requested_model: 'claude-opus-4-7' }, + { preferred_model: 'claude-haiku-4-5-20251001' }, + ) + expect(cfg.model).toBe('claude-opus-4-7') + }) + + it('task.requires_opus overrult product.preferred_model', () => { + const cfg = resolveJobConfig( + { kind: 'TASK_IMPLEMENTATION' }, + { preferred_model: 'claude-sonnet-4-6' }, + { requires_opus: true }, + ) + expect(cfg.model).toBe('claude-opus-4-7') + }) + + it('task.requires_opus overrult ook job.requested_model = haiku', () => { + const cfg = resolveJobConfig( + { kind: 'TASK_IMPLEMENTATION', requested_model: 'claude-haiku-4-5-20251001' }, + {}, + { requires_opus: true }, + ) + expect(cfg.model).toBe('claude-opus-4-7') + }) + + it('job.requested_thinking_budget overrult kind-default', () => { + const cfg = resolveJobConfig({ kind: 'PLAN_CHAT', requested_thinking_budget: 1024 }, {}) + expect(cfg.thinking_budget).toBe(1024) + }) + + it('product.thinking_budget_default overrult kind-default', () => { + const cfg = resolveJobConfig({ kind: 'IDEA_GRILL' }, { thinking_budget_default: 0 }) + expect(cfg.thinking_budget).toBe(0) + }) + + it('product.preferred_permission_mode = acceptEdits overrult bypassPermissions voor TASK_IMPLEMENTATION', () => { + const cfg = resolveJobConfig( + { kind: 'TASK_IMPLEMENTATION' }, + { preferred_permission_mode: 'acceptEdits' }, + ) + expect(cfg.permission_mode).toBe('acceptEdits') + }) + + it('max_turns en allowed_tools blijven kind-default ook met product- en job-overrides (geen V1-cascade)', () => { + const cfg = resolveJobConfig( + { kind: 'IDEA_GRILL', requested_model: 'claude-haiku-4-5-20251001' }, + { preferred_model: 'claude-sonnet-4-6' }, + ) + expect(cfg.max_turns).toBe(15) + expect(cfg.allowed_tools).toEqual(['Read', 'Grep', 'Glob', 'WebSearch', 'AskUserQuestion']) + }) +}) diff --git a/src/lib/job-config.ts b/src/lib/job-config.ts new file mode 100644 index 0000000..c615de1 --- /dev/null +++ b/src/lib/job-config.ts @@ -0,0 +1,120 @@ +// PBI-67: model + mode-selectie per ClaudeJob-kind. +// +// Override-cascade (eerste match wint): +// 1. task.requires_opus === true → forceer Opus +// 2. job.requested_* (snapshot bij enqueue) +// 3. product.preferred_* +// 4. KIND_DEFAULTS hieronder + +export type ClaudeModel = + | 'claude-opus-4-7' + | 'claude-sonnet-4-6' + | 'claude-haiku-4-5-20251001' + +export type PermissionMode = 'plan' | 'default' | 'acceptEdits' | 'bypassPermissions' + +export type JobConfig = { + model: ClaudeModel + thinking_budget: number // 0 = uit + permission_mode: PermissionMode + max_turns: number | null // null = onbegrensd + allowed_tools: string[] | null // null = alle +} + +export type JobInput = { + kind: string + requested_model?: string | null + requested_thinking_budget?: number | null + requested_permission_mode?: string | null +} + +export type ProductInput = { + preferred_model?: string | null + thinking_budget_default?: number | null + preferred_permission_mode?: string | null +} + +export type TaskInput = { + requires_opus?: boolean | null +} + +const KIND_DEFAULTS: Record = { + IDEA_GRILL: { + model: 'claude-sonnet-4-6', + thinking_budget: 12000, + permission_mode: 'plan', + max_turns: 15, + allowed_tools: ['Read', 'Grep', 'Glob', 'WebSearch', 'AskUserQuestion'], + }, + IDEA_MAKE_PLAN: { + model: 'claude-opus-4-7', + thinking_budget: 24000, + permission_mode: 'plan', + max_turns: 20, + allowed_tools: ['Read', 'Grep', 'Glob', 'WebSearch', 'AskUserQuestion', 'Write'], + }, + PLAN_CHAT: { + model: 'claude-sonnet-4-6', + thinking_budget: 6000, + permission_mode: 'plan', + max_turns: 5, + allowed_tools: ['Read', 'Grep', 'AskUserQuestion'], + }, + TASK_IMPLEMENTATION: { + model: 'claude-sonnet-4-6', + thinking_budget: 6000, + permission_mode: 'bypassPermissions', + max_turns: 50, + allowed_tools: null, + }, + SPRINT_IMPLEMENTATION: { + model: 'claude-sonnet-4-6', + thinking_budget: 6000, + permission_mode: 'bypassPermissions', + max_turns: null, + allowed_tools: null, + }, +} + +const FALLBACK: JobConfig = { + model: 'claude-sonnet-4-6', + thinking_budget: 6000, + permission_mode: 'default', + max_turns: 50, + allowed_tools: null, +} + +export function getKindDefault(kind: string): JobConfig { + return KIND_DEFAULTS[kind] ?? FALLBACK +} + +// max_turns en allowed_tools blijven kind-default (geen product/task override +// in V1 — als de behoefte ontstaat, voeg analoge velden toe aan Product/Task). +export function resolveJobConfig( + job: JobInput, + product: ProductInput, + task?: TaskInput, +): JobConfig { + const base = getKindDefault(job.kind) + + const model = ( + task?.requires_opus + ? 'claude-opus-4-7' + : job.requested_model ?? product.preferred_model ?? base.model + ) as ClaudeModel + + const thinking_budget = + job.requested_thinking_budget ?? product.thinking_budget_default ?? base.thinking_budget + + const permission_mode = (job.requested_permission_mode ?? + product.preferred_permission_mode ?? + base.permission_mode) as PermissionMode + + return { + model, + thinking_budget, + permission_mode, + max_turns: base.max_turns, + allowed_tools: base.allowed_tools, + } +} From e2963d58fb70414a6e3b997086c865d0648fc5ba Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Fri, 8 May 2026 11:11:29 +0200 Subject: [PATCH 13/27] feat(PBI-67/ST-1299/T-788): wait_for_job retourneert config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roept resolveJobConfig aan na het claimen van een job en voegt het resultaat toe als `config: JobConfig` aan de response payload. Werkt voor alle 3 return-paden (IDEA_*, SPRINT_IMPLEMENTATION, default TASK_IMPLEMENTATION). Schema-velden lokaal toegevoegd ter ondersteuning van het Prisma-include (preferred_*, requires_opus, requested_*, actual_thinking_tokens). De sync-schema.sh-flow refresht ze later vanuit het scrum4me-submodule zodra PBI-67/ST-1297 in main is. Pure additief — oude clients negeren `config` en blijven werken op Claude Code defaults uit ~/.claude/settings.json. 301 tests slagen onveranderd. Co-Authored-By: Claude Opus 4.7 (1M context) --- prisma/schema.prisma | 8 ++++++++ src/tools/wait-for-job.ts | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9766b6f..0c04619 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -200,6 +200,9 @@ model Product { definition_of_done String auto_pr Boolean @default(false) pr_strategy PrStrategy @default(SPRINT) + preferred_model String? + thinking_budget_default Int? + preferred_permission_mode String? archived Boolean @default(false) created_at DateTime @default(now()) updated_at DateTime @updatedAt @@ -353,6 +356,7 @@ model Task { status TaskStatus @default(TO_DO) verify_only Boolean @default(false) verify_required VerifyRequired @default(ALIGNED_OR_PARTIAL) + requires_opus Boolean @default(false) // Override product.repo_url for branch/worktree/push purposes. Set when // a task targets a different repo than its parent product (e.g. an // MCP-server task tracked under the main product's PBI). Falls back to @@ -398,6 +402,10 @@ model ClaudeJob { output_tokens Int? cache_read_tokens Int? cache_write_tokens Int? + requested_model String? + requested_thinking_budget Int? + requested_permission_mode String? + actual_thinking_tokens Int? plan_snapshot String? base_sha String? head_sha String? diff --git a/src/tools/wait-for-job.ts b/src/tools/wait-for-job.ts index 1323e50..99a8090 100644 --- a/src/tools/wait-for-job.ts +++ b/src/tools/wait-for-job.ts @@ -18,6 +18,7 @@ import { createWorktreeForJob } from '../git/worktree.js' import { getWorktreeRoot } from '../git/worktree-paths.js' import { setupProductWorktrees, releaseLocksOnTerminal } from '../git/job-locks.js' import { pushBranchForJob } from '../git/push.js' +import { resolveJobConfig } from '../lib/job-config.js' /** Parse `https://github.com//(.git)?` → ``. */ export function repoNameFromUrl(repoUrl: string | null | undefined): string | null { @@ -467,11 +468,38 @@ async function getFullJobContext(jobId: string) { }, }, }, - product: { select: { id: true, name: true, repo_url: true, definition_of_done: true } }, + product: { + select: { + id: true, + name: true, + repo_url: true, + definition_of_done: true, + preferred_model: true, + thinking_budget_default: true, + preferred_permission_mode: true, + }, + }, }, }) if (!job) return null + // PBI-67: model + mode-selectie. Resolved op claim-moment; override-cascade + // task.requires_opus → job.requested_* → product.preferred_* → kind-default. + const config = resolveJobConfig( + { + kind: job.kind, + requested_model: job.requested_model, + requested_thinking_budget: job.requested_thinking_budget, + requested_permission_mode: job.requested_permission_mode, + }, + { + preferred_model: job.product.preferred_model, + thinking_budget_default: job.product.thinking_budget_default, + preferred_permission_mode: job.product.preferred_permission_mode, + }, + job.task ? { requires_opus: job.task.requires_opus } : undefined, + ) + // M12: branch on kind. Idea-jobs hebben geen task/story/pbi/sprint; ze // hebben in plaats daarvan idea + embedded prompt_text. if (job.kind === 'IDEA_GRILL' || job.kind === 'IDEA_MAKE_PLAN') { @@ -515,6 +543,7 @@ async function getFullJobContext(jobId: string) { job_id: job.id, kind: job.kind, status: 'claimed', + config, idea: { id: idea.id, code: idea.code, @@ -659,6 +688,7 @@ async function getFullJobContext(jobId: string) { job_id: job.id, kind: job.kind, status: 'claimed', + config, sprint: { id: sprintRun.sprint.id, sprint_goal: sprintRun.sprint.sprint_goal, @@ -724,6 +754,7 @@ async function getFullJobContext(jobId: string) { job_id: job.id, kind: job.kind, status: 'claimed', + config, task: { id: task.id, title: task.title, From 1c0f41687b8d22a25ec0db74776c2c2f8fc3e865 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Fri, 8 May 2026 11:12:31 +0200 Subject: [PATCH 14/27] feat(PBI-67/ST-1300/T-791): persist actual_thinking_tokens in update_job_status Workers kunnen voortaan het werkelijk verbruikte thinking-budget meegeven via `actual_thinking_tokens`. Identiek aan de bestaande input/output/cache_*-velden: optioneel + conditional update. Backwards-compatible: oude workers zonder deze veld blijven werken. 57 update-job-status tests groen. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tools/update-job-status.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/tools/update-job-status.ts b/src/tools/update-job-status.ts index 8fcb83e..5a75a7d 100644 --- a/src/tools/update-job-status.ts +++ b/src/tools/update-job-status.ts @@ -54,6 +54,7 @@ const inputSchema = z.object({ output_tokens: z.number().int().nonnegative().optional(), cache_read_tokens: z.number().int().nonnegative().optional(), cache_write_tokens: z.number().int().nonnegative().optional(), + actual_thinking_tokens: z.number().int().nonnegative().optional(), }) export async function cleanupWorktreeForTerminalStatus( @@ -539,6 +540,7 @@ export function registerUpdateJobStatusTool(server: McpServer) { output_tokens, cache_read_tokens, cache_write_tokens, + actual_thinking_tokens, }) => withToolErrors(async () => { const auth = await requireWriteAccess() @@ -707,6 +709,7 @@ export function registerUpdateJobStatusTool(server: McpServer) { ...(output_tokens !== undefined ? { output_tokens } : {}), ...(cache_read_tokens !== undefined ? { cache_read_tokens } : {}), ...(cache_write_tokens !== undefined ? { cache_write_tokens } : {}), + ...(actual_thinking_tokens !== undefined ? { actual_thinking_tokens } : {}), }, select: { id: true, From 96f5b0dd03b9a3e109221e5e86c07a564e0d40cc Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Fri, 8 May 2026 17:15:21 +0200 Subject: [PATCH 15/27] feat(PBI-4/ST-004): publieke API + KIND_DEFAULTS + per-kind prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Voorbereidende wijzigingen voor de queue-loop-refactor (zie docs/plans/queue-loop-extraction.md in Scrum4Me-repo). Maakt scrum4me-mcp geschikt als gedeelde library voor de nieuwe scrum4me-docker runner. - T-13: export getFullJobContext uit src/tools/wait-for-job.ts - T-14: mapBudgetToEffort(budget) → --effort {medium,high,xhigh,max} mapping voor Claude CLI 2.1.x (heeft geen --thinking-budget). Comment in header documenteert dat max_turns audit-only is en de CLI-flag-mapping. - T-15: KIND_DEFAULTS.allowed_tools van null → expliciete lijsten zonder wait_for_job/check_queue_empty/get_idea_context. Vangrail tegen recursieve claims. SPRINT_IMPLEMENTATION mist bewust job_heartbeat (runner doet lease-renewal). - T-16: src/lib/idea-prompts.ts → src/lib/kind-prompts.ts. Nieuwe export getKindPromptText voor alle 5 kinds. Back-compat re-export getIdeaPromptText behouden zodat wait-for-job.ts:508 ongewijzigd werkt. - T-17: nieuwe prompts src/prompts/task/implementation.md, sprint/implementation.md, plan-chat/chat.md. Idea-prompts (M12) ongewijzigd. Tests: 334 passed (38 files). 27 nieuwe asserts: mapBudgetToEffort grenswaarden (14), KIND_DEFAULTS.allowed_tools structurele checks (6), kind-prompts loading + verboden-tool-mentions (13). Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/job-config.test.ts | 75 +++++++++++++++++++++++++-- __tests__/kind-prompts.test.ts | 48 +++++++++++++++++ src/lib/idea-prompts.ts | 32 ------------ src/lib/job-config.ts | 74 ++++++++++++++++++++++++-- src/lib/kind-prompts.ts | 48 +++++++++++++++++ src/prompts/plan-chat/chat.md | 16 ++++++ src/prompts/sprint/implementation.md | 77 ++++++++++++++++++++++++++++ src/prompts/task/implementation.md | 58 +++++++++++++++++++++ src/tools/wait-for-job.ts | 4 +- 9 files changed, 391 insertions(+), 41 deletions(-) create mode 100644 __tests__/kind-prompts.test.ts delete mode 100644 src/lib/idea-prompts.ts create mode 100644 src/lib/kind-prompts.ts create mode 100644 src/prompts/plan-chat/chat.md create mode 100644 src/prompts/sprint/implementation.md create mode 100644 src/prompts/task/implementation.md diff --git a/__tests__/job-config.test.ts b/__tests__/job-config.test.ts index 3a7af58..bef0de1 100644 --- a/__tests__/job-config.test.ts +++ b/__tests__/job-config.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { getKindDefault, resolveJobConfig } from '../src/lib/job-config.js' +import { getKindDefault, resolveJobConfig, mapBudgetToEffort } from '../src/lib/job-config.js' const KIND_EXPECTED = { IDEA_GRILL: { model: 'claude-sonnet-4-6', thinking_budget: 12000, permission_mode: 'plan', max_turns: 15 }, @@ -86,12 +86,81 @@ describe('resolveJobConfig — cascade', () => { expect(cfg.permission_mode).toBe('acceptEdits') }) - it('max_turns en allowed_tools blijven kind-default ook met product- en job-overrides (geen V1-cascade)', () => { + it('max_turns blijft kind-default ook met product- en job-overrides (geen V1-cascade)', () => { const cfg = resolveJobConfig( { kind: 'IDEA_GRILL', requested_model: 'claude-haiku-4-5-20251001' }, { preferred_model: 'claude-sonnet-4-6' }, ) expect(cfg.max_turns).toBe(15) - expect(cfg.allowed_tools).toEqual(['Read', 'Grep', 'Glob', 'WebSearch', 'AskUserQuestion']) + }) +}) + +describe('KIND_DEFAULTS.allowed_tools', () => { + it('TASK_IMPLEMENTATION bevat geen claim-tools', () => { + const cfg = getKindDefault('TASK_IMPLEMENTATION') + expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__wait_for_job') + expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__check_queue_empty') + expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__get_idea_context') + }) + + it('TASK_IMPLEMENTATION bevat de essentiële task-tools', () => { + const cfg = getKindDefault('TASK_IMPLEMENTATION') + expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_task_status') + expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_job_status') + expect(cfg.allowed_tools).toContain('mcp__scrum4me__verify_task_against_plan') + expect(cfg.allowed_tools).toContain('Bash') + expect(cfg.allowed_tools).toContain('Edit') + expect(cfg.allowed_tools).toContain('Write') + }) + + it('SPRINT_IMPLEMENTATION bevat sprint-specifieke tools maar GEEN job_heartbeat (runner doet die)', () => { + const cfg = getKindDefault('SPRINT_IMPLEMENTATION') + expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_task_execution') + expect(cfg.allowed_tools).toContain('mcp__scrum4me__verify_sprint_task') + expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__job_heartbeat') + }) + + it('IDEA_GRILL bevat update_idea_grill_md en geen wait_for_job', () => { + const cfg = getKindDefault('IDEA_GRILL') + expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_idea_grill_md') + expect(cfg.allowed_tools).toContain('mcp__scrum4me__log_idea_decision') + expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_job_status') + expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__wait_for_job') + }) + + it('IDEA_MAKE_PLAN bevat update_idea_plan_md en geen wait_for_job', () => { + const cfg = getKindDefault('IDEA_MAKE_PLAN') + expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_idea_plan_md') + expect(cfg.allowed_tools).toContain('mcp__scrum4me__log_idea_decision') + expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__wait_for_job') + }) + + it('alle kinds hebben non-null allowed_tools', () => { + for (const kind of ['IDEA_GRILL', 'IDEA_MAKE_PLAN', 'PLAN_CHAT', 'TASK_IMPLEMENTATION', 'SPRINT_IMPLEMENTATION']) { + const cfg = getKindDefault(kind) + expect(cfg.allowed_tools).not.toBeNull() + expect(Array.isArray(cfg.allowed_tools)).toBe(true) + } + }) +}) + +describe('mapBudgetToEffort', () => { + it.each([ + [0, null], + [-1, null], + [1, 'medium'], + [3000, 'medium'], + [6000, 'medium'], + [6001, 'high'], + [9000, 'high'], + [12000, 'high'], + [12001, 'xhigh'], + [18000, 'xhigh'], + [24000, 'xhigh'], + [24001, 'max'], + [50000, 'max'], + [100000, 'max'], + ])('budget %i → %s', (budget, expected) => { + expect(mapBudgetToEffort(budget)).toBe(expected) }) }) diff --git a/__tests__/kind-prompts.test.ts b/__tests__/kind-prompts.test.ts new file mode 100644 index 0000000..6dbb9d2 --- /dev/null +++ b/__tests__/kind-prompts.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest' +import type { ClaudeJobKind } from '@prisma/client' +import { getKindPromptText, getIdeaPromptText } from '../src/lib/kind-prompts.js' + +const KINDS: ClaudeJobKind[] = [ + 'IDEA_GRILL', + 'IDEA_MAKE_PLAN', + 'TASK_IMPLEMENTATION', + 'SPRINT_IMPLEMENTATION', + 'PLAN_CHAT', +] + +describe('getKindPromptText', () => { + it.each(KINDS)('returnt non-empty content voor %s', (kind) => { + const text = getKindPromptText(kind) + expect(text.length).toBeGreaterThan(0) + }) + + it('TASK_IMPLEMENTATION-prompt verbiedt wait_for_job', () => { + const text = getKindPromptText('TASK_IMPLEMENTATION') + expect(text).toMatch(/GEEN.*wait_for_job/) + }) + + it('SPRINT_IMPLEMENTATION-prompt verbiedt job_heartbeat', () => { + const text = getKindPromptText('SPRINT_IMPLEMENTATION') + expect(text).toMatch(/GEEN.*job_heartbeat/) + }) + + it.each(['TASK_IMPLEMENTATION', 'SPRINT_IMPLEMENTATION', 'PLAN_CHAT'] as const)( + '%s-prompt noemt $PAYLOAD_PATH als variabele', + (kind) => { + const text = getKindPromptText(kind) + expect(text).toContain('$PAYLOAD_PATH') + }, + ) +}) + +describe('getIdeaPromptText (back-compat)', () => { + it('returnt content voor IDEA_GRILL', () => { + expect(getIdeaPromptText('IDEA_GRILL').length).toBeGreaterThan(0) + }) + it('returnt content voor IDEA_MAKE_PLAN', () => { + expect(getIdeaPromptText('IDEA_MAKE_PLAN').length).toBeGreaterThan(0) + }) + it('returnt empty string voor non-idea kind', () => { + expect(getIdeaPromptText('TASK_IMPLEMENTATION')).toBe('') + }) +}) diff --git a/src/lib/idea-prompts.ts b/src/lib/idea-prompts.ts deleted file mode 100644 index bcc8873..0000000 --- a/src/lib/idea-prompts.ts +++ /dev/null @@ -1,32 +0,0 @@ -// Loader voor embedded idea-prompts (M12). -// De .md-bestanden in src/prompts/idea/ zijn een kopie van -// scrum4me/lib/idea-prompts/* — bewust dupliceren voor reproduceerbaarheid -// op elke worker (geen externe anthropic-skills-plugin-dependency). - -import { readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' - -import type { ClaudeJobKind } from '@prisma/client' - -let cached: { grill?: string; makePlan?: string } = {} - -function loadPrompt(file: 'grill.md' | 'make-plan.md'): string { - const here = dirname(fileURLToPath(import.meta.url)) - // src/lib/idea-prompts.ts → src/lib → src → src/prompts/idea/{file} - const path = join(here, '..', 'prompts', 'idea', file) - return readFileSync(path, 'utf8') -} - -export function getIdeaPromptText(kind: ClaudeJobKind): string { - if (kind === 'IDEA_GRILL') { - if (!cached.grill) cached.grill = loadPrompt('grill.md') - return cached.grill - } - if (kind === 'IDEA_MAKE_PLAN') { - if (!cached.makePlan) cached.makePlan = loadPrompt('make-plan.md') - return cached.makePlan - } - // TASK_IMPLEMENTATION en future kinds: geen embedded prompt nodig. - return '' -} diff --git a/src/lib/job-config.ts b/src/lib/job-config.ts index c615de1..1c77915 100644 --- a/src/lib/job-config.ts +++ b/src/lib/job-config.ts @@ -1,10 +1,21 @@ // PBI-67: model + mode-selectie per ClaudeJob-kind. // +// Sync met Scrum4Me/lib/job-config.ts — als je hier een veld aanpast, +// doe hetzelfde aan de webapp-kant. Bewust duplicate (geen gedeeld +// package) om de MCP-server eigenstandig te houden. +// // Override-cascade (eerste match wint): // 1. task.requires_opus === true → forceer Opus // 2. job.requested_* (snapshot bij enqueue) // 3. product.preferred_* // 4. KIND_DEFAULTS hieronder +// +// CLI-flag-mapping (Claude CLI 2.1.x): +// - thinking_budget (number) → mapBudgetToEffort() → --effort {low,medium,high,xhigh,max} +// (de CLI heeft geen --thinking-budget flag — alleen --effort) +// - max_turns blijft audit-only: de CLI heeft géén --max-turns flag. +// De waarde wordt gesnapshot voor cost-attribution maar niet doorgegeven. +// - allowed_tools → --allowedTools (komma-gescheiden lijst) export type ClaudeModel = | 'claude-opus-4-7' @@ -38,20 +49,52 @@ export type TaskInput = { requires_opus?: boolean | null } +// Tool-allowlists per kind. Bewust géén `wait_for_job`, `check_queue_empty` +// of `get_idea_context` — de runner (scrum4me-docker/bin/run-one-job.ts) +// claimt voor Claude. Vangrail tegen recursieve claims binnen één invocation. +const TASK_TOOLS = [ + 'Read', 'Edit', 'Write', 'Bash', 'Grep', 'Glob', + 'mcp__scrum4me__get_claude_context', + 'mcp__scrum4me__update_task_status', + 'mcp__scrum4me__update_task_plan', + 'mcp__scrum4me__log_implementation', + 'mcp__scrum4me__log_test_result', + 'mcp__scrum4me__log_commit', + 'mcp__scrum4me__verify_task_against_plan', + 'mcp__scrum4me__update_job_status', + 'mcp__scrum4me__ask_user_question', + 'mcp__scrum4me__get_question_answer', + 'mcp__scrum4me__list_open_questions', + 'mcp__scrum4me__cancel_question', + 'mcp__scrum4me__worker_heartbeat', +] + const KIND_DEFAULTS: Record = { IDEA_GRILL: { model: 'claude-sonnet-4-6', thinking_budget: 12000, permission_mode: 'plan', max_turns: 15, - allowed_tools: ['Read', 'Grep', 'Glob', 'WebSearch', 'AskUserQuestion'], + allowed_tools: [ + 'Read', 'Grep', 'Glob', 'WebSearch', 'AskUserQuestion', + 'mcp__scrum4me__update_idea_grill_md', + 'mcp__scrum4me__log_idea_decision', + 'mcp__scrum4me__update_job_status', + 'mcp__scrum4me__ask_user_question', + 'mcp__scrum4me__get_question_answer', + ], }, IDEA_MAKE_PLAN: { model: 'claude-opus-4-7', thinking_budget: 24000, permission_mode: 'plan', max_turns: 20, - allowed_tools: ['Read', 'Grep', 'Glob', 'WebSearch', 'AskUserQuestion', 'Write'], + allowed_tools: [ + 'Read', 'Grep', 'Glob', 'WebSearch', 'AskUserQuestion', 'Write', + 'mcp__scrum4me__update_idea_plan_md', + 'mcp__scrum4me__log_idea_decision', + 'mcp__scrum4me__update_job_status', + ], }, PLAN_CHAT: { model: 'claude-sonnet-4-6', @@ -65,14 +108,20 @@ const KIND_DEFAULTS: Record = { thinking_budget: 6000, permission_mode: 'bypassPermissions', max_turns: 50, - allowed_tools: null, + allowed_tools: TASK_TOOLS, }, SPRINT_IMPLEMENTATION: { model: 'claude-sonnet-4-6', thinking_budget: 6000, permission_mode: 'bypassPermissions', max_turns: null, - allowed_tools: null, + // Geen `mcp__scrum4me__job_heartbeat` — de runner verlengt de lease + // automatisch via setInterval (zie scrum4me-docker/bin/run-one-job.ts). + allowed_tools: [ + ...TASK_TOOLS, + 'mcp__scrum4me__update_task_execution', + 'mcp__scrum4me__verify_sprint_task', + ], }, } @@ -118,3 +167,20 @@ export function resolveJobConfig( allowed_tools: base.allowed_tools, } } + +// Map numeriek thinking_budget naar de Claude CLI 2.1.x --effort flag. +// Returns null als de flag niet meegegeven moet worden (budget = 0). +// +// Mapping (sync met Scrum4Me/lib/job-config.ts): +// 0 → null (geen --effort flag) +// 1..6000 → "medium" +// 6001..12000 → "high" +// 12001..24000→ "xhigh" +// >24000 → "max" +export function mapBudgetToEffort(budget: number): string | null { + if (budget <= 0) return null + if (budget <= 6000) return 'medium' + if (budget <= 12000) return 'high' + if (budget <= 24000) return 'xhigh' + return 'max' +} diff --git a/src/lib/kind-prompts.ts b/src/lib/kind-prompts.ts new file mode 100644 index 0000000..f7e03c1 --- /dev/null +++ b/src/lib/kind-prompts.ts @@ -0,0 +1,48 @@ +// Loader voor embedded prompts per ClaudeJob-kind. +// +// De .md-bestanden in src/prompts// worden bewust meegebakken zodat +// elke runner ze kan inlezen zonder externe plugin-dependency. De runner +// (scrum4me-docker/bin/run-one-job.ts) leest de juiste prompt via +// getKindPromptText() en geeft die door als `claude -p`-prompt. +// +// Variabele-vervanging gebeurt door de runner zelf (bv. $PAYLOAD_PATH). + +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import type { ClaudeJobKind } from '@prisma/client' + +const cache: Partial> = {} + +function loadPrompt(rel: string): string { + const here = dirname(fileURLToPath(import.meta.url)) + // src/lib/kind-prompts.ts → src/lib → src → src/prompts/ + const path = join(here, '..', 'prompts', rel) + return readFileSync(path, 'utf8') +} + +const KIND_TO_PROMPT_PATH: Partial> = { + IDEA_GRILL: 'idea/grill.md', + IDEA_MAKE_PLAN: 'idea/make-plan.md', + TASK_IMPLEMENTATION: 'task/implementation.md', + SPRINT_IMPLEMENTATION: 'sprint/implementation.md', + PLAN_CHAT: 'plan-chat/chat.md', +} + +export function getKindPromptText(kind: ClaudeJobKind): string { + if (cache[kind]) return cache[kind]! + const rel = KIND_TO_PROMPT_PATH[kind] + if (!rel) return '' + const text = loadPrompt(rel) + cache[kind] = text + return text +} + +// Back-compat re-export. wait-for-job.ts roept getIdeaPromptText aan voor +// de twee idea-kinds; behouden zodat we de bestaande call-site niet hoeven +// te wijzigen tot een aparte cleanup-pass. +export function getIdeaPromptText(kind: ClaudeJobKind): string { + if (kind !== 'IDEA_GRILL' && kind !== 'IDEA_MAKE_PLAN') return '' + return getKindPromptText(kind) +} diff --git a/src/prompts/plan-chat/chat.md b/src/prompts/plan-chat/chat.md new file mode 100644 index 0000000..224d51e --- /dev/null +++ b/src/prompts/plan-chat/chat.md @@ -0,0 +1,16 @@ +# PLAN_CHAT-prompt (placeholder) + +> Deze prompt is een placeholder. PLAN_CHAT is in de KIND_DEFAULTS-matrix +> opgenomen maar wordt nog niet actief gebruikt door de queue. Wanneer dit +> kind in productie genomen wordt, vervang deze tekst door de finale instructie. + +--- + +Je bent gestart voor een `PLAN_CHAT`-job. De payload staat in: + +``` +$PAYLOAD_PATH +``` + +Lees de payload en doe wat erin staat. Sluit af met +`mcp__scrum4me__update_job_status({ job_id, status: 'done' })`. diff --git a/src/prompts/sprint/implementation.md b/src/prompts/sprint/implementation.md new file mode 100644 index 0000000..9089f8a --- /dev/null +++ b/src/prompts/sprint/implementation.md @@ -0,0 +1,77 @@ +# SPRINT_IMPLEMENTATION-prompt + +> Deze prompt wordt door `scrum4me-docker/bin/run-one-job.ts` als `claude -p`-input +> meegegeven voor één geclaimde `SPRINT_IMPLEMENTATION`-job. Eén job = de hele +> sprint-run sequentieel afhandelen. + +--- + +Je bent gestart voor één geclaimde `SPRINT_IMPLEMENTATION`-job. De payload bevat +een **frozen scope-snapshot** met alle te verwerken taken: + +``` +$PAYLOAD_PATH +``` + +Lees die payload eerst. Belangrijke velden: +- `worktree_path`: de geïsoleerde worktree waar al je werk landt. +- `branch_name`: de feature-branch (bv. `feat/sprint-`); bij PR-strategy + SPRINT zit alle werk in één branch. +- `task_executions[]`: ordered lijst van `SprintTaskExecution`-rijen. Verwerk in + `order`-volgorde. Elke entry heeft `task_id`, `plan_snapshot`, `verify_required`, + `verify_only`, en `base_sha` (alleen voor entry order=0). +- `pbis[]`, `stories[]`: context voor begrip; geen wijzigingen daarop. +- `sprint_run.id`: nodig voor `update_task_status` cascade-prop. Geef altijd + `sprint_run_id` mee aan `update_task_status`. + +## Hard regels + +- **GEEN** `mcp__scrum4me__wait_for_job` aanroepen. De runner heeft geclaimd. +- **GEEN** `mcp__scrum4me__job_heartbeat` aanroepen. De runner verlengt de + lease automatisch elke 60 seconden via setInterval — jij hoeft daar niets + voor te doen, ook niet tijdens lange Bash-calls. +- Werk uitsluitend in `worktree_path` op `branch_name`. Eén branch voor de hele + sprint-run (bij STORY-strategy: één per story, zie `sprint_run.pr_strategy`). +- Verwerk taken in de exacte `order`-volgorde uit `task_executions[]`. + +## Workflow per task_execution + +Voor elke entry in `task_executions[]` (in order-volgorde): + +1. **Start**: `update_task_execution({ execution_id, status: 'RUNNING' })` en + `update_task_status({ task_id, status: 'in_progress', sprint_run_id })`. +2. **Lees** het `plan_snapshot` uit de execution + de bredere context uit + `task`/`story`/`pbi` in de payload. +3. **Implementeer** de taak in `worktree_path`. Commit per logische laag met + `git add -A && git commit`. +4. **Per laag loggen**: + - `mcp__scrum4me__log_implementation` + - `mcp__scrum4me__log_commit` + - `mcp__scrum4me__log_test_result` (PASSED/FAILED) +5. **Verify-gate** (als `verify_required === true`): + `mcp__scrum4me__verify_sprint_task({ execution_id })`. Bij DIVERGENT: stop de + sprint en sluit af met `update_job_status('failed')`. +6. **Afronden taak**: + - Bij ALIGNED/PARTIAL: `update_task_status({ task_id, status: 'done', sprint_run_id })` + en `update_task_execution({ execution_id, status: 'DONE' })`. + - Bij EMPTY (no-op): `update_task_execution({ execution_id, status: 'SKIPPED' })` + en `update_task_status({ task_id, status: 'done', sprint_run_id })`. + +## Sprint afronden + +Na de laatste `task_execution`: + +- **Verify-gate run**: optioneel een algemene `npm run verify` op de hele worktree. +- **Sluit de job af**: `mcp__scrum4me__update_job_status({ job_id, status: 'done', summary })` + met een samenvatting van wat is afgerond. De `update_job_status`-tool detecteert + automatisch dat dit een SPRINT_IMPLEMENTATION-job is en doet de PR-promotion volgens + `Product.auto_pr` en `sprint_run.pr_strategy`. + +Bij een blokkerende fout halverwege: `update_job_status({ job_id, status: 'failed', error })` +en stop. De runner zorgt voor lease-cleanup. + +## Vragen aan de gebruiker + +Voor blokkerende keuzes: `mcp__scrum4me__ask_user_question` + wacht op antwoord +met `mcp__scrum4me__get_question_answer`. Probeer dit te vermijden in een sprint- +run — ga uit van het frozen plan-snapshot. diff --git a/src/prompts/task/implementation.md b/src/prompts/task/implementation.md new file mode 100644 index 0000000..fa408ee --- /dev/null +++ b/src/prompts/task/implementation.md @@ -0,0 +1,58 @@ +# TASK_IMPLEMENTATION-prompt + +> Deze prompt wordt door `scrum4me-docker/bin/run-one-job.ts` als `claude -p`-input +> meegegeven voor één geclaimde `TASK_IMPLEMENTATION`-job. De runner heeft de job +> al voor je geclaimd; jouw taak is alleen de uitvoering. + +--- + +Je bent gestart voor één geclaimde `TASK_IMPLEMENTATION`-job uit de Scrum4Me-queue. +De volledige job-payload (inclusief task, story, pbi, sprint, product, config en +worktree_path) staat in: + +``` +$PAYLOAD_PATH +``` + +Lees die payload eerst met `Read $PAYLOAD_PATH`. Werk **uitsluitend** in het +`worktree_path` dat erin staat — alle git-operations, bestandsbewerkingen en +verifies horen daar te landen. + +## Hard regels + +- **GEEN** `mcp__scrum4me__wait_for_job` aanroepen. De runner heeft al voor je + geclaimd. Eén Claude-invocation = één job. +- **GEEN** `mcp__scrum4me__check_queue_empty`. Je sluit af na deze ene job. +- Werk in het toegewezen worktree-pad; geen edits in andere directories. +- Volg `task.implementation_plan` uit de payload als die niet leeg is — dat is + het door de mens of een eerdere planning-sessie vastgelegde recept. + +## Workflow + +1. **Status op in_progress**: `mcp__scrum4me__update_task_status({ task_id, status: 'in_progress' })`. +2. **Plan lezen**: Lees `task.implementation_plan` uit de payload + relevante + project-docs (`docs/specs/functional.md`, eventueel `docs/patterns/*.md`). +3. **Implementeer** de taak: lees → verander → test → commit per logische laag. + Gebruik `git add -A && git commit` per laag, **geen** `git push`. +4. **Logging per laag**: + - `mcp__scrum4me__log_implementation` met een korte beschrijving van wat je + gewijzigd hebt en waarom. + - `mcp__scrum4me__log_commit` met `commit_hash` en `commit_message` na elke + commit (haal hash uit `git rev-parse HEAD`). + - `mcp__scrum4me__log_test_result` met PASSED/FAILED en uitleg na elke + `npm test` of build-run. +5. **Verify-gate**: roep `mcp__scrum4me__verify_task_against_plan({ task_id })` + aan om de wijzigingen tegen het plan te toetsen. +6. **Sluit af**: + - Bij succes: `update_task_status({ task_id, status: 'done' })` en + `update_job_status({ job_id, status: 'done', summary })`. + - Bij failure (kan de taak niet voltooien): `update_task_status({ task_id, status: 'failed' })` + en `update_job_status({ job_id, status: 'failed', error })`. + - Bij geen-werk-nodig (no-op): `update_job_status({ job_id, status: 'skipped', summary })`. + +## Vragen aan de gebruiker + +Als je een blokkerende keuze tegenkomt waarvoor je input nodig hebt, gebruik +`mcp__scrum4me__ask_user_question` en wacht op het antwoord met +`mcp__scrum4me__get_question_answer`. Vraag **niet** voor zaken die je zelf +kunt afleiden uit het plan. diff --git a/src/tools/wait-for-job.ts b/src/tools/wait-for-job.ts index 99a8090..c8af6f4 100644 --- a/src/tools/wait-for-job.ts +++ b/src/tools/wait-for-job.ts @@ -446,7 +446,7 @@ export async function tryClaimJob( return rows.length > 0 ? rows[0].id : null } -async function getFullJobContext(jobId: string) { +export async function getFullJobContext(jobId: string) { const job = await prisma.claudeJob.findUnique({ where: { id: jobId }, include: { @@ -505,7 +505,7 @@ async function getFullJobContext(jobId: string) { if (job.kind === 'IDEA_GRILL' || job.kind === 'IDEA_MAKE_PLAN') { if (!job.idea) return null const { idea } = job - const { getIdeaPromptText } = await import('../lib/idea-prompts.js') + const { getIdeaPromptText } = await import('../lib/kind-prompts.js') // Setup persistent product-worktrees for this idea-job (PBI-9). // Primary product is gated by repo_url via resolveRepoRoot returning null. From e64ece3d41d8e0893929dff74cb60a8c0fbc9b00 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sat, 9 May 2026 11:28:31 +0200 Subject: [PATCH 16/27] fix(KIND_DEFAULTS): permission_mode acceptEdits voor idea-kinds + PLAN_CHAT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptoom: IDEA_GRILL job IDEA-047 werd 3x geclaimd, Claude liep telkens succesvol (exit 0 na 600-900s) maar deed nooit update_job_status('done'). Lease verliep, retry_count >= 2 → status FAILED met "agent did not complete job within 2 attempts". Root cause: KIND_DEFAULTS.permission_mode='plan' voor idea-kinds en PLAN_CHAT. In autonome batch-mode wacht plan-mode op een human "go" na elke planning-fase — er is geen mens in de loop om te approven, dus Claude blijft hangen en sluit netjes maar onvolledig af. Fix: - IDEA_GRILL.permission_mode: plan → acceptEdits - IDEA_MAKE_PLAN.permission_mode: plan → acceptEdits - PLAN_CHAT.permission_mode: plan → acceptEdits De allowed_tools-lijsten doen de echte sandboxing (geen Bash, geen Edit voor IDEA_GRILL/PLAN_CHAT, alleen Write voor IDEA_MAKE_PLAN). De "veiligheid" van plan-mode wordt dus al door tool-allowlists geleverd — acceptEdits is hier puur om Claude door zijn own update_job_status loop te laten lopen zonder approval-wachttijd. Plus: PLAN_CHAT.allowed_tools krijgt nu ook update_job_status (ontbrak, zou het kind ook in acceptEdits-mode niet kunnen afsluiten). Tests: KIND_EXPECTED in __tests__/job-config.test.ts bijgewerkt. 334 tests in 38 files passed. Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/job-config.test.ts | 6 +++--- src/lib/job-config.ts | 16 ++++++++++++---- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/__tests__/job-config.test.ts b/__tests__/job-config.test.ts index bef0de1..80ea72f 100644 --- a/__tests__/job-config.test.ts +++ b/__tests__/job-config.test.ts @@ -2,9 +2,9 @@ import { describe, it, expect } from 'vitest' import { getKindDefault, resolveJobConfig, mapBudgetToEffort } from '../src/lib/job-config.js' const KIND_EXPECTED = { - IDEA_GRILL: { model: 'claude-sonnet-4-6', thinking_budget: 12000, permission_mode: 'plan', max_turns: 15 }, - IDEA_MAKE_PLAN: { model: 'claude-opus-4-7', thinking_budget: 24000, permission_mode: 'plan', max_turns: 20 }, - PLAN_CHAT: { model: 'claude-sonnet-4-6', thinking_budget: 6000, permission_mode: 'plan', max_turns: 5 }, + IDEA_GRILL: { model: 'claude-sonnet-4-6', thinking_budget: 12000, permission_mode: 'acceptEdits', max_turns: 15 }, + IDEA_MAKE_PLAN: { model: 'claude-opus-4-7', thinking_budget: 24000, permission_mode: 'acceptEdits', max_turns: 20 }, + PLAN_CHAT: { model: 'claude-sonnet-4-6', thinking_budget: 6000, permission_mode: 'acceptEdits', max_turns: 5 }, TASK_IMPLEMENTATION: { model: 'claude-sonnet-4-6', thinking_budget: 6000, permission_mode: 'bypassPermissions', max_turns: 50 }, SPRINT_IMPLEMENTATION: { model: 'claude-sonnet-4-6', thinking_budget: 6000, permission_mode: 'bypassPermissions', max_turns: null }, } as const diff --git a/src/lib/job-config.ts b/src/lib/job-config.ts index 1c77915..811e365 100644 --- a/src/lib/job-config.ts +++ b/src/lib/job-config.ts @@ -70,10 +70,15 @@ const TASK_TOOLS = [ ] const KIND_DEFAULTS: Record = { + // Idea-kinds en PLAN_CHAT draaien in `acceptEdits` (niet `plan`): + // `plan`-mode wacht op human-approval na elke planning-fase, wat in een + // autonome runner-context betekent dat Claude geen `update_job_status` + // aanroept en de job na lease-expiry FAILED'd. De `allowed_tools`-lijst + // doet de echte sandboxing (geen Bash, geen Edit, alleen Read/Grep/etc). IDEA_GRILL: { model: 'claude-sonnet-4-6', thinking_budget: 12000, - permission_mode: 'plan', + permission_mode: 'acceptEdits', max_turns: 15, allowed_tools: [ 'Read', 'Grep', 'Glob', 'WebSearch', 'AskUserQuestion', @@ -87,7 +92,7 @@ const KIND_DEFAULTS: Record = { IDEA_MAKE_PLAN: { model: 'claude-opus-4-7', thinking_budget: 24000, - permission_mode: 'plan', + permission_mode: 'acceptEdits', max_turns: 20, allowed_tools: [ 'Read', 'Grep', 'Glob', 'WebSearch', 'AskUserQuestion', 'Write', @@ -99,9 +104,12 @@ const KIND_DEFAULTS: Record = { PLAN_CHAT: { model: 'claude-sonnet-4-6', thinking_budget: 6000, - permission_mode: 'plan', + permission_mode: 'acceptEdits', max_turns: 5, - allowed_tools: ['Read', 'Grep', 'AskUserQuestion'], + allowed_tools: [ + 'Read', 'Grep', 'AskUserQuestion', + 'mcp__scrum4me__update_job_status', + ], }, TASK_IMPLEMENTATION: { model: 'claude-sonnet-4-6', From ae017b86447707907eb6a39f9d952cb759eebb8f Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sat, 9 May 2026 11:55:27 +0200 Subject: [PATCH 17/27] fix(prompts): idea-prompts gebruiken $PAYLOAD_PATH ipv onvervangen placeholders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptoom: IDEA_GRILL en IDEA_MAKE_PLAN jobs hingen 11+ minuten zonder update_job_status aan te roepen. Claude zag in de prompt: - "Je bent een grill-agent voor Scrum4Me-idee {idea_code}" — letterlijke string omdat run-one-job.ts alleen $PAYLOAD_PATH substitueert, geen {idea_*}-vars. - "context (meegegeven in wait_for_job-payload)" — maar Claude krijgt geen wait_for_job-respons, want die tool zit niet meer in allowed_tools voor idea-kinds (de runner claimt al). - Geen instructie om $PAYLOAD_PATH te lezen — de placeholder ontbrak in beide idea-prompts (alleen task/sprint/plan-chat hadden 'm). Resultaat: Claude wist niet wat het te doen had, kon geen idea_id of job_id achterhalen, en draaide tot de natuurlijke session-cap zonder ooit de juiste tools aan te roepen. Fix: - grill.md en make-plan.md: vervang `wait_for_job`-references door `scrum4me-docker/bin/run-one-job.ts` (de daadwerkelijke runner). - Beide prompts beginnen nu met "Lees $PAYLOAD_PATH met de Read-tool" als verplichte eerste actie. Lijst van velden die uit de payload moeten worden bewaard (idea.id, idea.code, job_id, product.id, etc.). - {idea_code} / {idea_title} placeholders verwijderd — alle benodigde velden komen uit de payload, geen runner-side substitution meer nodig. - Update_job_status-stap expliciet als "verplicht, ook bij failure". Tests: kind-prompts.test.ts uitgebreid: - Alle 5 kinds moeten $PAYLOAD_PATH bevatten (was alleen task/sprint/ plan-chat). - IDEA_GRILL en IDEA_MAKE_PLAN mogen geen wait_for_job meer noemen. - IDEA_GRILL en IDEA_MAKE_PLAN mogen geen {idea_*} placeholders meer bevatten. 19 tests in kind-prompts.test.ts passed (was 13). Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/kind-prompts.test.ts | 20 ++++++++++++++-- src/prompts/idea/grill.md | 42 ++++++++++++++++++++-------------- src/prompts/idea/make-plan.md | 39 ++++++++++++++++++++----------- 3 files changed, 69 insertions(+), 32 deletions(-) diff --git a/__tests__/kind-prompts.test.ts b/__tests__/kind-prompts.test.ts index 6dbb9d2..fda08f4 100644 --- a/__tests__/kind-prompts.test.ts +++ b/__tests__/kind-prompts.test.ts @@ -26,13 +26,29 @@ describe('getKindPromptText', () => { expect(text).toMatch(/GEEN.*job_heartbeat/) }) - it.each(['TASK_IMPLEMENTATION', 'SPRINT_IMPLEMENTATION', 'PLAN_CHAT'] as const)( - '%s-prompt noemt $PAYLOAD_PATH als variabele', + it.each(KINDS)( + '%s-prompt noemt $PAYLOAD_PATH als variabele (alle kinds — runner doet substitution)', (kind) => { const text = getKindPromptText(kind) expect(text).toContain('$PAYLOAD_PATH') }, ) + + it.each(['IDEA_GRILL', 'IDEA_MAKE_PLAN'] as const)( + '%s-prompt verwijst niet meer naar wait_for_job (refactor: runner claimt)', + (kind) => { + const text = getKindPromptText(kind) + expect(text).not.toContain('wait_for_job') + }, + ) + + it.each(['IDEA_GRILL', 'IDEA_MAKE_PLAN'] as const)( + '%s-prompt bevat geen onvervangen {idea_*} placeholders', + (kind) => { + const text = getKindPromptText(kind) + expect(text).not.toMatch(/\{idea_code\}|\{idea_title\}/) + }, + ) }) describe('getIdeaPromptText (back-compat)', () => { diff --git a/src/prompts/idea/grill.md b/src/prompts/idea/grill.md index d5af711..13be8d1 100644 --- a/src/prompts/idea/grill.md +++ b/src/prompts/idea/grill.md @@ -1,21 +1,28 @@ # Grill-prompt voor IDEA_GRILL-jobs -> Deze prompt wordt door `wait_for_job` meegestuurd in de payload van een -> `IDEA_GRILL`-job en gevolgd door de Claude-CLI-worker. Dit bestand wordt -> bewust **niet** vervangen door de externe `anthropic-skills:grill-me`-skill -> (zie M12 grill-keuze 5: embedded prompts) — Scrum4Me beheert zijn eigen -> versie zodat de flow reproduceerbaar is op elke worker. +> Deze prompt wordt door `scrum4me-docker/bin/run-one-job.ts` als +> `claude -p`-input meegegeven voor één geclaimde `IDEA_GRILL`-job. Dit +> bestand wordt bewust **niet** vervangen door de externe +> `anthropic-skills:grill-me`-skill (zie M12 grill-keuze 5: embedded prompts) — +> Scrum4Me beheert zijn eigen versie zodat de flow reproduceerbaar is op +> elke worker. --- -Je bent een **grill-agent** voor Scrum4Me-idee `{idea_code}` (titel: -`{idea_title}`). +Je bent een **grill-agent** voor een Scrum4Me-idee. De runner heeft de job +al voor je geclaimd; jouw eerste actie is altijd: -Je context (meegegeven in `wait_for_job`-payload): +``` +Read $PAYLOAD_PATH +``` -- `idea`: het volledige idee-record incl. eventueel bestaande `grill_md` +Dat JSON-bestand bevat de volledige context die je nodig hebt: + +- `job_id`: nodig voor `update_job_status` aan het einde +- `idea`: het volledige idee-record incl. `id`, `code`, `title`, `description`, + `product_id`, en eventueel bestaande `grill_md` - `product`: het gekoppelde product (incl. `repo_url` en `definition_of_done`) -- `repo_url`: lokale repo om te lezen (worker bevindt zich daar al) +- `primary_worktree_path`: lokale repo om te lezen (je `cwd` zit daar al) ## Doel @@ -25,11 +32,11 @@ PBI van kan maken. Eindresultaat is een markdown-document dat je via ## Werkwijze (loop, één vraag per cyclus) -1. Lees de huidige `idea.title`, `idea.description`, en (indien aanwezig) - `idea.grill_md` — bij re-grill bouw je voort op wat er al staat, je gooit - het niet weg. -2. Verken de repo voor context: `README`, `docs/`, `package.json`, en relevante - source-bestanden. Gebruik `Read`/`Grep`/`Glob` zoals normaal. +1. **Lees `$PAYLOAD_PATH`** met de `Read`-tool. Bewaar `idea.id`, `idea.code`, + `idea.title`, `idea.grill_md` (mag null zijn), `product.id`, en `job_id` — + die heb je nodig in alle MCP-tool-calls hieronder. +2. Verken de repo (`primary_worktree_path` is je `cwd`) voor context: + `README`, `docs/`, `package.json`, relevante source. `Read`/`Grep`/`Glob`. 3. Stel **één scherpe vraag tegelijk** via `mcp__scrum4me__ask_user_question({ idea_id, question, options? })`. Wacht op het antwoord (`mcp__scrum4me__get_question_answer` of `wait_seconds`). @@ -39,7 +46,8 @@ PBI van kan maken. Eindresultaat is een markdown-document dat je via 5. Herhaal tot je voldoende hebt voor een PBI (zie stop-conditie). 6. Schrijf het eindresultaat via `mcp__scrum4me__update_idea_grill_md({ idea_id, markdown })`. -7. Roep `mcp__scrum4me__update_job_status({ job_id, status: 'done', summary })`. +7. Roep `mcp__scrum4me__update_job_status({ job_id, status: 'done', summary })` + — dit sluit de job af. **Verplicht**, ook als de gebruiker afbreekt. ## Stop-conditie @@ -55,7 +63,7 @@ Stop óók als de gebruiker expliciet zegt "klaar" / "genoeg" / "ga door". ## Output-format (strikt) ```markdown -# Idee — {korte titel} +# Idee — ## Scope … diff --git a/src/prompts/idea/make-plan.md b/src/prompts/idea/make-plan.md index 86891a0..300eaf6 100644 --- a/src/prompts/idea/make-plan.md +++ b/src/prompts/idea/make-plan.md @@ -1,21 +1,29 @@ # Make-Plan-prompt voor IDEA_MAKE_PLAN-jobs -> Deze prompt wordt door `wait_for_job` meegestuurd in de payload van een -> `IDEA_MAKE_PLAN`-job. Single-pass, **stel geen vragen** (zie M12 grill-keuze -> 8). Twijfels → terug naar grill via UI. +> Deze prompt wordt door `scrum4me-docker/bin/run-one-job.ts` als +> `claude -p`-input meegegeven voor één geclaimde `IDEA_MAKE_PLAN`-job. +> Single-pass, **stel geen vragen** (zie M12 grill-keuze 8). Twijfels → +> terug naar grill via UI. --- -Je bent een **planning-agent** voor Scrum4Me-idee `{idea_code}`. +Je bent een **planning-agent** voor een Scrum4Me-idee. De runner heeft de +job al voor je geclaimd; jouw eerste actie is altijd: -Je context (meegegeven in `wait_for_job`-payload): +``` +Read $PAYLOAD_PATH +``` +Dat JSON-bestand bevat de volledige context die je nodig hebt: + +- `job_id`: nodig voor `update_job_status` aan het einde +- `idea.id`, `idea.code`, `idea.title`, `idea.description` - `idea.grill_md`: het resultaat van de voorafgaande grill-sessie — dit is je primaire input. -- `idea.plan_md`: bij re-plan bevat dit het vorige plan; gebruik als - referentie. +- `idea.plan_md`: bij re-plan bevat dit het vorige plan; gebruik als referentie. - `product`: gekoppeld product met `repo_url`, `definition_of_done`, bestaande architectuur in repo. +- `primary_worktree_path`: lokale repo (je `cwd` zit daar al). ## Doel @@ -26,13 +34,18 @@ PBI + stories + taken via `materializeIdeaPlanAction`. ## Werkwijze (single-pass) -1. Lees `idea.grill_md` volledig. -2. Verken de repo voor patronen, bestaande modules, en `docs/`-structuur. -3. **Bij removal/refactor: doe een dependency-cascade-grep** (zie volgende +1. **Lees `$PAYLOAD_PATH`** met de `Read`-tool. Bewaar `idea.id`, `idea.code`, + `idea.grill_md`, `idea.plan_md` (mag null zijn), `product.id`, en `job_id` — + die heb je nodig in alle MCP-tool-calls hieronder. +2. Lees `idea.grill_md` volledig. +3. Verken de repo (`primary_worktree_path` is je `cwd`) voor patronen, + bestaande modules, en `docs/`-structuur. +4. **Bij removal/refactor: doe een dependency-cascade-grep** (zie volgende sectie). Voeg per geraakte file een taak toe vóór de schema/code-edit zelf. -4. Bouw het plan op in de **strikte format** hieronder. -5. Roep `mcp__scrum4me__update_idea_plan_md({ idea_id, markdown })`. -6. Roep `mcp__scrum4me__update_job_status({ job_id, status: 'done', summary })`. +5. Bouw het plan op in de **strikte format** hieronder. +6. Roep `mcp__scrum4me__update_idea_plan_md({ idea_id, markdown })`. +7. Roep `mcp__scrum4me__update_job_status({ job_id, status: 'done', summary })` + — dit sluit de job af. **Verplicht**, ook bij parse-failure. ## Dependency-cascade-grep (verplicht bij removal/refactor) From 233e0ef3b64346f291e78089165686838aec80a7 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sat, 9 May 2026 13:39:13 +0200 Subject: [PATCH 18/27] =?UTF-8?q?chore:=20sync=20schema=20+=20adapt=20to?= =?UTF-8?q?=20ACTIVE=E2=86=92OPEN,=20COMPLETED=E2=86=92CLOSED,=20EXCLUDED-?= =?UTF-8?q?task?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Webapp had Prisma-schema migrated: SprintStatus.ACTIVE→OPEN, SprintStatus.COMPLETED→CLOSED, plus new SprintStatus.ARCHIVED. Also new TaskStatus.EXCLUDED. scrum4me-mcp Prisma client was 110 commits behind, causing runtime errors when reading sprint.status from the live DB: Value 'OPEN' not found in enum 'SprintStatus' Symptom: TASK_IMPLEMENTATION jobs in QUEUED status were claimed by tryClaimJob (raw SQL succeeds), then getFullJobContext crashed on the findUnique with the enum error → rollbackClaim → loop forever until UNHEALTHY (5 consecutive failures). Fix: - Updated vendor/scrum4me submodule to current main (3c77342). - Re-ran sync-schema.sh → prisma/schema.prisma now has SprintStatus { OPEN, CLOSED, ARCHIVED, FAILED } and TaskStatus including EXCLUDED. - src/lib/tasks-status-update.ts: ACTIVE→OPEN, COMPLETED→CLOSED. - src/status.ts: TASK_DB_TO_API + TASK_API_TO_DB krijgen EXCLUDED entry. - src/tools/get-claude-context.ts: status: 'ACTIVE' → status: 'OPEN'. Tests: 340 passed (38 files). Typecheck OK. Na merge + docker rebuild met cache-bust pakt de runner sprint-tasks weer op zonder enum-error. Co-Authored-By: Claude Opus 4.7 (1M context) --- prisma/schema.prisma | 26 +++++++++++++++++++++++--- src/lib/tasks-status-update.ts | 8 ++++---- src/status.ts | 2 ++ src/tools/get-claude-context.ts | 2 +- vendor/scrum4me | 2 +- 5 files changed, 31 insertions(+), 9 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0c04619..4f6b086 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -56,6 +56,7 @@ enum TaskStatus { REVIEW DONE FAILED + EXCLUDED } enum LogType { @@ -70,8 +71,9 @@ enum TestStatus { } enum SprintStatus { - ACTIVE - COMPLETED + OPEN + CLOSED + ARCHIVED FAILED } @@ -159,6 +161,7 @@ model User { claude_jobs ClaudeJob[] claude_workers ClaudeWorker[] started_sprint_runs SprintRun[] @relation("SprintRunStartedBy") + push_subscriptions PushSubscription[] @@index([active_product_id]) @@map("users") @@ -297,8 +300,9 @@ model Sprint { id String @id @default(cuid()) product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) product_id String + code String @db.VarChar(30) sprint_goal String - status SprintStatus @default(ACTIVE) + status SprintStatus @default(OPEN) start_date DateTime? @db.Date end_date DateTime? @db.Date created_at DateTime @default(now()) @@ -307,6 +311,7 @@ model Sprint { tasks Task[] sprint_runs SprintRun[] + @@unique([product_id, code]) @@index([product_id, status]) @@map("sprints") } @@ -625,3 +630,18 @@ model ClaudeQuestion { @@index([status, expires_at]) @@map("claude_questions") } + +model PushSubscription { + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_id String + endpoint String @unique + p256dh String + auth String + user_agent String? + created_at DateTime @default(now()) + last_used_at DateTime @default(now()) + + @@index([user_id]) + @@map("push_subscriptions") +} diff --git a/src/lib/tasks-status-update.ts b/src/lib/tasks-status-update.ts index 64e2ac6..6dde6c5 100644 --- a/src/lib/tasks-status-update.ts +++ b/src/lib/tasks-status-update.ts @@ -140,15 +140,15 @@ export async function propagateStatusUpwards( let nextStatus: SprintStatus if (anyPbiFailed) nextStatus = 'FAILED' - else if (allPbisDone) nextStatus = 'COMPLETED' - else nextStatus = 'ACTIVE' + else if (allPbisDone) nextStatus = 'CLOSED' + else nextStatus = 'OPEN' if (nextStatus !== sprint.status) { await tx.sprint.update({ where: { id: sprint.id }, data: { status: nextStatus, - ...(nextStatus === 'COMPLETED' ? { completed_at: new Date() } : {}), + ...(nextStatus === 'CLOSED' ? { completed_at: new Date() } : {}), }, }) sprintChanged = true @@ -162,7 +162,7 @@ export async function propagateStatusUpwards( // 3. Story → Sprint → SprintRun.findFirst({ status: active }) (geen // task-job, bv. handmatige task-statuswijziging via UI). let sprintRunChanged = false - if (nextSprintStatus === 'FAILED' || nextSprintStatus === 'COMPLETED') { + if (nextSprintStatus === 'FAILED' || nextSprintStatus === 'CLOSED') { let resolvedRunId: string | null = sprintRunId ?? null let cancelExceptJobId: string | null = null diff --git a/src/status.ts b/src/status.ts index b256252..dd37dc8 100644 --- a/src/status.ts +++ b/src/status.ts @@ -6,6 +6,7 @@ const TASK_DB_TO_API = { REVIEW: 'review', DONE: 'done', FAILED: 'failed', + EXCLUDED: 'excluded', } as const satisfies Record const TASK_API_TO_DB: Record = { @@ -14,6 +15,7 @@ const TASK_API_TO_DB: Record = { review: 'REVIEW', done: 'DONE', failed: 'FAILED', + excluded: 'EXCLUDED', } const STORY_DB_TO_API = { diff --git a/src/tools/get-claude-context.ts b/src/tools/get-claude-context.ts index fb450e7..80f7a4c 100644 --- a/src/tools/get-claude-context.ts +++ b/src/tools/get-claude-context.ts @@ -47,7 +47,7 @@ export function registerGetClaudeContextTool(server: McpServer) { } const activeSprint = await prisma.sprint.findFirst({ - where: { product_id, status: 'ACTIVE' }, + where: { product_id, status: 'OPEN' }, orderBy: { created_at: 'desc' }, select: { id: true, sprint_goal: true, status: true }, }) diff --git a/vendor/scrum4me b/vendor/scrum4me index 77617e8..3c77342 160000 --- a/vendor/scrum4me +++ b/vendor/scrum4me @@ -1 +1 @@ -Subproject commit 77617e89ac830bc4a86fa7d41f16a5122a1d9689 +Subproject commit 3c773421dacaf506bf35a8270249822cf509ccf3 From 0a18f565d27ef2e051ff8756dc2bed41910a2262 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sat, 9 May 2026 13:53:43 +0200 Subject: [PATCH 19/27] fix(update_job_status): gebruik DB-branch ipv legacy feat/job-<8> fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptoom: TASK_IMPLEMENTATION job T-806 in een SPRINT-strategy sprint faalde met: push failed (unknown): error: src refspec feat/job-us3aqoup does not match any error: failed to push some refs to 'https://github.com/.../Scrum4Me.git' Maar de PR was wel succesvol aangemaakt door Claude (PR #174 op feat/sprint-fvy30lvv) — Claude commit'te in de juiste worktree-branch, maar update_job_status's prepareDoneUpdate probeerde te pushen op een niet-bestaande branch. Root cause: prepareDoneUpdate(jobId, branch) accepteert een branch-arg (meestal undefined want Claude geeft 'm niet mee) en valt terug op `feat/job-${jobId.slice(-8)}`. Dat is het legacy pre-PBI-50 pad — voor sprint-jobs is de werkelijke branch `feat/sprint-` (PR_strategy=SPRINT) of `feat/story-` (STORY), opgeslagen in ClaudeJob.branch door attachWorktreeToJob. Fix: - prepareDoneUpdate leest nu eerst ClaudeJob.branch uit de DB als de expliciete branch-arg ontbreekt. - Pas daarna fallback op `feat/job-<8>` (zou niet moeten voorkomen na PBI-50). Tests: vi.mock('../src/prisma.js') toegevoegd voor de findUnique-stub. Bestaande test "derives branchName from jobId when branch is undefined" hernoemd naar "reads branchName from DB" met DB-mock returnt 'feat/sprint-fvy30lvv'. Plus extra test voor de legacy fallback wanneer DB.branch ook null is. 341 tests in 38 files passed (was 340, +1). Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/update-job-status-push.test.ts | 32 +++++++++++++++++++++++- src/tools/update-job-status.ts | 18 ++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/__tests__/update-job-status-push.test.ts b/__tests__/update-job-status-push.test.ts index 1232670..3ffd6b3 100644 --- a/__tests__/update-job-status-push.test.ts +++ b/__tests__/update-job-status-push.test.ts @@ -5,13 +5,26 @@ vi.mock('../src/git/push.js', () => ({ pushBranchForJob: vi.fn(), })) +vi.mock('../src/prisma.js', () => ({ + prisma: { + claudeJob: { + findUnique: vi.fn(), + }, + }, +})) + import { pushBranchForJob } from '../src/git/push.js' +import { prisma } from '../src/prisma.js' import { prepareDoneUpdate } from '../src/tools/update-job-status.js' const mockPush = pushBranchForJob as ReturnType +const mockFindUnique = (prisma as unknown as { + claudeJob: { findUnique: ReturnType } +}).claudeJob.findUnique beforeEach(() => { vi.clearAllMocks() + mockFindUnique.mockResolvedValue(null) }) describe('prepareDoneUpdate', () => { @@ -39,8 +52,25 @@ describe('prepareDoneUpdate', () => { }) }) - it('derives branchName from jobId when branch is undefined', async () => { + it('reads branchName from DB (claudeJob.branch) when branch arg is undefined', async () => { process.env.SCRUM4ME_AGENT_WORKTREE_DIR = '/wt' + mockFindUnique.mockResolvedValue({ branch: 'feat/sprint-fvy30lvv' }) + mockPush.mockResolvedValue({ pushed: true, remoteRef: 'refs/heads/feat/sprint-fvy30lvv' }) + + await prepareDoneUpdate('job-abc12345', undefined) + + expect(mockFindUnique).toHaveBeenCalledWith({ + where: { id: 'job-abc12345' }, + select: { branch: true }, + }) + expect(mockPush).toHaveBeenCalledWith( + expect.objectContaining({ branchName: 'feat/sprint-fvy30lvv' }), + ) + }) + + it('falls back to feat/job-<8> when neither branch arg nor DB.branch is set', async () => { + process.env.SCRUM4ME_AGENT_WORKTREE_DIR = '/wt' + mockFindUnique.mockResolvedValue({ branch: null }) mockPush.mockResolvedValue({ pushed: true, remoteRef: 'refs/heads/feat/job-abc12345' }) await prepareDoneUpdate('job-abc12345', undefined) diff --git a/src/tools/update-job-status.ts b/src/tools/update-job-status.ts index 5a75a7d..6b4680f 100644 --- a/src/tools/update-job-status.ts +++ b/src/tools/update-job-status.ts @@ -119,9 +119,25 @@ export async function prepareDoneUpdate( jobId: string, branch: string | undefined, ): Promise { + // Resolve branch in deze volgorde: + // 1. Expliciete `branch`-arg van Claude (meestal niet meegegeven). + // 2. ClaudeJob.branch uit de DB — gezet door attachWorktreeToJob met de + // juiste pr_strategy: feat/sprint- voor SPRINT, feat/story- + // voor STORY met sibling-reuse. + // 3. Legacy fallback feat/job-<8> — alleen voor jobs zonder DB-branch + // (zou niet moeten voorkomen na PBI-50). + let resolvedBranch = branch + if (!resolvedBranch) { + const dbJob = await prisma.claudeJob.findUnique({ + where: { id: jobId }, + select: { branch: true }, + }) + resolvedBranch = dbJob?.branch ?? undefined + } + const branchName = resolvedBranch ?? `feat/job-${jobId.slice(-8)}` + const worktreeDir = getWorktreeRoot() const worktreePath = path.join(worktreeDir, jobId) - const branchName = branch ?? `feat/job-${jobId.slice(-8)}` const pushResult = await pushBranchForJob({ worktreePath, branchName }) From 51533cf48e828099a77222dd863daae2df1f2d43 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sat, 9 May 2026 14:05:59 +0200 Subject: [PATCH 20/27] fix(attachWorktreeToJob): schrijf branch naar claudeJob.branch in DB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptoom: TASK_IMPLEMENTATION jobs in een sprint-run met pr_strategy= SPRINT kregen branch=null in claudeJob.branch, ook al maakte attachWorktreeToJob de juiste worktree-branch (feat/sprint-) aan en returnde die in de payload-response. Gevolg: update_job_status (na PR #43-fix) leest claudeJob.branch uit de DB → null → valt terug op legacy `feat/job-<8>` → `git push` faalt met "src refspec feat/job-xxx does not match any" → job FAILED → cascade- cancel van sibling-tasks in dezelfde sprint-run. Live waargenomen voor sprint-run cmoy9irr8000ci017fvy30lvv (T-806 FAILED, T-807-T-811 CANCELLED) ondanks dat Claude PR #174 op feat/sprint-fvy30lvv had gemaakt. Root cause: attachWorktreeToJob (wait-for-job.ts:205-209) update'de alleen base_sha. Voor SPRINT_IMPLEMENTATION-kind wordt branch wel naar DB geschreven (regel 655) maar voor TASK_IMPLEMENTATION-pad zat dat gat. Fix: altijd branch + (indien aanwezig) base_sha schrijven naar claudeJob in de update aan het eind van attachWorktreeToJob. Tests: __tests__/wait-for-job-worktree.test.ts mock-prisma uitgebreid met `claudeJob.update`. 341 tests in 38 files passed. Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/wait-for-job-worktree.test.ts | 4 ++-- src/tools/wait-for-job.ts | 18 ++++++++++++------ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/__tests__/wait-for-job-worktree.test.ts b/__tests__/wait-for-job-worktree.test.ts index c03e91d..d36e08f 100644 --- a/__tests__/wait-for-job-worktree.test.ts +++ b/__tests__/wait-for-job-worktree.test.ts @@ -6,7 +6,7 @@ import * as fs from 'node:fs/promises' vi.mock('../src/prisma.js', () => ({ prisma: { $executeRaw: vi.fn(), - claudeJob: { findFirst: vi.fn(), findUnique: vi.fn() }, + claudeJob: { findFirst: vi.fn(), findUnique: vi.fn(), update: vi.fn() }, product: { findUnique: vi.fn() }, }, })) @@ -21,7 +21,7 @@ import { resolveRepoRoot, rollbackClaim, attachWorktreeToJob } from '../src/tool const mockPrisma = prisma as unknown as { $executeRaw: ReturnType - claudeJob: { findFirst: ReturnType; findUnique: ReturnType } + claudeJob: { findFirst: ReturnType; findUnique: ReturnType; update: ReturnType } product: { findUnique: ReturnType } } const mockCreateWorktree = createWorktreeForJob as ReturnType diff --git a/src/tools/wait-for-job.ts b/src/tools/wait-for-job.ts index c8af6f4..96c11ba 100644 --- a/src/tools/wait-for-job.ts +++ b/src/tools/wait-for-job.ts @@ -202,12 +202,18 @@ export async function attachWorktreeToJob( } catch (err) { console.warn(`[attachWorktreeToJob] failed to resolve base_sha for ${jobId}:`, err) } - if (baseSha) { - await prisma.claudeJob.update({ - where: { id: jobId }, - data: { base_sha: baseSha }, - }) - } + // Persist branch + base_sha. update_job_status (prepareDoneUpdate) + // leest claudeJob.branch om naar de juiste ref te pushen — zonder deze + // update valt 'ie terug op het legacy `feat/job-<8>` patroon en faalt + // de push met "src refspec ... does not match any" voor sprint/story + // strategy branches. + await prisma.claudeJob.update({ + where: { id: jobId }, + data: { + branch: actualBranch, + ...(baseSha ? { base_sha: baseSha } : {}), + }, + }) return { worktree_path: worktreePath, branch_name: actualBranch, reused_branch: reused } } catch (err) { From da1fe415c479bfe7ef8ff7a998b8d5d1e6b14f75 Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Sat, 9 May 2026 16:29:41 +0200 Subject: [PATCH 21/27] fix(cleanup): keepBranch + sprint-scope siblings voor SPRINT pr_strategy (#45) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptoom: in een sprint met pr_strategy=SPRINT (5 tasks, 3 stories) werden de eerste twee tasks SKIPPED door Claude (werk al in main na een externe PR). De derde task crashte op: git worktree add /home/agent/.scrum4me-agent-worktrees/ feat/sprint-uhrbtc8z fatal: invalid reference: feat/sprint-uhrbtc8z Root cause: cleanupWorktreeForTerminalStatus checkte op active siblings binnen dezelfde **story** + verwijderde de branch bij keepBranch=false. Voor SPRINT pr_strategy delen alle stories in de sprint één branch (feat/sprint-). Eerste task SKIPPED, story ST-1304 had geen actieve siblings meer (T-807 was ook al SKIPPED), branch werd verwijderd. T-808 in story ST-1305 wilde reuse'n maar branch bestond niet meer. Fix: 1. Sibling-check verbreden voor SPRINT pr_strategy: kijk naar alle actieve jobs in dezelfde sprint_run_id (niet alleen story_id). 2. keepBranch=true voor SKIPPED bij SPRINT pr_strategy: andere stories in dezelfde sprint hebben de branch nog nodig. Tests: 341 passed (38 files). Typecheck OK. Co-authored-by: Madhura68 Co-authored-by: Claude Opus 4.7 (1M context) --- src/tools/update-job-status.ts | 54 +++++++++++++++++++++++++--------- 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/src/tools/update-job-status.ts b/src/tools/update-job-status.ts index 6b4680f..5e40988 100644 --- a/src/tools/update-job-status.ts +++ b/src/tools/update-job-status.ts @@ -71,31 +71,57 @@ export async function cleanupWorktreeForTerminalStatus( return } - // Branch-per-story: only remove the worktree if no sibling job in the same - // story is still active. If siblings are queued/claimed/running they will - // re-use this branch — destroying the worktree now wastes the next claim. + // Branch-shared check: bepaal welke siblings dezelfde branch reuse'n. + // - SPRINT pr_strategy → alle TASK_IMPLEMENTATION jobs in dezelfde + // sprint_run delen feat/sprint-. + // - STORY pr_strategy / legacy → alle TASK_IMPLEMENTATION jobs in + // dezelfde story delen feat/story-. + // Bij active siblings: defer cleanup (en in elk geval keepBranch=true) + // zodat de volgende claim de branch kan reuse'n. const job = await prisma.claudeJob.findUnique({ where: { id: jobId }, - select: { task: { select: { story_id: true } } }, + select: { + task: { select: { story_id: true } }, + sprint_run_id: true, + sprint_run: { select: { pr_strategy: true } }, + }, }) - if (job?.task) { - const activeSiblings = await prisma.claudeJob.count({ + + let activeSiblings = 0 + let scope = '' + if (job?.sprint_run && job.sprint_run.pr_strategy === 'SPRINT') { + activeSiblings = await prisma.claudeJob.count({ + where: { + sprint_run_id: job.sprint_run_id, + status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] }, + id: { not: jobId }, + }, + }) + scope = `sprint_run ${job.sprint_run_id}` + } else if (job?.task) { + activeSiblings = await prisma.claudeJob.count({ where: { task: { story_id: job.task.story_id }, status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] }, id: { not: jobId }, }, }) - if (activeSiblings > 0) { - console.log( - `[update_job_status] cleanup deferred for job=${jobId}: ${activeSiblings} sibling(s) still active in story ${job.task.story_id}`, - ) - return - } + scope = `story ${job.task.story_id}` } - // Keep branch when job is done and a branch was reported (agent pushed) - const keepBranch = status === 'done' && branch !== undefined + if (activeSiblings > 0) { + console.log( + `[update_job_status] cleanup deferred for job=${jobId}: ${activeSiblings} sibling(s) still active in ${scope}`, + ) + return + } + + // Keep branch when: + // - job is done en agent rapporteerde push (branch !== undefined), of + // - SPRINT pr_strategy job is skipped — andere stories delen branch. + const keepBranch = + (status === 'done' && branch !== undefined) || + (status === 'skipped' && job?.sprint_run?.pr_strategy === 'SPRINT') try { await removeWorktreeForJob({ repoRoot, jobId, keepBranch }) } catch (err) { From 9ffa25f0536cebe328a8abf9d2b24c43509a13ca Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Sat, 9 May 2026 20:30:17 +0200 Subject: [PATCH 22/27] fix(verify/classify): negeer pseudo-paths in plan (geen PARTIAL meer voor delete-only) (#46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extractPlanPaths beschouwde tokens als `data-debug-label="..."` als file-paden omdat ze een dot bevatten en geen spaties. Resultaat: het pseudo-pad werd nooit in de diff gevonden → coverage < 1 → PARTIAL → met verify_required=ALIGNED faalde de job, ondanks dat het werk volledig gedaan was. Concreet incident T-815 (sprint cmoyiu4yd, 2026-05-09): - 17/17 files data-debug-label verwijderd, grep 0 hits, typecheck groen - Verifier zei PARTIAL → Claude rapporteerde failed → propagateStatusUpwards + cancelPbiOnFailure cancelden 12 siblings + deleten feat/sprint-acq9twtr - T-814's al-gepushte werk verloren Fix: nieuwe `looksLikePath`-helper die backtick-tokens verwerpt als ze operator/quote/bracket chars bevatten, een ellipsis (`..`/`...`) hebben, of geen `/` én geen herkenbare file-extensie hebben. Bullet-extractor blijft onveranderd — die parseert al expliciet op `.ext`. Tests: 5 nieuwe regression-cases + alle 18 bestaande blijven groen. Co-authored-by: Madhura68 Co-authored-by: Claude Opus 4.7 (1M context) --- __tests__/verify/classify.test.ts | 50 +++++++++++++++++++++++++++++++ src/verify/classify.ts | 16 +++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/__tests__/verify/classify.test.ts b/__tests__/verify/classify.test.ts index 1658e36..968e125 100644 --- a/__tests__/verify/classify.test.ts +++ b/__tests__/verify/classify.test.ts @@ -163,3 +163,53 @@ describe('classifyDiffAgainstPlan — delete-only commits', () => { expect(r.result).toBe('EMPTY') }) }) + +// Pseudo-paths in plans (code-snippets, attribute-syntax, ellipses) moeten +// niet als plan-paden meetellen — anders krijg je PARTIAL terwijl het werk +// volledig gedaan is. Regression-guard voor T-815-incident (sprint +// cmoyiu4yd000zf917acq9twtr, 2026-05-09). +describe('classifyDiffAgainstPlan — plan met pseudo-paths', () => { + it('negeert `data-debug-label="..."` als pseudo-pad en classificeert ALIGNED', () => { + const plan = [ + 'Verwijder alle voorkomens van `data-debug-label="..."` uit:', + '', + '- `app/components/shared/status-bar.tsx`', + '- `app/components/shared/header.tsx`', + ].join('\n') + const diff = makeDiff([ + 'app/components/shared/status-bar.tsx', + 'app/components/shared/header.tsx', + ]) + const r = classifyDiffAgainstPlan({ diff, plan }) + expect(r.result).toBe('ALIGNED') + }) + + it('negeert ellipsis-tokens (drie of meer dots) als pad', () => { + const plan = 'Refactor `foo(...)` naar `bar()`. Files: `src/a.ts`.' + const diff = makeDiff(['src/a.ts']) + const r = classifyDiffAgainstPlan({ diff, plan }) + expect(r.result).toBe('ALIGNED') + }) + + it('negeert tokens met operators/quotes als pad', () => { + const plan = 'Wijzig `props={x: 1}` en `useState()` in `src/c.tsx`.' + const diff = makeDiff(['src/c.tsx']) + const r = classifyDiffAgainstPlan({ diff, plan }) + expect(r.result).toBe('ALIGNED') + }) + + it('accepteert package.json en andere extension-only paths', () => { + const plan = 'Update `package.json` en `tsconfig.json`.' + const diff = makeDiff(['package.json', 'tsconfig.json']) + const r = classifyDiffAgainstPlan({ diff, plan }) + expect(r.result).toBe('ALIGNED') + }) + + it('blijft PARTIAL retourneren wanneer een echt plan-pad ontbreekt', () => { + const plan = 'Wijzig `src/foo.ts` en `src/bar.ts`. Verwijder `data-x="..."`.' + const diff = makeDiff(['src/foo.ts']) + const r = classifyDiffAgainstPlan({ diff, plan }) + expect(r.result).toBe('PARTIAL') + expect(r.reasoning).toMatch(/bar\.ts/) + }) +}) diff --git a/src/verify/classify.ts b/src/verify/classify.ts index 3fe99f5..429bfe3 100644 --- a/src/verify/classify.ts +++ b/src/verify/classify.ts @@ -27,7 +27,7 @@ function extractPlanPaths(plan: string): string[] { let m: RegExpExecArray | null while ((m = backtickRe.exec(plan)) !== null) { const p = m[1].trim() - if ((p.includes('/') || p.includes('.')) && !p.includes(' ') && p.length > 3) paths.add(p) + if (looksLikePath(p)) paths.add(p) } const bulletRe = /^[-*]\s+\*{0,2}([^\s*][^\s]*)\.([a-zA-Z]{1,6})\*{0,2}\s*[:\n]/gm @@ -38,6 +38,20 @@ function extractPlanPaths(plan: string): string[] { return [...paths] } +// Heuristic: does this backtick-quoted token look like a file path? +// Excludes code-snippets like `data-debug-label="..."`, `foo()`, `
` — +// anything containing operator/quote/bracket chars or an ellipsis is rejected. +// Accepts paths with a slash (multi-segment) or a recognisable file-extension +// suffix (1–6 alphanumeric chars after a final dot, e.g. `.tsx`, `.json`). +function looksLikePath(p: string): boolean { + if (p.length <= 3) return false + if (p.includes(' ')) return false + if (/[="'<>()[\]{};,]/.test(p)) return false + if (/\.{2,}/.test(p)) return false + if (!p.includes('/') && !/\.[a-zA-Z][a-zA-Z0-9]{0,5}$/.test(p)) return false + return true +} + // Path match: exact or suffix match so "classify.ts" matches "src/verify/classify.ts". function pathMatches(planPath: string, diffPaths: string[]): boolean { const norm = planPath.replace(/\\/g, '/') From 93d881318db6da7cc48bd759bf4e4043ae629c67 Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Mon, 11 May 2026 21:37:05 +0200 Subject: [PATCH 23/27] feat(PBI-12): create_sprint + update_sprint MCP-tools (#47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(PBI-12 T-51): voeg create_sprint tool toe Maakt een sprint aan met status=OPEN. Code auto-gegenereerd als S-{YYYY-MM-DD}-{N} per product per datum als niet meegegeven, met retry bij race-conflict op @@unique([product_id, code]). Volgt create-pbi.ts template. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(PBI-12 T-52): voeg update_sprint tool toe Generieke update voor status, sprint_goal, start_date en end_date. Géén state-machine validatie — last-write-wins. Bij status → CLOSED/FAILED/ARCHIVED zonder expliciete end_date wordt end_date automatisch op vandaag gezet. Minimaal één veld vereist (handmatige check in handler i.p.v. zod-refine want McpServer.inputSchema accepteert geen ZodEffects). Co-Authored-By: Claude Opus 4.7 (1M context) * feat(PBI-12 T-53): registreer sprint-tools + unit-tests - Imports + register-calls toegevoegd in src/index.ts (groep met andere authoring-tools, comment "PBI-12: sprint lifecycle tools") - Refactor: create-sprint en update-sprint exporteren nu handleX + inputSchema apart (pattern van set-pbi-pr.ts) zodat de logica zonder McpServer wrapper testbaar is - 6 unit-tests voor create_sprint (happy path, custom code, auto-increment, P2002-retry, access-denied, explicit start_date) - 11 unit-tests voor update_sprint (no-fields-error, status-only, auto-end_date voor CLOSED/FAILED/ARCHIVED, geen auto voor OPEN, expliciete end_date respect, multi-field, not-found, access-denied, any-status-transition) - Defensive date-check in generateNextSprintCode tegen filter-veranderingen of mock-data anomalieën - 363 tests groen (was 346 + 17 nieuwe) DB-smoke-test (MCP-server vs dev-DB) overgeslagen want unit-coverage dekt het gedrag volledig; mock-vrije integratie volgt automatisch bij eerstvolgende productie-aanroep van create_sprint via een echte agent. Co-Authored-By: Claude Opus 4.7 (1M context) * chore: untrack .claude/worktrees gitlinks + ignore pad Per ongeluk in adbea3f meegenomen via 'git add -A'; deze embedded worktree- clones horen niet in de repo. Ook .gitignore aangevuld zodat dit niet opnieuw gebeurt. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(PBI-12): update_sprint zet completed_at op CLOSED — parity met cascade Codex-review op #47: bij status → CLOSED werd alleen end_date gezet, niet completed_at. Dat is divergeert van src/lib/tasks-status-update.ts dat completed_at = new Date() zet bij automatische sluiting via task-status- cascade. Reporting en UI die op completed_at filteren zagen handmatig gesloten sprints als 'never completed'. Fix: - update_sprint zet nu data.completed_at = new Date() wanneer status === 'CLOSED' - FAILED/ARCHIVED raken completed_at NIET (parity met bestaand patroon) - Test-coverage uitgebreid: - CLOSED zet end_date EN completed_at - FAILED zet end_date, completed_at blijft undefined - ARCHIVED zet end_date, completed_at blijft undefined - OPEN zet noch end_date noch completed_at - Expliciete end_date wordt gerespecteerd, completed_at wordt nog steeds gezet - Tool description vermeldt nu de completed_at-side-effect Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Madhura68 Co-authored-by: Claude Opus 4.7 (1M context) --- .gitignore | 3 + __tests__/create-sprint.test.ts | 163 ++++++++++++++++++++++++++++++ __tests__/update-sprint.test.ts | 174 ++++++++++++++++++++++++++++++++ src/index.ts | 5 + src/tools/create-sprint.ts | 113 +++++++++++++++++++++ src/tools/update-sprint.ts | 102 +++++++++++++++++++ 6 files changed, 560 insertions(+) create mode 100644 __tests__/create-sprint.test.ts create mode 100644 __tests__/update-sprint.test.ts create mode 100644 src/tools/create-sprint.ts create mode 100644 src/tools/update-sprint.ts diff --git a/.gitignore b/.gitignore index 10a6dab..547c38e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ prisma/generated # Editor .vscode .idea + +# Claude Code worktrees (per-session, never tracked) +.claude/worktrees/ diff --git a/__tests__/create-sprint.test.ts b/__tests__/create-sprint.test.ts new file mode 100644 index 0000000..72d400d --- /dev/null +++ b/__tests__/create-sprint.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { Prisma } from '@prisma/client' + +vi.mock('../src/prisma.js', () => ({ + prisma: { + sprint: { + findMany: vi.fn(), + create: vi.fn(), + }, + }, +})) + +vi.mock('../src/auth.js', () => ({ + requireWriteAccess: vi.fn(), + PermissionDeniedError: class PermissionDeniedError extends Error { + constructor(message = 'Demo accounts cannot perform write operations') { + super(message) + this.name = 'PermissionDeniedError' + } + }, +})) + +vi.mock('../src/access.js', () => ({ + userCanAccessProduct: vi.fn(), +})) + +import { prisma } from '../src/prisma.js' +import { requireWriteAccess } from '../src/auth.js' +import { userCanAccessProduct } from '../src/access.js' +import { handleCreateSprint } from '../src/tools/create-sprint.js' + +const mockPrisma = prisma as unknown as { + sprint: { + findMany: ReturnType + create: ReturnType + } +} +const mockRequireWriteAccess = requireWriteAccess as ReturnType +const mockUserCanAccessProduct = userCanAccessProduct as ReturnType + +const PRODUCT_ID = 'prod-1' +const USER_ID = 'user-1' + +beforeEach(() => { + vi.clearAllMocks() + mockRequireWriteAccess.mockResolvedValue({ userId: USER_ID, tokenId: 'tok-1', username: 'alice', isDemo: false }) + mockUserCanAccessProduct.mockResolvedValue(true) + mockPrisma.sprint.findMany.mockResolvedValue([]) +}) + +function parseResult(result: Awaited>) { + const text = result.content?.[0]?.type === 'text' ? result.content[0].text : '' + try { return JSON.parse(text) } catch { return text } +} + +describe('handleCreateSprint', () => { + it('happy path: creates sprint with auto-generated code', async () => { + mockPrisma.sprint.create.mockResolvedValue({ + id: 'spr-1', + code: 'S-2026-05-11-1', + sprint_goal: 'My goal', + status: 'OPEN', + start_date: new Date('2026-05-11'), + created_at: new Date('2026-05-11T10:00:00Z'), + }) + + const result = await handleCreateSprint({ + product_id: PRODUCT_ID, + sprint_goal: 'My goal', + }) + + expect(mockPrisma.sprint.create).toHaveBeenCalledTimes(1) + const callArgs = mockPrisma.sprint.create.mock.calls[0][0] + expect(callArgs.data.product_id).toBe(PRODUCT_ID) + expect(callArgs.data.status).toBe('OPEN') + expect(callArgs.data.sprint_goal).toBe('My goal') + expect(callArgs.data.code).toMatch(/^S-\d{4}-\d{2}-\d{2}-1$/) + expect(callArgs.data.start_date).toBeInstanceOf(Date) + + const parsed = parseResult(result) + expect(parsed.id).toBe('spr-1') + expect(parsed.status).toBe('OPEN') + }) + + it('uses user-provided code when given', async () => { + mockPrisma.sprint.create.mockResolvedValue({ + id: 'spr-2', + code: 'CUSTOM-CODE', + sprint_goal: 'g', + status: 'OPEN', + start_date: new Date(), + created_at: new Date(), + }) + + await handleCreateSprint({ + product_id: PRODUCT_ID, + code: 'CUSTOM-CODE', + sprint_goal: 'g', + }) + + expect(mockPrisma.sprint.create).toHaveBeenCalledTimes(1) + expect(mockPrisma.sprint.findMany).not.toHaveBeenCalled() + expect(mockPrisma.sprint.create.mock.calls[0][0].data.code).toBe('CUSTOM-CODE') + }) + + it('auto-code increments past existing same-day sprints', async () => { + mockPrisma.sprint.findMany.mockResolvedValue([ + { code: 'S-2026-05-11-1' }, + { code: 'S-2026-05-11-3' }, + { code: 'S-2026-05-10-7' }, + ]) + mockPrisma.sprint.create.mockResolvedValue({ + id: 'spr-3', code: 'X', sprint_goal: 'g', status: 'OPEN', start_date: new Date(), created_at: new Date(), + }) + + await handleCreateSprint({ product_id: PRODUCT_ID, sprint_goal: 'g' }) + + const today = new Date().toISOString().slice(0, 10) + expect(mockPrisma.sprint.create.mock.calls[0][0].data.code).toBe(`S-${today}-4`) + }) + + it('retries on P2002 unique conflict', async () => { + const conflict = new Prisma.PrismaClientKnownRequestError('unique', { + code: 'P2002', clientVersion: 'x', meta: { target: ['product_id', 'code'] }, + }) + mockPrisma.sprint.create + .mockRejectedValueOnce(conflict) + .mockResolvedValueOnce({ + id: 'spr-r', code: 'S-2026-05-11-2', sprint_goal: 'g', status: 'OPEN', + start_date: new Date(), created_at: new Date(), + }) + + const result = await handleCreateSprint({ product_id: PRODUCT_ID, sprint_goal: 'g' }) + + expect(mockPrisma.sprint.create).toHaveBeenCalledTimes(2) + expect(parseResult(result).id).toBe('spr-r') + }) + + it('returns error when user cannot access product', async () => { + mockUserCanAccessProduct.mockResolvedValue(false) + const result = await handleCreateSprint({ product_id: PRODUCT_ID, sprint_goal: 'g' }) + + expect(mockPrisma.sprint.create).not.toHaveBeenCalled() + const text = result.content?.[0]?.type === 'text' ? result.content[0].text : '' + expect(text).toMatch(/not found or not accessible/) + }) + + it('uses provided start_date when given', async () => { + mockPrisma.sprint.create.mockResolvedValue({ + id: 'spr-d', code: 'X', sprint_goal: 'g', status: 'OPEN', + start_date: new Date('2026-01-01'), created_at: new Date(), + }) + + await handleCreateSprint({ + product_id: PRODUCT_ID, + sprint_goal: 'g', + start_date: '2026-01-01', + }) + + const callArgs = mockPrisma.sprint.create.mock.calls[0][0] + expect(callArgs.data.start_date.toISOString().slice(0, 10)).toBe('2026-01-01') + }) +}) diff --git a/__tests__/update-sprint.test.ts b/__tests__/update-sprint.test.ts new file mode 100644 index 0000000..3c62790 --- /dev/null +++ b/__tests__/update-sprint.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../src/prisma.js', () => ({ + prisma: { + sprint: { + findUnique: vi.fn(), + update: vi.fn(), + }, + }, +})) + +vi.mock('../src/auth.js', () => ({ + requireWriteAccess: vi.fn(), + PermissionDeniedError: class PermissionDeniedError extends Error { + constructor(message = 'Demo accounts cannot perform write operations') { + super(message) + this.name = 'PermissionDeniedError' + } + }, +})) + +vi.mock('../src/access.js', () => ({ + userCanAccessProduct: vi.fn(), +})) + +import { prisma } from '../src/prisma.js' +import { requireWriteAccess } from '../src/auth.js' +import { userCanAccessProduct } from '../src/access.js' +import { handleUpdateSprint } from '../src/tools/update-sprint.js' + +const mockPrisma = prisma as unknown as { + sprint: { + findUnique: ReturnType + update: ReturnType + } +} +const mockRequireWriteAccess = requireWriteAccess as ReturnType +const mockUserCanAccessProduct = userCanAccessProduct as ReturnType + +const SPRINT_ID = 'spr-1' +const PRODUCT_ID = 'prod-1' +const USER_ID = 'user-1' + +beforeEach(() => { + vi.clearAllMocks() + mockRequireWriteAccess.mockResolvedValue({ userId: USER_ID, tokenId: 'tok-1', username: 'alice', isDemo: false }) + mockUserCanAccessProduct.mockResolvedValue(true) + mockPrisma.sprint.findUnique.mockResolvedValue({ id: SPRINT_ID, product_id: PRODUCT_ID }) + mockPrisma.sprint.update.mockResolvedValue({ + id: SPRINT_ID, + code: 'S-2026-05-11-1', + sprint_goal: 'g', + status: 'OPEN', + start_date: new Date('2026-05-11'), + end_date: null, + completed_at: null, + }) +}) + +function getText(result: Awaited>) { + return result.content?.[0]?.type === 'text' ? result.content[0].text : '' +} + +describe('handleUpdateSprint', () => { + it('returns error when no fields provided', async () => { + const result = await handleUpdateSprint({ sprint_id: SPRINT_ID }) + + expect(mockPrisma.sprint.update).not.toHaveBeenCalled() + expect(getText(result)).toMatch(/Minstens één veld vereist/) + }) + + it('updates status only', async () => { + await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'OPEN' }) + + expect(mockPrisma.sprint.update).toHaveBeenCalledTimes(1) + const args = mockPrisma.sprint.update.mock.calls[0][0] + expect(args.where).toEqual({ id: SPRINT_ID }) + expect(args.data).toEqual({ status: 'OPEN' }) + }) + + it('auto-sets end_date AND completed_at when status → CLOSED without explicit end_date', async () => { + const before = Date.now() + await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'CLOSED' }) + const after = Date.now() + + const args = mockPrisma.sprint.update.mock.calls[0][0] + expect(args.data.status).toBe('CLOSED') + expect(args.data.end_date).toBeInstanceOf(Date) + expect(args.data.end_date.getTime()).toBeGreaterThanOrEqual(before) + expect(args.data.end_date.getTime()).toBeLessThanOrEqual(after) + expect(args.data.completed_at).toBeInstanceOf(Date) + expect(args.data.completed_at.getTime()).toBeGreaterThanOrEqual(before) + expect(args.data.completed_at.getTime()).toBeLessThanOrEqual(after) + }) + + it('auto-sets end_date when status → FAILED, but does NOT set completed_at', async () => { + await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'FAILED' }) + + const args = mockPrisma.sprint.update.mock.calls[0][0] + expect(args.data.end_date).toBeInstanceOf(Date) + expect(args.data.completed_at).toBeUndefined() + }) + + it('auto-sets end_date when status → ARCHIVED, but does NOT set completed_at', async () => { + await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'ARCHIVED' }) + + const args = mockPrisma.sprint.update.mock.calls[0][0] + expect(args.data.end_date).toBeInstanceOf(Date) + expect(args.data.completed_at).toBeUndefined() + }) + + it('still sets completed_at when status → CLOSED even with explicit end_date', async () => { + await handleUpdateSprint({ + sprint_id: SPRINT_ID, + status: 'CLOSED', + end_date: '2025-12-31', + }) + + const args = mockPrisma.sprint.update.mock.calls[0][0] + expect(args.data.end_date.toISOString().slice(0, 10)).toBe('2025-12-31') + expect(args.data.completed_at).toBeInstanceOf(Date) + }) + + it('does NOT auto-set end_date or completed_at when status → OPEN', async () => { + await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'OPEN' }) + + const args = mockPrisma.sprint.update.mock.calls[0][0] + expect(args.data.end_date).toBeUndefined() + expect(args.data.completed_at).toBeUndefined() + }) + + it('updates multiple fields at once', async () => { + await handleUpdateSprint({ + sprint_id: SPRINT_ID, + sprint_goal: 'New goal', + start_date: '2026-05-15', + }) + + const args = mockPrisma.sprint.update.mock.calls[0][0] + expect(args.data.sprint_goal).toBe('New goal') + expect(args.data.start_date.toISOString().slice(0, 10)).toBe('2026-05-15') + expect(args.data.status).toBeUndefined() + expect(args.data.end_date).toBeUndefined() + }) + + it('returns error when sprint not found', async () => { + mockPrisma.sprint.findUnique.mockResolvedValue(null) + + const result = await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'CLOSED' }) + + expect(mockPrisma.sprint.update).not.toHaveBeenCalled() + expect(getText(result)).toMatch(/not found/) + }) + + it('returns error when user cannot access sprint product', async () => { + mockUserCanAccessProduct.mockResolvedValue(false) + + const result = await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'CLOSED' }) + + expect(mockPrisma.sprint.update).not.toHaveBeenCalled() + expect(getText(result)).toMatch(/not accessible/) + }) + + it('allows any status transition (no state-machine)', async () => { + await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'OPEN' }) + expect(mockPrisma.sprint.update).toHaveBeenCalledTimes(1) + + await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'CLOSED' }) + expect(mockPrisma.sprint.update).toHaveBeenCalledTimes(2) + + await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'OPEN' }) + expect(mockPrisma.sprint.update).toHaveBeenCalledTimes(3) + }) +}) diff --git a/src/index.ts b/src/index.ts index 2938c70..06cefba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,8 @@ import { registerLogCommitTool } from './tools/log-commit.js' import { registerCreatePbiTool } from './tools/create-pbi.js' import { registerCreateStoryTool } from './tools/create-story.js' import { registerCreateTaskTool } from './tools/create-task.js' +import { registerCreateSprintTool } from './tools/create-sprint.js' +import { registerUpdateSprintTool } from './tools/update-sprint.js' import { registerAskUserQuestionTool } from './tools/ask-user-question.js' import { registerGetQuestionAnswerTool } from './tools/get-question-answer.js' import { registerListOpenQuestionsTool } from './tools/list-open-questions.js' @@ -77,6 +79,9 @@ async function main() { registerCreatePbiTool(server) registerCreateStoryTool(server) registerCreateTaskTool(server) + // PBI-12: sprint lifecycle tools + registerCreateSprintTool(server) + registerUpdateSprintTool(server) registerAskUserQuestionTool(server) registerGetQuestionAnswerTool(server) registerListOpenQuestionsTool(server) diff --git a/src/tools/create-sprint.ts b/src/tools/create-sprint.ts new file mode 100644 index 0000000..5d8cd9b --- /dev/null +++ b/src/tools/create-sprint.ts @@ -0,0 +1,113 @@ +// MCP authoring tool: create een Sprint binnen een product. +// +// Status start altijd op OPEN; geen reuse-check op bestaande OPEN-sprints +// (per plan-to-pbi-flow.md "altijd nieuwe sprint"). Code wordt auto-gegenereerd +// als S-{YYYY-MM-DD}-{N} per product per datum, met retry bij race-condition +// op de unique constraint (@@unique([product_id, code])). + +import { z } from 'zod' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { Prisma } from '@prisma/client' +import { prisma } from '../prisma.js' +import { requireWriteAccess } from '../auth.js' +import { userCanAccessProduct } from '../access.js' +import { toolError, toolJson, withToolErrors } from '../errors.js' + +const SPRINT_AUTO_RE = /^S-(\d{4}-\d{2}-\d{2})-(\d+)$/ +const MAX_CODE_ATTEMPTS = 3 + +function todayIsoDate(): string { + return new Date().toISOString().slice(0, 10) +} + +async function generateNextSprintCode(productId: string): Promise { + const today = todayIsoDate() + const sprints = await prisma.sprint.findMany({ + where: { product_id: productId, code: { startsWith: `S-${today}-` } }, + select: { code: true }, + }) + let max = 0 + for (const s of sprints) { + const m = s.code?.match(SPRINT_AUTO_RE) + // Dubbele check op de datum — defensive tegen filterveranderingen + // of mock-data die niet door de DB-where heen ging. + if (m && m[1] === today) { + const n = Number.parseInt(m[2], 10) + if (!Number.isNaN(n) && n > max) max = n + } + } + return `S-${today}-${max + 1}` +} + +function isCodeUniqueConflict(error: unknown): boolean { + if (!(error instanceof Prisma.PrismaClientKnownRequestError)) return false + if (error.code !== 'P2002') return false + const target = (error.meta as { target?: string[] | string } | undefined)?.target + if (!target) return false + return Array.isArray(target) ? target.includes('code') : target.includes('code') +} + +export const inputSchema = z.object({ + product_id: z.string().min(1), + code: z.string().min(1).max(30).optional(), + sprint_goal: z.string().min(1).max(500), + start_date: z.string().date().optional(), +}) + +export async function handleCreateSprint( + { product_id, code, sprint_goal, start_date }: z.infer, +) { + return withToolErrors(async () => { + const auth = await requireWriteAccess() + if (!(await userCanAccessProduct(product_id, auth.userId))) { + return toolError(`Product ${product_id} not found or not accessible`) + } + + const resolvedStartDate = start_date ? new Date(start_date) : new Date() + const baseSelect = { + id: true, + code: true, + sprint_goal: true, + status: true, + start_date: true, + created_at: true, + } as const + + if (code) { + const sprint = await prisma.sprint.create({ + data: { product_id, code, sprint_goal, status: 'OPEN', start_date: resolvedStartDate }, + select: baseSelect, + }) + return toolJson(sprint) + } + + let lastError: unknown + for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) { + const generated = await generateNextSprintCode(product_id) + try { + const sprint = await prisma.sprint.create({ + data: { product_id, code: generated, sprint_goal, status: 'OPEN', start_date: resolvedStartDate }, + select: baseSelect, + }) + return toolJson(sprint) + } catch (e) { + if (isCodeUniqueConflict(e)) { lastError = e; continue } + throw e + } + } + throw lastError ?? new Error('Kon geen unieke sprint-code genereren') + }) +} + +export function registerCreateSprintTool(server: McpServer) { + server.registerTool( + 'create_sprint', + { + title: 'Create Sprint', + description: + 'Create a new sprint for a product with status=OPEN. Code auto-generated as S-{YYYY-MM-DD}-{N} per product per date if not provided. Forbidden for demo accounts.', + inputSchema, + }, + handleCreateSprint, + ) +} diff --git a/src/tools/update-sprint.ts b/src/tools/update-sprint.ts new file mode 100644 index 0000000..04800e3 --- /dev/null +++ b/src/tools/update-sprint.ts @@ -0,0 +1,102 @@ +// MCP tool: update een Sprint. +// +// Generieke update — wijzigt elke combinatie van status, sprint_goal, +// start_date en end_date. Géén state-machine validatie (zie +// docs/plans/sprint-mcp-tools.md): last-write-wins, het resubmit/heropen-pad +// zit elders. Bij status → CLOSED/FAILED/ARCHIVED zonder expliciete end_date +// wordt end_date automatisch op vandaag gezet. Bij status → CLOSED wordt +// daarnaast `completed_at` op now() gezet (parity met +// src/lib/tasks-status-update.ts dat hetzelfde doet bij auto-close via +// task-status-cascade; zo houden reporting en UI één bron van waarheid voor +// completion-tijd). + +import { z } from 'zod' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import type { SprintStatus } from '@prisma/client' +import { prisma } from '../prisma.js' +import { requireWriteAccess } from '../auth.js' +import { userCanAccessProduct } from '../access.js' +import { toolError, toolJson, withToolErrors } from '../errors.js' + +const TERMINAL_STATUSES = new Set(['CLOSED', 'FAILED', 'ARCHIVED']) + +export const inputSchema = z.object({ + sprint_id: z.string().min(1), + status: z.enum(['OPEN', 'CLOSED', 'ARCHIVED', 'FAILED']).optional(), + sprint_goal: z.string().min(1).max(500).optional(), + end_date: z.string().date().optional(), + start_date: z.string().date().optional(), +}) + +export async function handleUpdateSprint( + { sprint_id, status, sprint_goal, end_date, start_date }: z.infer, +) { + return withToolErrors(async () => { + if ( + status === undefined && + sprint_goal === undefined && + end_date === undefined && + start_date === undefined + ) { + return toolError('Minstens één veld vereist om te wijzigen') + } + + const auth = await requireWriteAccess() + + const sprint = await prisma.sprint.findUnique({ + where: { id: sprint_id }, + select: { id: true, product_id: true }, + }) + if (!sprint) { + return toolError(`Sprint ${sprint_id} not found`) + } + if (!(await userCanAccessProduct(sprint.product_id, auth.userId))) { + return toolError(`Sprint ${sprint_id} not accessible`) + } + + const data: { + status?: SprintStatus + sprint_goal?: string + start_date?: Date + end_date?: Date + completed_at?: Date + } = {} + if (status !== undefined) data.status = status + if (sprint_goal !== undefined) data.sprint_goal = sprint_goal + if (start_date !== undefined) data.start_date = new Date(start_date) + if (end_date !== undefined) { + data.end_date = new Date(end_date) + } else if (status !== undefined && TERMINAL_STATUSES.has(status)) { + data.end_date = new Date() + } + if (status === 'CLOSED') data.completed_at = new Date() + + const updated = await prisma.sprint.update({ + where: { id: sprint_id }, + data, + select: { + id: true, + code: true, + sprint_goal: true, + status: true, + start_date: true, + end_date: true, + completed_at: true, + }, + }) + return toolJson(updated) + }) +} + +export function registerUpdateSprintTool(server: McpServer) { + server.registerTool( + 'update_sprint', + { + title: 'Update Sprint', + description: + 'Update a sprint: status, sprint_goal, start_date and/or end_date. At least one field required. No state-machine validation — last-write-wins. When status goes to CLOSED/FAILED/ARCHIVED and end_date is not provided, end_date is set to today. When status goes to CLOSED, completed_at is set to now (parity with auto-close via task-cascade). Forbidden for demo accounts.', + inputSchema, + }, + handleUpdateSprint, + ) +} From 55fa133150a5dd7ae249cc448d73568317b20667 Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Thu, 14 May 2026 14:30:17 +0000 Subject: [PATCH 24/27] feat: IDEA_REVIEW_PLAN-wiring + create_story sprint_id (v0.8.0) (#48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(PBI-12 T-51): voeg create_sprint tool toe Maakt een sprint aan met status=OPEN. Code auto-gegenereerd als S-{YYYY-MM-DD}-{N} per product per datum als niet meegegeven, met retry bij race-conflict op @@unique([product_id, code]). Volgt create-pbi.ts template. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(PBI-12 T-52): voeg update_sprint tool toe Generieke update voor status, sprint_goal, start_date en end_date. Géén state-machine validatie — last-write-wins. Bij status → CLOSED/FAILED/ARCHIVED zonder expliciete end_date wordt end_date automatisch op vandaag gezet. Minimaal één veld vereist (handmatige check in handler i.p.v. zod-refine want McpServer.inputSchema accepteert geen ZodEffects). Co-Authored-By: Claude Opus 4.7 (1M context) * feat(PBI-12 T-53): registreer sprint-tools + unit-tests - Imports + register-calls toegevoegd in src/index.ts (groep met andere authoring-tools, comment "PBI-12: sprint lifecycle tools") - Refactor: create-sprint en update-sprint exporteren nu handleX + inputSchema apart (pattern van set-pbi-pr.ts) zodat de logica zonder McpServer wrapper testbaar is - 6 unit-tests voor create_sprint (happy path, custom code, auto-increment, P2002-retry, access-denied, explicit start_date) - 11 unit-tests voor update_sprint (no-fields-error, status-only, auto-end_date voor CLOSED/FAILED/ARCHIVED, geen auto voor OPEN, expliciete end_date respect, multi-field, not-found, access-denied, any-status-transition) - Defensive date-check in generateNextSprintCode tegen filter-veranderingen of mock-data anomalieën - 363 tests groen (was 346 + 17 nieuwe) DB-smoke-test (MCP-server vs dev-DB) overgeslagen want unit-coverage dekt het gedrag volledig; mock-vrije integratie volgt automatisch bij eerstvolgende productie-aanroep van create_sprint via een echte agent. Co-Authored-By: Claude Opus 4.7 (1M context) * chore: untrack .claude/worktrees gitlinks + ignore pad Per ongeluk in adbea3f meegenomen via 'git add -A'; deze embedded worktree- clones horen niet in de repo. Ook .gitignore aangevuld zodat dit niet opnieuw gebeurt. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(PBI-12): update_sprint zet completed_at op CLOSED — parity met cascade Codex-review op #47: bij status → CLOSED werd alleen end_date gezet, niet completed_at. Dat is divergeert van src/lib/tasks-status-update.ts dat completed_at = new Date() zet bij automatische sluiting via task-status- cascade. Reporting en UI die op completed_at filteren zagen handmatig gesloten sprints als 'never completed'. Fix: - update_sprint zet nu data.completed_at = new Date() wanneer status === 'CLOSED' - FAILED/ARCHIVED raken completed_at NIET (parity met bestaand patroon) - Test-coverage uitgebreid: - CLOSED zet end_date EN completed_at - FAILED zet end_date, completed_at blijft undefined - ARCHIVED zet end_date, completed_at blijft undefined - OPEN zet noch end_date noch completed_at - Expliciete end_date wordt gerespecteerd, completed_at wordt nog steeds gezet - Tool description vermeldt nu de completed_at-side-effect Co-Authored-By: Claude Opus 4.7 (1M context) * PBI-67 Phase 2: Add update-idea-plan-reviewed MCP tool - Create src/tools/update-idea-plan-reviewed.ts: saves review-log and transitions idea status - Register tool in src/index.ts - Update Prisma schema: add plan_review_log and reviewed_at fields to Idea model - Add PLAN_REVIEW_RESULT to IdeaLogType enum - Add REVIEWING_PLAN, PLAN_REVIEW_FAILED, PLAN_REVIEWED to IdeaStatus enum - Add IDEA_REVIEW_PLAN to ClaudeJobKind enum - Build successful with all type checks passing Co-Authored-By: Claude Haiku 4.5 * feat(PBI-67): bedraad IDEA_REVIEW_PLAN prompt + job-context - src/prompts/idea/review-plan.md: prompt voor IDEA_REVIEW_PLAN-jobs — iteratieve 3-ronden plan-review met convergentie-detectie - kind-prompts.ts: koppel IDEA_REVIEW_PLAN aan de prompt + getIdeaPromptText - wait-for-job.ts: getFullJobContext handelt IDEA_REVIEW_PLAN-jobs af Co-Authored-By: Claude Opus 4.7 (1M context) * feat(create_story): optionele sprint_id om story aan sprint te koppelen create_story accepteert nu een optionele sprint_id; bij meegeven wordt de story aangemaakt met status=IN_SPRINT (sprint moet bij hetzelfde product horen als de PBI). Handler geextraheerd naar handleCreateStory voor testbaarheid; nieuwe unit-tests in __tests__/create-story.test.ts. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(test): maak create-sprint auto-code test datum-onafhankelijk De test hardcodede 2026-05-11-datums maar berekende "today" dynamisch, waardoor hij alleen op die datum slaagde. Mock-codes nu relatief aan today. Co-Authored-By: Claude Opus 4.7 (1M context) * chore: bump version 0.7.0 -> 0.8.0 Co-Authored-By: Claude Opus 4.7 (1M context) * chore: bump vendor/scrum4me submodule naar app-main (7bb252c) De submodule stond 27 commits achter (3c77342, v1.0.0-147), waardoor sync-schema.sh prisma/schema.prisma terugzette naar een versie zonder IDEA_REVIEW_PLAN. Bumpt naar huidige app-main + re-synct het schema; enige inhoudelijke wijziging is het nieuwe User.settings-veld. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Madhura68 Co-authored-by: Claude Opus 4.7 (1M context) --- __tests__/create-sprint.test.ts | 10 +- __tests__/create-story.test.ts | 141 +++++++++++++++++ package.json | 2 +- prisma/schema.prisma | 40 +++-- src/index.ts | 2 + src/lib/job-config.ts | 13 ++ src/lib/kind-prompts.ts | 5 +- src/prompts/idea/review-plan.md | 210 +++++++++++++++++++++++++ src/tools/create-story.ts | 158 +++++++++++-------- src/tools/update-idea-plan-reviewed.ts | 116 ++++++++++++++ src/tools/wait-for-job.ts | 8 +- vendor/scrum4me | 2 +- 12 files changed, 619 insertions(+), 88 deletions(-) create mode 100644 __tests__/create-story.test.ts create mode 100644 src/prompts/idea/review-plan.md create mode 100644 src/tools/update-idea-plan-reviewed.ts diff --git a/__tests__/create-sprint.test.ts b/__tests__/create-sprint.test.ts index 72d400d..5837d6e 100644 --- a/__tests__/create-sprint.test.ts +++ b/__tests__/create-sprint.test.ts @@ -104,10 +104,13 @@ describe('handleCreateSprint', () => { }) it('auto-code increments past existing same-day sprints', async () => { + // Codes moeten relatief aan "vandaag" zijn: generateNextSprintCode telt + // alleen same-day sprints. Hardcoded datums maakten deze test datum-flaky. + const today = new Date().toISOString().slice(0, 10) mockPrisma.sprint.findMany.mockResolvedValue([ - { code: 'S-2026-05-11-1' }, - { code: 'S-2026-05-11-3' }, - { code: 'S-2026-05-10-7' }, + { code: `S-${today}-1` }, + { code: `S-${today}-3` }, + { code: 'S-2020-01-01-7' }, ]) mockPrisma.sprint.create.mockResolvedValue({ id: 'spr-3', code: 'X', sprint_goal: 'g', status: 'OPEN', start_date: new Date(), created_at: new Date(), @@ -115,7 +118,6 @@ describe('handleCreateSprint', () => { await handleCreateSprint({ product_id: PRODUCT_ID, sprint_goal: 'g' }) - const today = new Date().toISOString().slice(0, 10) expect(mockPrisma.sprint.create.mock.calls[0][0].data.code).toBe(`S-${today}-4`) }) diff --git a/__tests__/create-story.test.ts b/__tests__/create-story.test.ts new file mode 100644 index 0000000..2bf1222 --- /dev/null +++ b/__tests__/create-story.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../src/prisma.js', () => ({ + prisma: { + pbi: { findUnique: vi.fn() }, + sprint: { findUnique: vi.fn() }, + story: { + findFirst: vi.fn(), + findMany: vi.fn(), + create: vi.fn(), + }, + }, +})) + +vi.mock('../src/auth.js', () => ({ + requireWriteAccess: vi.fn(), + PermissionDeniedError: class PermissionDeniedError extends Error { + constructor(message = 'Demo accounts cannot perform write operations') { + super(message) + this.name = 'PermissionDeniedError' + } + }, +})) + +vi.mock('../src/access.js', () => ({ + userCanAccessProduct: vi.fn(), +})) + +import { prisma } from '../src/prisma.js' +import { requireWriteAccess } from '../src/auth.js' +import { userCanAccessProduct } from '../src/access.js' +import { handleCreateStory } from '../src/tools/create-story.js' + +const mockPrisma = prisma as unknown as { + pbi: { findUnique: ReturnType } + sprint: { findUnique: ReturnType } + story: { + findFirst: ReturnType + findMany: ReturnType + create: ReturnType + } +} +const mockRequireWriteAccess = requireWriteAccess as ReturnType +const mockUserCanAccessProduct = userCanAccessProduct as ReturnType + +const PRODUCT_ID = 'prod-1' +const PBI_ID = 'pbi-1' +const SPRINT_ID = 'spr-1' +const USER_ID = 'user-1' + +beforeEach(() => { + vi.clearAllMocks() + mockRequireWriteAccess.mockResolvedValue({ userId: USER_ID, tokenId: 'tok-1', username: 'alice', isDemo: false }) + mockUserCanAccessProduct.mockResolvedValue(true) + mockPrisma.pbi.findUnique.mockResolvedValue({ product_id: PRODUCT_ID }) + mockPrisma.story.findMany.mockResolvedValue([]) + mockPrisma.story.findFirst.mockResolvedValue(null) + mockPrisma.story.create.mockImplementation((args: { data: Record }) => + Promise.resolve({ id: 'story-1', created_at: new Date('2026-05-14T10:00:00Z'), ...args.data }), + ) +}) + +function parseResult(result: Awaited>) { + const text = result.content?.[0]?.type === 'text' ? result.content[0].text : '' + try { return JSON.parse(text) } catch { return text } +} + +function errorText(result: Awaited>): string { + return result.content?.[0]?.type === 'text' ? result.content[0].text : '' +} + +describe('handleCreateStory', () => { + it('without sprint_id: creates story with status OPEN and no sprint', async () => { + const result = await handleCreateStory({ pbi_id: PBI_ID, title: 'A story', priority: 2 }) + + expect(mockPrisma.sprint.findUnique).not.toHaveBeenCalled() + const data = mockPrisma.story.create.mock.calls[0][0].data + expect(data.status).toBe('OPEN') + expect(data.sprint_id).toBeNull() + expect(data.product_id).toBe(PRODUCT_ID) + expect(parseResult(result).status).toBe('OPEN') + }) + + it('with valid sprint_id: links story to sprint with status IN_SPRINT', async () => { + mockPrisma.sprint.findUnique.mockResolvedValue({ product_id: PRODUCT_ID }) + + const result = await handleCreateStory({ + pbi_id: PBI_ID, + title: 'A story', + priority: 2, + sprint_id: SPRINT_ID, + }) + + expect(mockPrisma.sprint.findUnique).toHaveBeenCalledWith({ + where: { id: SPRINT_ID }, + select: { product_id: true }, + }) + const data = mockPrisma.story.create.mock.calls[0][0].data + expect(data.status).toBe('IN_SPRINT') + expect(data.sprint_id).toBe(SPRINT_ID) + expect(parseResult(result).sprint_id).toBe(SPRINT_ID) + }) + + it('rejects a non-existent sprint_id', async () => { + mockPrisma.sprint.findUnique.mockResolvedValue(null) + + const result = await handleCreateStory({ + pbi_id: PBI_ID, + title: 'A story', + priority: 2, + sprint_id: 'missing', + }) + + expect(mockPrisma.story.create).not.toHaveBeenCalled() + expect(errorText(result)).toMatch(/Sprint missing not found/) + }) + + it('rejects a sprint from a different product', async () => { + mockPrisma.sprint.findUnique.mockResolvedValue({ product_id: 'other-product' }) + + const result = await handleCreateStory({ + pbi_id: PBI_ID, + title: 'A story', + priority: 2, + sprint_id: SPRINT_ID, + }) + + expect(mockPrisma.story.create).not.toHaveBeenCalled() + expect(errorText(result)).toMatch(/different product/) + }) + + it('returns error when PBI not found', async () => { + mockPrisma.pbi.findUnique.mockResolvedValue(null) + + const result = await handleCreateStory({ pbi_id: 'missing', title: 'A story', priority: 2 }) + + expect(mockPrisma.sprint.findUnique).not.toHaveBeenCalled() + expect(mockPrisma.story.create).not.toHaveBeenCalled() + expect(errorText(result)).toMatch(/PBI missing not found/) + }) +}) diff --git a/package.json b/package.json index de00265..0cbcf56 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "scrum4me-mcp", - "version": "0.7.0", + "version": "0.8.0", "description": "MCP server for Scrum4Me — exposes dev-flow tools and prompts via the Model Context Protocol", "type": "module", "bin": { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4f6b086..d854a58 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -100,6 +100,9 @@ enum IdeaStatus { PLANNING PLAN_FAILED PLAN_READY + REVIEWING_PLAN + PLAN_REVIEW_FAILED + PLAN_REVIEWED PLANNED } @@ -107,6 +110,7 @@ enum ClaudeJobKind { TASK_IMPLEMENTATION IDEA_GRILL IDEA_MAKE_PLAN + IDEA_REVIEW_PLAN PLAN_CHAT SPRINT_IMPLEMENTATION } @@ -124,6 +128,7 @@ enum IdeaLogType { NOTE GRILL_RESULT PLAN_RESULT + PLAN_REVIEW_RESULT STATUS_CHANGE JOB_EVENT } @@ -147,6 +152,7 @@ model User { active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull) idea_code_counter Int @default(0) min_quota_pct Int @default(20) + settings Json @default("{}") created_at DateTime @default(now()) updated_at DateTime @updatedAt roles UserRole[] @@ -510,22 +516,24 @@ model ProductMember { } model Idea { - id String @id @default(cuid()) - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) - user_id String - product Product? @relation(fields: [product_id], references: [id], onDelete: SetNull) - product_id String? - code String @db.VarChar(30) - title String - description String? @db.VarChar(4000) - grill_md String? @db.Text - plan_md String? @db.Text - pbi Pbi? @relation(fields: [pbi_id], references: [id], onDelete: SetNull) - pbi_id String? @unique - status IdeaStatus @default(DRAFT) - archived Boolean @default(false) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_id String + product Product? @relation(fields: [product_id], references: [id], onDelete: SetNull) + product_id String? + code String @db.VarChar(30) + title String + description String? @db.VarChar(4000) + grill_md String? @db.Text + plan_md String? @db.Text + plan_review_log Json? // ReviewLog from orchestrator (all rounds, convergence metrics, approval status) + reviewed_at DateTime? // When last reviewed + pbi Pbi? @relation(fields: [pbi_id], references: [id], onDelete: SetNull) + pbi_id String? @unique + status IdeaStatus @default(DRAFT) + archived Boolean @default(false) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt questions ClaudeQuestion[] jobs ClaudeJob[] diff --git a/src/index.ts b/src/index.ts index 06cefba..03f08d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,7 @@ import { registerMarkPbiPrMergedTool } from './tools/mark-pbi-pr-merged.js' import { registerGetIdeaContextTool } from './tools/get-idea-context.js' import { registerUpdateIdeaGrillMdTool } from './tools/update-idea-grill-md.js' import { registerUpdateIdeaPlanMdTool } from './tools/update-idea-plan-md.js' +import { registerUpdateIdeaPlanReviewedTool } from './tools/update-idea-plan-reviewed.js' import { registerLogIdeaDecisionTool } from './tools/log-idea-decision.js' import { registerGetWorkerSettingsTool } from './tools/get-worker-settings.js' import { registerWorkerHeartbeatTool } from './tools/worker-heartbeat.js' @@ -97,6 +98,7 @@ async function main() { registerGetIdeaContextTool(server) registerUpdateIdeaGrillMdTool(server) registerUpdateIdeaPlanMdTool(server) + registerUpdateIdeaPlanReviewedTool(server) registerLogIdeaDecisionTool(server) // M13: worker quota-gate tools registerGetWorkerSettingsTool(server) diff --git a/src/lib/job-config.ts b/src/lib/job-config.ts index 811e365..ef7270d 100644 --- a/src/lib/job-config.ts +++ b/src/lib/job-config.ts @@ -101,6 +101,19 @@ const KIND_DEFAULTS: Record = { 'mcp__scrum4me__update_job_status', ], }, + IDEA_REVIEW_PLAN: { + model: 'claude-opus-4-7', + thinking_budget: 6000, + permission_mode: 'acceptEdits', + max_turns: 1, + allowed_tools: [ + 'Read', 'Write', 'Grep', 'Glob', + 'mcp__scrum4me__update_idea_plan_reviewed', + 'mcp__scrum4me__log_idea_decision', + 'mcp__scrum4me__update_job_status', + 'mcp__scrum4me__ask_user_question', + ], + }, PLAN_CHAT: { model: 'claude-sonnet-4-6', thinking_budget: 6000, diff --git a/src/lib/kind-prompts.ts b/src/lib/kind-prompts.ts index f7e03c1..15a7a16 100644 --- a/src/lib/kind-prompts.ts +++ b/src/lib/kind-prompts.ts @@ -25,6 +25,7 @@ function loadPrompt(rel: string): string { const KIND_TO_PROMPT_PATH: Partial> = { IDEA_GRILL: 'idea/grill.md', IDEA_MAKE_PLAN: 'idea/make-plan.md', + IDEA_REVIEW_PLAN: 'idea/review-plan.md', TASK_IMPLEMENTATION: 'task/implementation.md', SPRINT_IMPLEMENTATION: 'sprint/implementation.md', PLAN_CHAT: 'plan-chat/chat.md', @@ -40,9 +41,9 @@ export function getKindPromptText(kind: ClaudeJobKind): string { } // Back-compat re-export. wait-for-job.ts roept getIdeaPromptText aan voor -// de twee idea-kinds; behouden zodat we de bestaande call-site niet hoeven +// de drie idea-kinds; behouden zodat we de bestaande call-site niet hoeven // te wijzigen tot een aparte cleanup-pass. export function getIdeaPromptText(kind: ClaudeJobKind): string { - if (kind !== 'IDEA_GRILL' && kind !== 'IDEA_MAKE_PLAN') return '' + if (kind !== 'IDEA_GRILL' && kind !== 'IDEA_MAKE_PLAN' && kind !== 'IDEA_REVIEW_PLAN') return '' return getKindPromptText(kind) } diff --git a/src/prompts/idea/review-plan.md b/src/prompts/idea/review-plan.md new file mode 100644 index 0000000..8df45f6 --- /dev/null +++ b/src/prompts/idea/review-plan.md @@ -0,0 +1,210 @@ +# Review-Plan-prompt voor IDEA_REVIEW_PLAN-jobs + +> Deze prompt wordt door `wait_for_job` meegestuurd in de payload van een +> `IDEA_REVIEW_PLAN`-job. Dit is een **iteratieve review met actieve plan-revisie** +> en convergence-detectie. Je coördineert drie review-rondes, herschrijft het plan +> na elke ronde, en slaat het review-log op via `update_idea_plan_reviewed`. + +--- + +Je bent een **plan-review-orchestrator** voor Scrum4Me-idee `{idea_code}`. + +Je context (meegegeven in `wait_for_job`-payload): + +- `idea.plan_md`: het te reviewen plan-document (YAML frontmatter + body) +- `idea.grill_md`: context uit de grill-fase (scope, acceptatie, risico's) +- `product`: gekoppeld product met `definition_of_done` en repo-context +- `repo_url`: lokale repo om bestaande patronen/code te raadplegen + +## Doel + +Drie iteratieve review-rondes uitvoeren, gericht op verschillende aspecten. Na +elke ronde herschrijf je het plan actief en sla je de herziene versie op in de +database. De reviews werken op convergentie af: zodra het plan stabiel is +(< 5% wijzigingen twee rondes achter elkaar), vraag je om goedkeuring. + +**Belangrijk:** het plan wordt bij elke ronde daadwerkelijk verbeterd en +gepersisteerd via `update_idea_plan_md`. Dit is geen passieve review — je +coördineert een actief verbeterproces. + +## Werkwijze + +### Setup (voor ronde 1) + +1. Lees `idea.plan_md` volledig — dit is de startversie van het plan. +2. Lees `idea.grill_md` voor scope/acceptatiecriteria-context. +3. **Laad codex** (verplicht, niet optioneel): + - Glob + Read alle `docs/patterns/**/*.md` → architectuurpatronen + - Glob + Read alle `docs/architecture/**/*.md` → systeemdesign + - Read `CLAUDE.md` → hardstop-regels (nooit schenden) + - Gebruik deze als leidraad bij elke review-ronde +4. Initialiseer `review_log`: + ```json + { "plan_file": "{idea_code}", "created_at": "", + "rounds": [], "approval": { "status": "pending" } } + ``` + +### Per Review-Ronde + +**Ronde 1 — Structuur & Syntax (Haiku-perspectief: snel en scherp)** +- Rol: structuur-reviewer — focus op correctheid, niet op inhoud +- Controleer: YAML parseable, alle verplichte velden aanwezig, geen lege strings, + priority-waarden valid (1–4), markdown-structuur intact +- Herschrijf plan_md: corrigeer structuurfouten en formatting +- *Opmerking multi-model:* directe Haiku API-call is momenteel niet beschikbaar + via job-config; voer deze rol zelf uit met een compacte, syntax-gerichte blik + +**Ronde 2 — Logica & Patronen (Sonnet-perspectief: diep en patroon-bewust)** +- Rol: architectuur-reviewer — focus op logica, volledigheid en patroonconformiteit +- Controleer: stories volgen uit grill-criteria, tasks zijn concreet + (bestandsnamen, commando's), patterns uit `docs/patterns/` worden gevolgd, + `verify_required` coherent, dependency-cascades geadresseerd +- Herschrijf plan_md: vul gaten aan, maak tasks specifieker, voeg missende stappen toe + +**Ronde 3 — Risico & Edge Cases (Opus-perspectief: kritisch en breed)** +- Rol: risico-reviewer — focus op wat mis kan gaan +- Controleer: grote taken gesplitst, refactors hebben undo-strategie, + schema-changes hebben migratie-taken, type-checking expliciet, concurrency + geadresseerd, error-handling per actie, feature-flags voor grote changes +- Herschrijf plan_md: voeg risico-mitigatie toe, split te grote taken + +### Plan Revision (na elke ronde — verplicht) + +Na het uitvoeren van de review-criteria: + +1. Sla de huidige versie op als `plan_before` in `review_log.rounds[N]`. +2. Herschrijf `plan_md` — integreer de gevonden verbeteringen. +3. Bereken `diff_pct = changed_lines / total_lines * 100`. +4. Sla de herziene versie op als `plan_after` in `review_log.rounds[N]`. +5. **Persisteer de herziene versie** via: + ``` + update_idea_plan_md({ idea_id: , plan_md: }) + ``` + Dit slaat het verbeterde plan op in de database zodat de gebruiker + de progressie ziet. Sla dit stap niet over — ook al zijn er weinig + wijzigingen. + +### Convergence Detection + +Na elke ronde (m.u.v. ronde 0): +``` +diff_pct_this_round = changed_lines / total_lines * 100 +if diff_pct_this_round < 5 AND prev_round_diff_pct < 5: + → CONVERGED +``` + +Indien converged (of na ronde 2 als max bereikt): +- Sla op: `review_log.convergence = { stable_at_round: N, final_diff_pct, convergence_metric: "plan_stability" }` +- Vraag goedkeuring via `ask_user_question` + +## Review-Criteria per Ronde + +### Ronde 1 — Structuur & Syntax +- [ ] Frontmatter YAML parseable +- [ ] Alle verplichte velden aanwezig (`pbi.title`, `stories`, `tasks`) +- [ ] Priority-waarden valid (1–4) +- [ ] Geen lege strings in verplichte velden +- [ ] Markdown-structuur correct (headers, code-blocks) + +### Ronde 2 — Logica & Patronen +- [ ] Stories volgen logisch uit grill-acceptance-criteria +- [ ] Tasks zijn concreet (bestandsnamen, commando's, niet abstract) +- [ ] Dependency-cascade-checks uitgevoerd (bij removal/refactor) +- [ ] Patronen uit `docs/patterns/` worden gevolgd +- [ ] Implementatie-plan per task is actionable +- [ ] `verify_required` waarden coherent met task-scope + +### Ronde 3 — Risico & Edge Cases +- [ ] Grote taken (> 4u) zijn gesplitst in subtaken +- [ ] Refactors hebben een undo/rollback-strategie +- [ ] Schema-changes hebben migratie-taken +- [ ] Type-checking wordt expliciet geverifieerd (einde-taak) +- [ ] Concurrency-issues / race-conditions geadresseerd +- [ ] Error-handling per actie duidelijk +- [ ] Feature-flags ingebouwd voor grote of riskante changes + +## Stappen (uitgebreid algoritme) + +1. **Init** + - Lees plan_md + grill_md. + - Laad codex (docs/patterns, docs/architecture, CLAUDE.md). + - Initialiseer `review_log`. + +2. **Loop: for round in [0, 1, 2]** + - Voer review uit (focus per ronde: structuur / logica / risico). + - Sla `plan_before` op. + - Herschrijf plan_md op basis van bevindingen. + - Roep `update_idea_plan_md` aan met de herziene tekst. + - Sla `plan_after` + `issues` + `score` + `diff_pct` op in review_log. + - Check convergence (na ronde 1+). + - Break indien converged. + +3. **Approval Gate** + - Vraag via `ask_user_question`: + "Plan beoordeeld ({N} rondes, {X}% eindwijziging). Goedkeuren?" + - Opties: `["Ja, accepteren", "Nee, aanpassingen gewenst", "Opnieuw reviewen"]` + - "Ja": `approval.status = 'approved'` → ga door naar Save & Close. + - "Nee": `approval.status = 'rejected'` → sluit af (user kan handmatig editen). + - "Opnieuw": max 2 extra rondes (rondes 3–4), dan dwingend approval vragen. + +4. **Save & Close** + - Call `update_idea_plan_reviewed({ idea_id, review_log, approval_status })`. + - Call `update_job_status({ job_id, status: 'done', summary: review_log.summary })`. + +## Output-format review_log (strikt JSON) + +```json +{ + "plan_file": "IDEA-016", + "created_at": "ISO8601", + "rounds": [ + { + "round": 0, + "model": "claude-opus-4-7", + "role": "Structure Review", + "focus": "YAML parsing, format, syntax", + "plan_before": "", + "plan_after": "", + "issues": [ + { + "category": "structure|logic|risk|pattern", + "severity": "error|warning|info", + "suggestion": "wat te fixen" + } + ], + "score": 75, + "plan_diff_lines": 12, + "converged": false, + "timestamp": "ISO8601" + } + ], + "convergence": { + "stable_at_round": 2, + "final_diff_pct": 2.1, + "convergence_metric": "plan_stability" + }, + "approval": { + "status": "pending|approved|rejected", + "timestamp": "ISO8601" + }, + "summary": "1–2 zinnen samenvatting: X rondes, Y% wijziging, status" +} +``` + +## Foutgevallen + +- **Plan parse-fout**: `update_job_status('failed', error: 'plan_parse_failed')` — stop. +- **update_idea_plan_md mislukt**: log error in review_log, ga door met review — niet fataal. +- **Gebruiker annuleert**: sluit netjes af; job wordt door server op CANCELLED gezet. +- **Vraag verloopt**: sla partial review-log op via `update_idea_plan_reviewed`, markeer als `rejected`. + +## Aannames & Limieten + +- **Multi-model:** directe Haiku/Sonnet API-calls zijn niet beschikbaar via de huidige + job-config architectuur. Alle rondes draaien op het geconfigureerde Opus model. + De rollen (structuur / logica / risico) worden wel strikt gescheiden gehouden. + Toekomst: directe model-switching via Anthropic API. +- Plan bevat geen versleutelde data (review-log opgeslagen als JSON in DB). +- Repo is leesbaar; geen network-fouts verwacht. +- Max 2 extra review-rondes buiten de initiële 3 (max 5 rondes totaal). +- Per ronde: max 10 issues gelogd (overige → samenvatting in `summary`). diff --git a/src/tools/create-story.ts b/src/tools/create-story.ts index cfa099e..37caa59 100644 --- a/src/tools/create-story.ts +++ b/src/tools/create-story.ts @@ -1,8 +1,9 @@ // MCP authoring tool: create een Story onder een bestaande PBI. // // product_id wordt afgeleid uit de PBI (denormalized FK conform CLAUDE.md -// convention — nooit vertrouwen op client-input). status='OPEN' default; -// landt in de Product Backlog, niet auto in een sprint. +// convention — nooit vertrouwen op client-input). Zonder sprint_id is +// status='OPEN' en landt de story in de Product Backlog; mét sprint_id +// wordt de story direct aan die sprint gekoppeld (status='IN_SPRINT'). import { z } from 'zod' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' @@ -46,75 +47,108 @@ const inputSchema = z.object({ acceptance_criteria: z.string().max(4000).optional(), priority: z.number().int().min(1).max(4), sort_order: z.number().optional(), + // Optionele sprint-koppeling: bij creatie de story direct aan een sprint + // hangen (status=IN_SPRINT). De sprint moet bij hetzelfde product horen. + sprint_id: z.string().min(1).optional(), }) +export async function handleCreateStory( + { + pbi_id, + title, + description, + acceptance_criteria, + priority, + sort_order, + sprint_id, + }: z.infer, +) { + return withToolErrors(async () => { + const auth = await requireWriteAccess() + + const pbi = await prisma.pbi.findUnique({ + where: { id: pbi_id }, + select: { product_id: true }, + }) + if (!pbi) return toolError(`PBI ${pbi_id} not found`) + if (!(await userCanAccessProduct(pbi.product_id, auth.userId))) { + return toolError(`PBI ${pbi_id} not accessible`) + } + + // Optionele sprint-koppeling: valideer dat de sprint bestaat én bij + // hetzelfde product hoort — voorkomt een cross-product koppeling. + if (sprint_id !== undefined) { + const sprint = await prisma.sprint.findUnique({ + where: { id: sprint_id }, + select: { product_id: true }, + }) + if (!sprint) return toolError(`Sprint ${sprint_id} not found`) + if (sprint.product_id !== pbi.product_id) { + return toolError( + `Sprint ${sprint_id} belongs to a different product than PBI ${pbi_id}`, + ) + } + } + + let resolvedSortOrder = sort_order + if (resolvedSortOrder === undefined) { + const last = await prisma.story.findFirst({ + where: { pbi_id, priority }, + orderBy: { sort_order: 'desc' }, + select: { sort_order: true }, + }) + resolvedSortOrder = (last?.sort_order ?? 0) + 1.0 + } + + let lastError: unknown + for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) { + const code = await generateNextStoryCode(pbi.product_id) + try { + const story = await prisma.story.create({ + data: { + pbi_id, + product_id: pbi.product_id, // denormalized uit DB-parent, niet uit input + sprint_id: sprint_id ?? null, + code, + title, + description: description ?? null, + acceptance_criteria: acceptance_criteria ?? null, + priority, + sort_order: resolvedSortOrder, + status: sprint_id ? 'IN_SPRINT' : 'OPEN', + }, + select: { + id: true, + code: true, + title: true, + description: true, + acceptance_criteria: true, + priority: true, + sort_order: true, + status: true, + sprint_id: true, + created_at: true, + }, + }) + return toolJson(story) + } catch (e) { + if (isCodeUniqueConflict(e)) { lastError = e; continue } + throw e + } + } + throw lastError ?? new Error('Kon geen unieke Story-code genereren') + }) +} + export function registerCreateStoryTool(server: McpServer) { server.registerTool( 'create_story', { title: 'Create story', description: - 'Add a story under an existing PBI. Status defaults to OPEN (lands in product backlog, not in a sprint). Sort_order auto-set to last+1 within the PBI/priority group if not provided. Forbidden for demo accounts.', + 'Add a story under an existing PBI. Optionally link it to a sprint via sprint_id — when given, the story is created with status=IN_SPRINT and the sprint must belong to the same product as the PBI; otherwise status=OPEN and the story lands in the product backlog. Sort_order auto-set to last+1 within the PBI/priority group if not provided. Forbidden for demo accounts.', inputSchema, }, - async ({ pbi_id, title, description, acceptance_criteria, priority, sort_order }) => - withToolErrors(async () => { - const auth = await requireWriteAccess() - - const pbi = await prisma.pbi.findUnique({ - where: { id: pbi_id }, - select: { product_id: true }, - }) - if (!pbi) return toolError(`PBI ${pbi_id} not found`) - if (!(await userCanAccessProduct(pbi.product_id, auth.userId))) { - return toolError(`PBI ${pbi_id} not accessible`) - } - - let resolvedSortOrder = sort_order - if (resolvedSortOrder === undefined) { - const last = await prisma.story.findFirst({ - where: { pbi_id, priority }, - orderBy: { sort_order: 'desc' }, - select: { sort_order: true }, - }) - resolvedSortOrder = (last?.sort_order ?? 0) + 1.0 - } - - let lastError: unknown - for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) { - const code = await generateNextStoryCode(pbi.product_id) - try { - const story = await prisma.story.create({ - data: { - pbi_id, - product_id: pbi.product_id, // denormalized uit DB-parent, niet uit input - code, - title, - description: description ?? null, - acceptance_criteria: acceptance_criteria ?? null, - priority, - sort_order: resolvedSortOrder, - status: 'OPEN', - }, - select: { - id: true, - code: true, - title: true, - description: true, - acceptance_criteria: true, - priority: true, - sort_order: true, - status: true, - created_at: true, - }, - }) - return toolJson(story) - } catch (e) { - if (isCodeUniqueConflict(e)) { lastError = e; continue } - throw e - } - } - throw lastError ?? new Error('Kon geen unieke Story-code genereren') - }), + handleCreateStory, ) } diff --git a/src/tools/update-idea-plan-reviewed.ts b/src/tools/update-idea-plan-reviewed.ts new file mode 100644 index 0000000..0217c22 --- /dev/null +++ b/src/tools/update-idea-plan-reviewed.ts @@ -0,0 +1,116 @@ +// MCP-tool: writes the review-log result after a IDEA_REVIEW_PLAN grill-job +// and transitions the idea.status to PLAN_REVIEWED (on success) or +// PLAN_REVIEW_FAILED (on failure). +// +// Called by the worker as the final step of a review-plan session. + +import { z } from 'zod' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' + +import { prisma } from '../prisma.js' +import { requireWriteAccess } from '../auth.js' +import { userOwnsIdea } from '../access.js' +import { toolError, toolJson, withToolErrors } from '../errors.js' + +const inputSchema = z.object({ + idea_id: z.string().min(1), + review_log: z.object({}).passthrough(), // Full ReviewLog from orchestrator (JSON object) + approval_status: z + .enum(['pending', 'approved', 'rejected'] as const) + .optional(), +}) + +export function registerUpdateIdeaPlanReviewedTool(server: McpServer) { + server.registerTool( + 'update_idea_plan_reviewed', + { + title: 'Mark plan as reviewed', + description: + 'Save review-log after plan review cycle and transition idea.status to PLAN_REVIEWED (if approved) or PLAN_REVIEW_FAILED (if rejected/pending requires manual approval). Forbidden for demo accounts.', + inputSchema, + }, + async ({ idea_id, review_log, approval_status }) => + withToolErrors(async () => { + const auth = await requireWriteAccess() + if (!(await userOwnsIdea(idea_id, auth.userId))) { + return toolError('Idea not found') + } + + // Determine target status based on approval + const nextStatus = + approval_status === 'approved' + ? 'PLAN_REVIEWED' + : approval_status === 'rejected' + ? 'PLAN_REVIEW_FAILED' + : 'PLAN_REVIEWED' // Default to approved if not specified + + // Log summary metrics from review_log + const logSummary = buildReviewLogSummary(review_log) + + const result = await prisma.$transaction([ + prisma.idea.update({ + where: { id: idea_id }, + data: { + plan_review_log: review_log as any, + reviewed_at: new Date(), + status: nextStatus, + }, + select: { id: true, status: true, code: true }, + }), + prisma.ideaLog.create({ + data: { + idea_id, + type: 'PLAN_REVIEW_RESULT', + content: logSummary.summary, + metadata: { + approval_status, + convergence_status: logSummary.convergence_status, + final_score: logSummary.final_score, + rounds_completed: logSummary.rounds_completed, + }, + }, + }), + ]) + + return toolJson({ + ok: true, + idea: result[0], + review_log_summary: logSummary, + }) + }), + ) +} + +function buildReviewLogSummary( + reviewLog: Record, +): { + summary: string + convergence_status: string + final_score: number + rounds_completed: number +} { + const rounds = Array.isArray(reviewLog.rounds) ? reviewLog.rounds : [] + const convergence = reviewLog.convergence || {} + const finalScore = + rounds.length > 0 ? rounds[rounds.length - 1].score ?? 0 : 0 + + const convergenceStatus = + convergence.stable_at_round !== undefined + ? `stable at round ${convergence.stable_at_round}` + : convergence.final_diff_pct !== undefined + ? `${convergence.final_diff_pct}% diff` + : 'pending' + + const summary = + `Plan reviewed in ${rounds.length} rounds. ` + + `Convergence: ${convergenceStatus}. ` + + `Final score: ${finalScore}/100. ` + + `Status: ${reviewLog.approval?.status || 'pending'}.` + + return { + summary, + convergence_status: convergenceStatus, + final_score: finalScore, + rounds_completed: rounds.length, + } +} diff --git a/src/tools/wait-for-job.ts b/src/tools/wait-for-job.ts index 96c11ba..f3e11c0 100644 --- a/src/tools/wait-for-job.ts +++ b/src/tools/wait-for-job.ts @@ -508,7 +508,7 @@ export async function getFullJobContext(jobId: string) { // M12: branch on kind. Idea-jobs hebben geen task/story/pbi/sprint; ze // hebben in plaats daarvan idea + embedded prompt_text. - if (job.kind === 'IDEA_GRILL' || job.kind === 'IDEA_MAKE_PLAN') { + if (job.kind === 'IDEA_GRILL' || job.kind === 'IDEA_MAKE_PLAN' || job.kind === 'IDEA_REVIEW_PLAN') { if (!job.idea) return null const { idea } = job const { getIdeaPromptText } = await import('../lib/kind-prompts.js') @@ -569,7 +569,11 @@ export async function getFullJobContext(jobId: string) { pbi: idea.pbi, repo_url: job.product.repo_url, prompt_text: getIdeaPromptText(job.kind), - branch_suggestion: `feat/idea-${idea.code.toLowerCase()}-${job.kind === 'IDEA_GRILL' ? 'grill' : 'plan'}`, + branch_suggestion: `feat/idea-${idea.code.toLowerCase()}-${(() => { + if (job.kind === 'IDEA_GRILL') return 'grill' + if (job.kind === 'IDEA_REVIEW_PLAN') return 'review' + return 'plan' + })()}`, product_worktrees: worktrees.map((w) => ({ product_id: w.productId, worktree_path: w.worktreePath, diff --git a/vendor/scrum4me b/vendor/scrum4me index 3c77342..7bb252c 160000 --- a/vendor/scrum4me +++ b/vendor/scrum4me @@ -1 +1 @@ -Subproject commit 3c773421dacaf506bf35a8270249822cf509ccf3 +Subproject commit 7bb252c528d810584bcb46a56cff3d26ebf392ff From 84c194d4e52840379361f0c23e5d8bc88e612206 Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Thu, 14 May 2026 19:16:15 +0200 Subject: [PATCH 25/27] fix(cross-repo): per-repo worktree-branch + PR resolutie (IDEA-062) (#49) Cross-repo sprints (sprint-product = repo X, maar een taak heeft task.repo_url naar repo Y) faalden op twee plekken omdat sprint-brede beslissingen werden toegepast op per-repo git-state. 1. createWorktreeForJob (src/git/worktree.ts) reuseBranch wordt sprint-breed bepaald in wait-for-job.ts. De eerste job die repo Y target krijgt reuseBranch=true terwijl de branch daar nooit is aangemaakt -> `git worktree add ` faalt met "invalid reference" -> job vast, worker UNHEALTHY. Idem na een container-recreate (clone is dan vers). Fix: 3-weg fallback in het reuseBranch-pad: - lokale branch bestaat -> hergebruik - alleen op origin -> recreate lokaal vanaf origin/ - nergens -> fresh vanaf baseRef Lost ook het container-recreate-verlies op. 2. maybeCreateAutoPr (src/tools/update-job-status.ts) De sprint/story sibling-lookup voor pr_url-hergebruik filterde niet op repo. Een repo-Y-job erfde de pr_url van een repo-X-sibling -> job.pr_url wees naar de verkeerde repo en er werd nooit een PR voor de repo-Y-branch aangemaakt (branch wel gepusht, maar PR-loos). Fix: siblings groeperen per repo-bucket ((task.repo_url ?? null)); alleen een sibling uit dezelfde bucket levert een herbruikbare pr_url. Geldt voor SPRINT- en STORY-mode. createPullRequest zelf was al repo-correct (gh pr create draait in de worktree). Tests: 3 nieuwe in worktree.test.ts (reuse-local / recreate-from-origin / fresh-fallback), 2 nieuwe in update-job-status-auto-pr.test.ts (cross-repo story + sprint). update-job-status-mock omgezet naar findMany. Alle 373 tests groen, build groen. package-lock.json: version 0.7.0 -> 0.8.0 (was niet mee-gesynced in de v0.8.0-bump commit 55fa133). Co-authored-by: Claude Opus 4.7 (1M context) --- __tests__/git/worktree.test.ts | 65 +++++++++++++++++++++ __tests__/update-job-status-auto-pr.test.ts | 51 ++++++++++++++-- package-lock.json | 4 +- src/git/worktree.ts | 35 ++++++++++- src/tools/update-job-status.ts | 31 +++++++--- 5 files changed, 171 insertions(+), 15 deletions(-) diff --git a/__tests__/git/worktree.test.ts b/__tests__/git/worktree.test.ts index 68f5e19..68cedfd 100644 --- a/__tests__/git/worktree.test.ts +++ b/__tests__/git/worktree.test.ts @@ -113,6 +113,71 @@ describe('createWorktreeForJob', () => { }), ).rejects.toThrow('Worktree path already exists') }) + + it('reuseBranch: reuses an existing local branch', async () => { + const { repoDir, originDir } = await setupRepo() + tmpDirs.push(repoDir, originDir) + await makeWorktreeParent() + + // Sibling already created the branch locally. + await git(['branch', 'feat/sprint-abc', 'origin/main'], repoDir) + + const result = await createWorktreeForJob({ + repoRoot: repoDir, + jobId: 'job-reuse-local', + branchName: 'feat/sprint-abc', + baseRef: 'origin/main', + reuseBranch: true, + }) + + const { stdout } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], result.worktreePath) + expect(stdout.trim()).toBe('feat/sprint-abc') + expect(result.branchName).toBe('feat/sprint-abc') + }) + + it('reuseBranch: recreates a local branch from origin when only the remote has it', async () => { + const { repoDir, originDir } = await setupRepo() + tmpDirs.push(repoDir, originDir) + await makeWorktreeParent() + + // Branch exists on origin (a sibling pushed it, or the container was + // recreated and the local clone is fresh) but not as a local branch. + await git(['branch', 'feat/sprint-xyz', 'origin/main'], repoDir) + await git(['push', 'origin', 'feat/sprint-xyz'], repoDir) + await git(['branch', '-D', 'feat/sprint-xyz'], repoDir) + + const result = await createWorktreeForJob({ + repoRoot: repoDir, + jobId: 'job-reuse-origin', + branchName: 'feat/sprint-xyz', + baseRef: 'origin/main', + reuseBranch: true, + }) + + const { stdout } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], result.worktreePath) + expect(stdout.trim()).toBe('feat/sprint-xyz') + }) + + it('reuseBranch: falls back to a fresh branch when it exists nowhere (cross-repo sprint)', async () => { + const { repoDir, originDir } = await setupRepo() + tmpDirs.push(repoDir, originDir) + await makeWorktreeParent() + + // reuseBranch is decided sprint-wide; for the first job targeting THIS + // repo the branch exists neither locally nor on origin. Must not throw + // "invalid reference" — should create it fresh from baseRef. + const result = await createWorktreeForJob({ + repoRoot: repoDir, + jobId: 'job-reuse-fresh', + branchName: 'feat/sprint-newrepo', + baseRef: 'origin/main', + reuseBranch: true, + }) + + const { stdout } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], result.worktreePath) + expect(stdout.trim()).toBe('feat/sprint-newrepo') + expect(result.branchName).toBe('feat/sprint-newrepo') + }) }) describe('removeWorktreeForJob', () => { diff --git a/__tests__/update-job-status-auto-pr.test.ts b/__tests__/update-job-status-auto-pr.test.ts index 3218b3e..e92fdb3 100644 --- a/__tests__/update-job-status-auto-pr.test.ts +++ b/__tests__/update-job-status-auto-pr.test.ts @@ -4,7 +4,7 @@ vi.mock('../src/prisma.js', () => ({ prisma: { product: { findUnique: vi.fn() }, task: { findUnique: vi.fn() }, - claudeJob: { findFirst: vi.fn(), findUnique: vi.fn() }, + claudeJob: { findFirst: vi.fn(), findMany: vi.fn(), findUnique: vi.fn() }, }, })) @@ -22,6 +22,7 @@ const mockPrisma = prisma as unknown as { task: { findUnique: ReturnType } claudeJob: { findFirst: ReturnType + findMany: ReturnType findUnique: ReturnType } } @@ -41,9 +42,10 @@ beforeEach(() => { mockPrisma.product.findUnique.mockResolvedValue({ auto_pr: true }) mockPrisma.task.findUnique.mockResolvedValue({ title: 'Add feature', + repo_url: null, story: { id: 'story-1', code: 'SCRUM-42', title: 'Story title' }, }) - mockPrisma.claudeJob.findFirst.mockResolvedValue(null) // no sibling PR by default + mockPrisma.claudeJob.findMany.mockResolvedValue([]) // no sibling PRs by default // Default: legacy job zonder sprint_run (STORY-mode pad). mockPrisma.claudeJob.findUnique.mockResolvedValue({ sprint_run_id: null, sprint_run: null }) mockCreatePr.mockResolvedValue({ url: 'https://github.com/org/repo/pull/99' }) @@ -62,12 +64,27 @@ describe('maybeCreateAutoPr', () => { }) it('reuses sibling pr_url when another job in same story already opened a PR', async () => { - mockPrisma.claudeJob.findFirst.mockResolvedValue({ pr_url: 'https://github.com/org/repo/pull/77' }) + mockPrisma.claudeJob.findMany.mockResolvedValue([ + { pr_url: 'https://github.com/org/repo/pull/77', task: { repo_url: null } }, + ]) const url = await maybeCreateAutoPr(BASE_OPTS) expect(url).toBe('https://github.com/org/repo/pull/77') expect(mockCreatePr).not.toHaveBeenCalled() }) + it('does NOT reuse a sibling PR from a different repo (cross-repo story)', async () => { + // Sibling targeted another repo via task.repo_url — its PR must not leak in. + mockPrisma.claudeJob.findMany.mockResolvedValue([ + { + pr_url: 'https://github.com/org/other-repo/pull/12', + task: { repo_url: 'https://github.com/org/other-repo' }, + }, + ]) + const url = await maybeCreateAutoPr(BASE_OPTS) + expect(url).toBe('https://github.com/org/repo/pull/99') // fresh PR, not the sibling's + expect(mockCreatePr).toHaveBeenCalledOnce() + }) + it('returns null when auto_pr=false', async () => { mockPrisma.product.findUnique.mockResolvedValue({ auto_pr: false }) const url = await maybeCreateAutoPr(BASE_OPTS) @@ -78,6 +95,7 @@ describe('maybeCreateAutoPr', () => { it('uses story title without code prefix when story has no code', async () => { mockPrisma.task.findUnique.mockResolvedValue({ title: 'Add feature', + repo_url: null, story: { id: 'story-1', code: null, title: 'Story title' }, }) await maybeCreateAutoPr(BASE_OPTS) @@ -113,7 +131,9 @@ describe('maybeCreateAutoPr', () => { sprint_run_id: 'run-1', sprint_run: { id: 'run-1', pr_strategy: 'SPRINT', sprint: { sprint_goal: 'Goal' } }, }) - mockPrisma.claudeJob.findFirst.mockResolvedValue({ pr_url: 'https://github.com/org/repo/pull/55' }) + mockPrisma.claudeJob.findMany.mockResolvedValue([ + { pr_url: 'https://github.com/org/repo/pull/55', task: { repo_url: null } }, + ]) const url = await maybeCreateAutoPr(BASE_OPTS) @@ -121,6 +141,29 @@ describe('maybeCreateAutoPr', () => { expect(mockCreatePr).not.toHaveBeenCalled() }) + it('SPRINT-mode: cross-repo — sibling-PR van ander repo wordt niet hergebruikt', async () => { + mockPrisma.claudeJob.findUnique.mockResolvedValue({ + sprint_run_id: 'run-1', + sprint_run: { id: 'run-1', pr_strategy: 'SPRINT', sprint: { sprint_goal: 'Goal' } }, + }) + // Deze job target een ander repo via task.repo_url. + mockPrisma.task.findUnique.mockResolvedValue({ + title: 'MCP-taak', + repo_url: 'https://github.com/org/scrum4me-mcp', + story: { id: 'story-1', code: 'SCRUM-9', title: 'Story title' }, + }) + // Sibling met pr_url hoort bij het product-repo (repo_url null) → andere bucket. + mockPrisma.claudeJob.findMany.mockResolvedValue([ + { pr_url: 'https://github.com/org/repo/pull/201', task: { repo_url: null } }, + ]) + + const url = await maybeCreateAutoPr(BASE_OPTS) + + // Geen hergebruik van de product-repo PR → eigen draft-PR voor het mcp-repo. + expect(url).toBe('https://github.com/org/repo/pull/99') + expect(mockCreatePr).toHaveBeenCalledOnce() + }) + it('returns null and does not throw when gh fails', async () => { mockCreatePr.mockResolvedValue({ error: 'gh CLI not found' }) const url = await maybeCreateAutoPr(BASE_OPTS) diff --git a/package-lock.json b/package-lock.json index 61bcb4a..3514598 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "scrum4me-mcp", - "version": "0.7.0", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "scrum4me-mcp", - "version": "0.7.0", + "version": "0.8.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/src/git/worktree.ts b/src/git/worktree.ts index 4d03443..a27aca6 100644 --- a/src/git/worktree.ts +++ b/src/git/worktree.ts @@ -15,6 +15,19 @@ async function branchExists(repoRoot: string, name: string): Promise { } } +async function remoteBranchExists(repoRoot: string, name: string): Promise { + try { + await exec( + 'git', + ['show-ref', '--verify', '--quiet', `refs/remotes/origin/${name}`], + { cwd: repoRoot }, + ) + return true + } catch { + return false + } +} + async function findWorktreeForBranch( repoRoot: string, branchName: string, @@ -75,7 +88,27 @@ export async function createWorktreeForJob(opts: { if (occupant) { await exec('git', ['worktree', 'remove', '--force', occupant], { cwd: repoRoot }) } - await exec('git', ['worktree', 'add', worktreePath, branchName], { cwd: repoRoot }) + // reuseBranch is decided sprint-wide, but git branches are per-repo. For a + // cross-repo sprint the first job targeting THIS repo gets reuseBranch=true + // even though the branch was never created here; a container recreate also + // wipes the local clone. Fall back gracefully instead of failing with + // "invalid reference": + // - local branch exists → reuse it + // - exists on origin only → recreate the local branch tracking origin + // - nowhere → create it fresh from baseRef + if (await branchExists(repoRoot, branchName)) { + await exec('git', ['worktree', 'add', worktreePath, branchName], { cwd: repoRoot }) + } else if (await remoteBranchExists(repoRoot, branchName)) { + await exec( + 'git', + ['worktree', 'add', '-b', branchName, worktreePath, `origin/${branchName}`], + { cwd: repoRoot }, + ) + } else { + await exec('git', ['worktree', 'add', '-b', branchName, worktreePath, baseRef], { + cwd: repoRoot, + }) + } return { worktreePath, branchName } } diff --git a/src/tools/update-job-status.ts b/src/tools/update-job-status.ts index 5e40988..e7a9495 100644 --- a/src/tools/update-job-status.ts +++ b/src/tools/update-job-status.ts @@ -420,24 +420,35 @@ export async function maybeCreateAutoPr(opts: { where: { id: taskId }, select: { title: true, + repo_url: true, story: { select: { id: true, code: true, title: true } }, }, }) if (!task) return null - // PBI-46 SPRINT-mode: hergebruik 1 draft-PR voor de hele SprintRun. + // Cross-repo sprints: een sprint kan taken hebben die via task.repo_url een + // ander repo targeten. PRs en branches zijn per-repo, dus een sibling-PR mag + // alleen hergebruikt worden als die sibling hetzelfde repo targette. null/leeg + // repo_url = het product-repo; twee taken zitten in dezelfde repo-bucket als + // hun (repo_url ?? null) gelijk is. + const thisRepoKey = task.repo_url ?? null + + // PBI-46 SPRINT-mode: hergebruik 1 draft-PR voor de hele SprintRun (per repo). // Mens zet 'm ready-for-review zodra de SprintRun DONE is. if (job?.sprint_run && job.sprint_run.pr_strategy === 'SPRINT') { - const sprintSibling = await prisma.claudeJob.findFirst({ + const sprintSiblings = await prisma.claudeJob.findMany({ where: { sprint_run_id: job.sprint_run_id, pr_url: { not: null }, id: { not: jobId }, }, - select: { pr_url: true }, + select: { pr_url: true, task: { select: { repo_url: true } } }, orderBy: { created_at: 'asc' }, }) - if (sprintSibling?.pr_url) return sprintSibling.pr_url + const sameRepoSibling = sprintSiblings.find( + (s) => (s.task?.repo_url ?? null) === thisRepoKey, + ) + if (sameRepoSibling?.pr_url) return sameRepoSibling.pr_url // Eerste DONE in deze SprintRun → maak draft-PR aan, geen auto-merge. const goal = job.sprint_run.sprint.sprint_goal @@ -459,17 +470,21 @@ export async function maybeCreateAutoPr(opts: { return null } - // STORY-mode (default of legacy): branch-per-story, sibling-tasks delen PR. - const sibling = await prisma.claudeJob.findFirst({ + // STORY-mode (default of legacy): branch-per-story, sibling-tasks delen PR + // — maar alleen siblings die hetzelfde repo targeten (zie thisRepoKey). + const storySiblings = await prisma.claudeJob.findMany({ where: { task: { story_id: task.story.id }, pr_url: { not: null }, id: { not: jobId }, }, - select: { pr_url: true }, + select: { pr_url: true, task: { select: { repo_url: true } } }, orderBy: { created_at: 'asc' }, }) - if (sibling?.pr_url) return sibling.pr_url + const sameRepoStorySibling = storySiblings.find( + (s) => (s.task?.repo_url ?? null) === thisRepoKey, + ) + if (sameRepoStorySibling?.pr_url) return sameRepoStorySibling.pr_url const storyTitle = task.story.code ? `${task.story.code}: ${task.story.title}` : task.story.title const body = summary From 51fc65e71548ede77435c590b7c7df6ea2434c45 Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Thu, 14 May 2026 19:46:31 +0200 Subject: [PATCH 26/27] fix(update_idea_plan_reviewed): nooit stilzwijgend goedkeuren (IDEA-066) (#50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De status-logica sprak z'n eigen tool-beschrijving tegen. De code deed: approved -> PLAN_REVIEWED rejected -> PLAN_REVIEW_FAILED else -> PLAN_REVIEWED // "Default to approved if not specified" Een review die 'pending' (needs manual approval) of helemaal geen approval_status teruggaf, markeerde het idee dus als PLAN_REVIEWED (goedgekeurd) — precies omgekeerd aan wat de beschrijving belooft. Fix: alleen een expliciete approval_status='approved' brengt het idee naar PLAN_REVIEWED; 'rejected', 'pending' én een weggelaten approval_status gaan allemaal naar PLAN_REVIEW_FAILED (mens beslist). Nooit stilzwijgend goedkeuren. Verder: - Handler geextraheerd naar handleUpdateIdeaPlanReviewed + inputSchema geexporteerd, conform het create-sprint/update-sprint-patroon, zodat de logica zonder McpServer-wrapper testbaar is. - Tool-beschrijving + header-comment aangescherpt zodat code en docs niet meer divergeren. - Nieuw test-bestand: 6 tests (approved/rejected/pending/omitted status-transitie, not-found, log-persistentie). Build groen, 379 tests groen. Co-authored-by: Claude Opus 4.7 (1M context) --- __tests__/update-idea-plan-reviewed.test.ts | 140 ++++++++++++++++++++ src/tools/update-idea-plan-reviewed.ts | 118 +++++++++-------- 2 files changed, 204 insertions(+), 54 deletions(-) create mode 100644 __tests__/update-idea-plan-reviewed.test.ts diff --git a/__tests__/update-idea-plan-reviewed.test.ts b/__tests__/update-idea-plan-reviewed.test.ts new file mode 100644 index 0000000..257fce4 --- /dev/null +++ b/__tests__/update-idea-plan-reviewed.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../src/prisma.js', () => ({ + prisma: { + idea: { update: vi.fn() }, + ideaLog: { create: vi.fn() }, + $transaction: vi.fn(), + }, +})) + +vi.mock('../src/auth.js', () => ({ + requireWriteAccess: vi.fn(), + PermissionDeniedError: class PermissionDeniedError extends Error { + constructor(message = 'Demo accounts cannot perform write operations') { + super(message) + this.name = 'PermissionDeniedError' + } + }, +})) + +vi.mock('../src/access.js', () => ({ + userOwnsIdea: vi.fn(), +})) + +import { prisma } from '../src/prisma.js' +import { requireWriteAccess } from '../src/auth.js' +import { userOwnsIdea } from '../src/access.js' +import { handleUpdateIdeaPlanReviewed } from '../src/tools/update-idea-plan-reviewed.js' + +const mockPrisma = prisma as unknown as { + idea: { update: ReturnType } + ideaLog: { create: ReturnType } + $transaction: ReturnType +} +const mockRequireWriteAccess = requireWriteAccess as ReturnType +const mockUserOwnsIdea = userOwnsIdea as ReturnType + +const IDEA_ID = 'idea-1' +const USER_ID = 'user-1' +const REVIEW_LOG = { + rounds: [{ score: 88 }], + convergence: { stable_at_round: 2 }, + approval: { status: 'approved' }, +} + +beforeEach(() => { + vi.clearAllMocks() + mockRequireWriteAccess.mockResolvedValue({ + userId: USER_ID, + tokenId: 'tok-1', + username: 'alice', + isDemo: false, + }) + mockUserOwnsIdea.mockResolvedValue(true) + // $transaction returns the array of its two operations' results; the handler + // only reads result[0] (the idea.update result). + mockPrisma.$transaction.mockImplementation(async () => [ + { id: IDEA_ID, status: 'PLACEHOLDER', code: 'IDEA-1' }, + {}, + ]) +}) + +function parseResult(result: Awaited>) { + const text = result.content?.[0]?.type === 'text' ? result.content[0].text : '' + try { + return JSON.parse(text) + } catch { + return text + } +} + +// The handler builds `data.status` inside the idea.update call passed to +// $transaction. We capture it by inspecting the prisma.idea.update mock args. +function statusPassedToUpdate(): string | undefined { + const call = mockPrisma.idea.update.mock.calls[0] + return call?.[0]?.data?.status +} + +describe('handleUpdateIdeaPlanReviewed — status transition', () => { + it('approval_status="approved" → PLAN_REVIEWED', async () => { + await handleUpdateIdeaPlanReviewed({ + idea_id: IDEA_ID, + review_log: REVIEW_LOG, + approval_status: 'approved', + }) + expect(statusPassedToUpdate()).toBe('PLAN_REVIEWED') + }) + + it('approval_status="rejected" → PLAN_REVIEW_FAILED', async () => { + await handleUpdateIdeaPlanReviewed({ + idea_id: IDEA_ID, + review_log: REVIEW_LOG, + approval_status: 'rejected', + }) + expect(statusPassedToUpdate()).toBe('PLAN_REVIEW_FAILED') + }) + + it('approval_status="pending" → PLAN_REVIEW_FAILED (needs manual approval, never silently approved)', async () => { + await handleUpdateIdeaPlanReviewed({ + idea_id: IDEA_ID, + review_log: REVIEW_LOG, + approval_status: 'pending', + }) + expect(statusPassedToUpdate()).toBe('PLAN_REVIEW_FAILED') + }) + + it('omitted approval_status → PLAN_REVIEW_FAILED (safe default, not PLAN_REVIEWED)', async () => { + await handleUpdateIdeaPlanReviewed({ + idea_id: IDEA_ID, + review_log: REVIEW_LOG, + }) + expect(statusPassedToUpdate()).toBe('PLAN_REVIEW_FAILED') + }) + + it('returns "Idea not found" when the user does not own the idea', async () => { + mockUserOwnsIdea.mockResolvedValue(false) + const result = await handleUpdateIdeaPlanReviewed({ + idea_id: IDEA_ID, + review_log: REVIEW_LOG, + approval_status: 'approved', + }) + expect(parseResult(result)).toContain('Idea not found') + expect(mockPrisma.idea.update).not.toHaveBeenCalled() + }) + + it('persists review_log + reviewed_at and logs a PLAN_REVIEW_RESULT entry', async () => { + await handleUpdateIdeaPlanReviewed({ + idea_id: IDEA_ID, + review_log: REVIEW_LOG, + approval_status: 'approved', + }) + const updateArg = mockPrisma.idea.update.mock.calls[0]?.[0] + expect(updateArg?.data?.plan_review_log).toEqual(REVIEW_LOG) + expect(updateArg?.data?.reviewed_at).toBeInstanceOf(Date) + + const logArg = mockPrisma.ideaLog.create.mock.calls[0]?.[0] + expect(logArg?.data?.type).toBe('PLAN_REVIEW_RESULT') + expect(logArg?.data?.idea_id).toBe(IDEA_ID) + }) +}) diff --git a/src/tools/update-idea-plan-reviewed.ts b/src/tools/update-idea-plan-reviewed.ts index 0217c22..2e9f1ac 100644 --- a/src/tools/update-idea-plan-reviewed.ts +++ b/src/tools/update-idea-plan-reviewed.ts @@ -1,6 +1,8 @@ -// MCP-tool: writes the review-log result after a IDEA_REVIEW_PLAN grill-job -// and transitions the idea.status to PLAN_REVIEWED (on success) or -// PLAN_REVIEW_FAILED (on failure). +// MCP-tool: writes the review-log result after an IDEA_REVIEW_PLAN job and +// transitions idea.status. Only an explicit approval_status='approved' moves +// the idea to PLAN_REVIEWED; anything else (rejected, pending, or omitted) +// goes to PLAN_REVIEW_FAILED — a human must then decide. The tool never +// silently approves. // // Called by the worker as the final step of a review-plan session. @@ -12,7 +14,7 @@ import { requireWriteAccess } from '../auth.js' import { userOwnsIdea } from '../access.js' import { toolError, toolJson, withToolErrors } from '../errors.js' -const inputSchema = z.object({ +export const inputSchema = z.object({ idea_id: z.string().min(1), review_log: z.object({}).passthrough(), // Full ReviewLog from orchestrator (JSON object) approval_status: z @@ -20,64 +22,72 @@ const inputSchema = z.object({ .optional(), }) +export async function handleUpdateIdeaPlanReviewed( + { idea_id, review_log, approval_status }: z.infer, +) { + return withToolErrors(async () => { + const auth = await requireWriteAccess() + if (!(await userOwnsIdea(idea_id, auth.userId))) { + return toolError('Idea not found') + } + + // Alleen een expliciete 'approved' brengt het idee naar PLAN_REVIEWED. + // 'rejected', 'pending' én een weggelaten approval_status betekenen + // allemaal "niet auto-goedgekeurd — mens moet beslissen" en gaan naar + // PLAN_REVIEW_FAILED. Nooit stilzwijgend goedkeuren (de vorige + // `: 'PLAN_REVIEWED'`-default deed dat wel bij pending/undefined). + const nextStatus = + approval_status === 'approved' ? 'PLAN_REVIEWED' : 'PLAN_REVIEW_FAILED' + + // Log summary metrics from review_log + const logSummary = buildReviewLogSummary(review_log) + + const result = await prisma.$transaction([ + prisma.idea.update({ + where: { id: idea_id }, + data: { + plan_review_log: review_log as any, + reviewed_at: new Date(), + status: nextStatus, + }, + select: { id: true, status: true, code: true }, + }), + prisma.ideaLog.create({ + data: { + idea_id, + type: 'PLAN_REVIEW_RESULT', + content: logSummary.summary, + metadata: { + approval_status, + convergence_status: logSummary.convergence_status, + final_score: logSummary.final_score, + rounds_completed: logSummary.rounds_completed, + }, + }, + }), + ]) + + return toolJson({ + ok: true, + idea: result[0], + review_log_summary: logSummary, + }) + }) +} + export function registerUpdateIdeaPlanReviewedTool(server: McpServer) { server.registerTool( 'update_idea_plan_reviewed', { title: 'Mark plan as reviewed', description: - 'Save review-log after plan review cycle and transition idea.status to PLAN_REVIEWED (if approved) or PLAN_REVIEW_FAILED (if rejected/pending requires manual approval). Forbidden for demo accounts.', + 'Save review-log after a plan review cycle and transition idea.status. ' + + 'Only approval_status="approved" → PLAN_REVIEWED; "rejected", "pending", ' + + 'or an omitted approval_status → PLAN_REVIEW_FAILED (needs manual ' + + 'approval — never silently approved). Forbidden for demo accounts.', inputSchema, }, - async ({ idea_id, review_log, approval_status }) => - withToolErrors(async () => { - const auth = await requireWriteAccess() - if (!(await userOwnsIdea(idea_id, auth.userId))) { - return toolError('Idea not found') - } - - // Determine target status based on approval - const nextStatus = - approval_status === 'approved' - ? 'PLAN_REVIEWED' - : approval_status === 'rejected' - ? 'PLAN_REVIEW_FAILED' - : 'PLAN_REVIEWED' // Default to approved if not specified - - // Log summary metrics from review_log - const logSummary = buildReviewLogSummary(review_log) - - const result = await prisma.$transaction([ - prisma.idea.update({ - where: { id: idea_id }, - data: { - plan_review_log: review_log as any, - reviewed_at: new Date(), - status: nextStatus, - }, - select: { id: true, status: true, code: true }, - }), - prisma.ideaLog.create({ - data: { - idea_id, - type: 'PLAN_REVIEW_RESULT', - content: logSummary.summary, - metadata: { - approval_status, - convergence_status: logSummary.convergence_status, - final_score: logSummary.final_score, - rounds_completed: logSummary.rounds_completed, - }, - }, - }), - ]) - - return toolJson({ - ok: true, - idea: result[0], - review_log_summary: logSummary, - }) - }), + handleUpdateIdeaPlanReviewed, ) } From fba2d67796b10ed20c76b8eb74072d57ce2c9fa1 Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Thu, 14 May 2026 23:21:44 +0200 Subject: [PATCH 27/27] fix(update_job_status): status-gedreven lifecycle-timestamps (#51) Een job kon CLAIMED -> done/failed/skipped gaan zonder ooit `running` te rapporteren, waardoor started_at NULL bleef terwijl finished_at wel gezet werd. Dat brak de invariant claimed_at <= started_at <= finished_at en elke duur-analyse. Nieuwe pure helper resolveJobTimestamps zet de lifecycle-timestamps set-once op basis van de status: started_at wordt gebackfild bij een terminale overgang, claimed_at defensief gevuld als die ontbreekt. De running-tak is nu set-once i.p.v. bij elke call overschrijven. Co-authored-by: Madhura68 Co-authored-by: Claude Opus 4.7 (1M context) --- .../update-job-status-timestamps.test.ts | 74 +++++++++++++++++++ src/tools/update-job-status.ts | 39 +++++++++- 2 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 __tests__/update-job-status-timestamps.test.ts diff --git a/__tests__/update-job-status-timestamps.test.ts b/__tests__/update-job-status-timestamps.test.ts new file mode 100644 index 0000000..d4ab80f --- /dev/null +++ b/__tests__/update-job-status-timestamps.test.ts @@ -0,0 +1,74 @@ +// Unit-tests voor resolveJobTimestamps — de status-gedreven timestamp-helper +// van update_job_status. Pure functie, geen mocks (zoals update-job-status-gate). + +import { describe, it, expect } from 'vitest' +import { resolveJobTimestamps } from '../src/tools/update-job-status.js' + +const NOW = new Date('2026-05-14T12:00:00.000Z') +const EARLIER = new Date('2026-05-14T11:00:00.000Z') + +describe('resolveJobTimestamps', () => { + describe('running', () => { + it('sets started_at when not yet set, no finished_at', () => { + const r = resolveJobTimestamps('running', { claimed_at: EARLIER, started_at: null }, NOW) + expect(r.started_at).toBe(NOW) + expect(r.finished_at).toBeUndefined() + expect(r.claimed_at).toBeUndefined() + }) + + it('is set-once: does not re-stamp started_at when already set', () => { + const r = resolveJobTimestamps('running', { claimed_at: EARLIER, started_at: EARLIER }, NOW) + expect(r.started_at).toBeUndefined() + expect(r.finished_at).toBeUndefined() + expect(r.claimed_at).toBeUndefined() + }) + }) + + describe('terminal transitions (done/failed/skipped)', () => { + it.each(['done', 'failed', 'skipped'] as const)( + 'backfills started_at and sets finished_at for %s when started_at is null', + (status) => { + const r = resolveJobTimestamps(status, { claimed_at: EARLIER, started_at: null }, NOW) + expect(r.started_at).toBe(NOW) + expect(r.finished_at).toBe(NOW) + expect(r.claimed_at).toBeUndefined() + }, + ) + + it('only sets finished_at when started_at is already set', () => { + const r = resolveJobTimestamps('done', { claimed_at: EARLIER, started_at: EARLIER }, NOW) + expect(r.started_at).toBeUndefined() + expect(r.finished_at).toBe(NOW) + expect(r.claimed_at).toBeUndefined() + }) + }) + + describe('claimed_at backfill', () => { + it.each(['running', 'done', 'failed', 'skipped'] as const)( + 'backfills claimed_at for %s when it is null', + (status) => { + const r = resolveJobTimestamps(status, { claimed_at: null, started_at: null }, NOW) + expect(r.claimed_at).toBe(NOW) + }, + ) + + it('never returns claimed_at when it is already set', () => { + const r = resolveJobTimestamps('done', { claimed_at: EARLIER, started_at: EARLIER }, NOW) + expect(r.claimed_at).toBeUndefined() + }) + }) + + it('returns only finished_at when all timestamps are already set and status is terminal', () => { + const r = resolveJobTimestamps('failed', { claimed_at: EARLIER, started_at: EARLIER }, NOW) + expect(r).toEqual({ finished_at: NOW }) + }) + + it('defaults now to a fresh Date when omitted', () => { + const before = Date.now() + const r = resolveJobTimestamps('running', { claimed_at: EARLIER, started_at: null }) + const after = Date.now() + expect(r.started_at).toBeInstanceOf(Date) + expect(r.started_at!.getTime()).toBeGreaterThanOrEqual(before) + expect(r.started_at!.getTime()).toBeLessThanOrEqual(after) + }) +}) diff --git a/src/tools/update-job-status.ts b/src/tools/update-job-status.ts index e7a9495..9fcd08b 100644 --- a/src/tools/update-job-status.ts +++ b/src/tools/update-job-status.ts @@ -390,6 +390,32 @@ export function resolveNextAction( return queueCount > 0 ? 'wait_for_job_again' : 'queue_empty' } +export type JobTimestampUpdate = { + claimed_at?: Date + started_at?: Date + finished_at?: Date +} + +// Bepaalt welke lifecycle-timestamps update_job_status schrijft bij een +// status-overgang. Set-once (backfill alleen als nu null) houdt de invariant +// claimed_at ≤ started_at ≤ finished_at: een job die CLAIMED → done gaat +// zonder `running`-rapport krijgt alsnog een started_at, en claimed_at +// (normaal door wait_for_job bij claim gezet) wordt nooit overschreven. +export function resolveJobTimestamps( + status: 'running' | 'done' | 'failed' | 'skipped', + current: { claimed_at: Date | null; started_at: Date | null }, + now: Date = new Date(), +): JobTimestampUpdate { + const isTerminal = status === 'done' || status === 'failed' || status === 'skipped' + const update: JobTimestampUpdate = {} + if (current.claimed_at == null) update.claimed_at = now + if (current.started_at == null && (status === 'running' || isTerminal)) { + update.started_at = now + } + if (isTerminal) update.finished_at = now + return update +} + export async function maybeCreateAutoPr(opts: { jobId: string productId: string @@ -569,6 +595,8 @@ export function registerUpdateJobStatusTool(server: McpServer) { 'Report progress on a claimed ClaudeJob. Allowed transitions from CLAIMED/RUNNING: ' + 'running (start), done (finished), failed (error), skipped (no-op exit). ' + 'The Bearer token must match the token that claimed the job. ' + + 'Stamps started_at on running and finished_at on done/failed/skipped, and backfills ' + + 'claimed_at/started_at when missing so claimed_at ≤ started_at ≤ finished_at always holds. ' + 'Before marking done: call verify_task_against_plan first — done is rejected when ' + 'verify_result is null, EMPTY (unless task.verify_only is true), or when the verify level ' + 'doesn’t meet task.verify_required: ALIGNED-only is strict; ALIGNED_OR_PARTIAL accepts ' + @@ -608,6 +636,8 @@ export function registerUpdateJobStatusTool(server: McpServer) { select: { id: true, status: true, + claimed_at: true, + started_at: true, claimed_by_token_id: true, user_id: true, product_id: true, @@ -751,10 +781,11 @@ export function registerUpdateJobStatusTool(server: McpServer) { where: { id: job_id }, data: { status: dbStatus, - ...(actualStatus === 'running' ? { started_at: now } : {}), - ...(actualStatus === 'done' || actualStatus === 'failed' || actualStatus === 'skipped' - ? { finished_at: now } - : {}), + ...resolveJobTimestamps( + actualStatus, + { claimed_at: job.claimed_at, started_at: job.started_at }, + now, + ), ...(branchToWrite !== undefined ? { branch: branchToWrite } : {}), ...(pushedAt !== undefined ? { pushed_at: pushedAt } : {}), ...(summary !== undefined ? { summary } : {}),