diff --git a/.env.example b/.env.example index 62b28f7..6a3e89c 100644 --- a/.env.example +++ b/.env.example @@ -3,9 +3,3 @@ 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 -# 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/.gitignore b/.gitignore index 547c38e..10a6dab 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,3 @@ prisma/generated # Editor .vscode .idea - -# Claude Code worktrees (per-session, never tracked) -.claude/worktrees/ diff --git a/README.md b/README.md index 793cc07..af91dbd 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,6 @@ 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`. @@ -296,10 +293,6 @@ 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 diff --git a/__tests__/cancel-pbi-cascade.test.ts b/__tests__/cancel-pbi-cascade.test.ts index e884c9f..8b55688 100644 --- a/__tests__/cancel-pbi-cascade.test.ts +++ b/__tests__/cancel-pbi-cascade.test.ts @@ -285,66 +285,4 @@ 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__/create-sprint.test.ts b/__tests__/create-sprint.test.ts deleted file mode 100644 index 5837d6e..0000000 --- a/__tests__/create-sprint.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -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 () => { - // 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-${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(), - }) - - await handleCreateSprint({ product_id: PRODUCT_ID, sprint_goal: 'g' }) - - 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__/create-story.test.ts b/__tests__/create-story.test.ts deleted file mode 100644 index 2bf1222..0000000 --- a/__tests__/create-story.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -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/__tests__/git/worktree.test.ts b/__tests__/git/worktree.test.ts index 68cedfd..68f5e19 100644 --- a/__tests__/git/worktree.test.ts +++ b/__tests__/git/worktree.test.ts @@ -113,71 +113,6 @@ 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__/job-config.test.ts b/__tests__/job-config.test.ts deleted file mode 100644 index 80ea72f..0000000 --- a/__tests__/job-config.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -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: '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 - -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 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) - }) -}) - -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__/job-heartbeat.test.ts b/__tests__/job-heartbeat.test.ts deleted file mode 100644 index 896f317..0000000 --- a/__tests__/job-heartbeat.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -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__/kind-prompts.test.ts b/__tests__/kind-prompts.test.ts deleted file mode 100644 index fda08f4..0000000 --- a/__tests__/kind-prompts.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -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(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)', () => { - 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/__tests__/update-idea-plan-reviewed.test.ts b/__tests__/update-idea-plan-reviewed.test.ts deleted file mode 100644 index 257fce4..0000000 --- a/__tests__/update-idea-plan-reviewed.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -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/__tests__/update-job-status-auto-pr.test.ts b/__tests__/update-job-status-auto-pr.test.ts index e92fdb3..3218b3e 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(), findMany: vi.fn(), findUnique: vi.fn() }, + claudeJob: { findFirst: vi.fn(), findUnique: vi.fn() }, }, })) @@ -22,7 +22,6 @@ const mockPrisma = prisma as unknown as { task: { findUnique: ReturnType } claudeJob: { findFirst: ReturnType - findMany: ReturnType findUnique: ReturnType } } @@ -42,10 +41,9 @@ 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.findMany.mockResolvedValue([]) // no sibling PRs by default + mockPrisma.claudeJob.findFirst.mockResolvedValue(null) // no sibling PR 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' }) @@ -64,27 +62,12 @@ describe('maybeCreateAutoPr', () => { }) it('reuses sibling pr_url when another job in same story already opened a PR', async () => { - mockPrisma.claudeJob.findMany.mockResolvedValue([ - { pr_url: 'https://github.com/org/repo/pull/77', task: { repo_url: null } }, - ]) + mockPrisma.claudeJob.findFirst.mockResolvedValue({ pr_url: 'https://github.com/org/repo/pull/77' }) 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) @@ -95,7 +78,6 @@ 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) @@ -131,9 +113,7 @@ describe('maybeCreateAutoPr', () => { sprint_run_id: 'run-1', sprint_run: { id: 'run-1', pr_strategy: 'SPRINT', sprint: { sprint_goal: 'Goal' } }, }) - mockPrisma.claudeJob.findMany.mockResolvedValue([ - { pr_url: 'https://github.com/org/repo/pull/55', task: { repo_url: null } }, - ]) + mockPrisma.claudeJob.findFirst.mockResolvedValue({ pr_url: 'https://github.com/org/repo/pull/55' }) const url = await maybeCreateAutoPr(BASE_OPTS) @@ -141,29 +121,6 @@ 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/__tests__/update-job-status-push.test.ts b/__tests__/update-job-status-push.test.ts index 3ffd6b3..1232670 100644 --- a/__tests__/update-job-status-push.test.ts +++ b/__tests__/update-job-status-push.test.ts @@ -5,26 +5,13 @@ 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', () => { @@ -52,25 +39,8 @@ describe('prepareDoneUpdate', () => { }) }) - it('reads branchName from DB (claudeJob.branch) when branch arg is undefined', async () => { + it('derives branchName from jobId when branch 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/__tests__/update-job-status-skipped.test.ts b/__tests__/update-job-status-skipped.test.ts deleted file mode 100644 index 53e745c..0000000 --- a/__tests__/update-job-status-skipped.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -// 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/__tests__/update-job-status-sprint-gate.test.ts b/__tests__/update-job-status-sprint-gate.test.ts deleted file mode 100644 index e96b94a..0000000 --- a/__tests__/update-job-status-sprint-gate.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -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-job-status-timestamps.test.ts b/__tests__/update-job-status-timestamps.test.ts deleted file mode 100644 index d4ab80f..0000000 --- a/__tests__/update-job-status-timestamps.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -// 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/__tests__/update-sprint.test.ts b/__tests__/update-sprint.test.ts deleted file mode 100644 index 3c62790..0000000 --- a/__tests__/update-sprint.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -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/__tests__/update-task-execution.test.ts b/__tests__/update-task-execution.test.ts deleted file mode 100644 index a893650..0000000 --- a/__tests__/update-task-execution.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -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 deleted file mode 100644 index 77bbc1b..0000000 --- a/__tests__/verify-sprint-task.test.ts +++ /dev/null @@ -1,216 +0,0 @@ -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/) - }) -}) diff --git a/__tests__/verify/classify.test.ts b/__tests__/verify/classify.test.ts index 968e125..1658e36 100644 --- a/__tests__/verify/classify.test.ts +++ b/__tests__/verify/classify.test.ts @@ -163,53 +163,3 @@ 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/__tests__/wait-for-job-worktree.test.ts b/__tests__/wait-for-job-worktree.test.ts index d36e08f..c03e91d 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(), update: vi.fn() }, + claudeJob: { findFirst: vi.fn(), findUnique: 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; update: ReturnType } + claudeJob: { findFirst: ReturnType; findUnique: ReturnType } product: { findUnique: ReturnType } } const mockCreateWorktree = createWorktreeForJob as ReturnType diff --git a/package-lock.json b/package-lock.json index 3514598..61bcb4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "scrum4me-mcp", - "version": "0.8.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "scrum4me-mcp", - "version": "0.8.0", + "version": "0.7.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 0cbcf56..de00265 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "scrum4me-mcp", - "version": "0.8.0", + "version": "0.7.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 d854a58..dce449e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -56,7 +56,6 @@ enum TaskStatus { REVIEW DONE FAILED - EXCLUDED } enum LogType { @@ -71,9 +70,8 @@ enum TestStatus { } enum SprintStatus { - OPEN - CLOSED - ARCHIVED + ACTIVE + COMPLETED FAILED } @@ -89,7 +87,6 @@ enum SprintRunStatus { enum PrStrategy { SPRINT STORY - SPRINT_BATCH } enum IdeaStatus { @@ -100,9 +97,6 @@ enum IdeaStatus { PLANNING PLAN_FAILED PLAN_READY - REVIEWING_PLAN - PLAN_REVIEW_FAILED - PLAN_REVIEWED PLANNED } @@ -110,17 +104,7 @@ enum ClaudeJobKind { TASK_IMPLEMENTATION IDEA_GRILL IDEA_MAKE_PLAN - IDEA_REVIEW_PLAN PLAN_CHAT - SPRINT_IMPLEMENTATION -} - -enum SprintTaskExecutionStatus { - PENDING - RUNNING - DONE - FAILED - SKIPPED } enum IdeaLogType { @@ -128,7 +112,6 @@ enum IdeaLogType { NOTE GRILL_RESULT PLAN_RESULT - PLAN_REVIEW_RESULT STATUS_CHANGE JOB_EVENT } @@ -152,7 +135,6 @@ 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[] @@ -167,7 +149,6 @@ model User { claude_jobs ClaudeJob[] claude_workers ClaudeWorker[] started_sprint_runs SprintRun[] @relation("SprintRunStartedBy") - push_subscriptions PushSubscription[] @@index([active_product_id]) @@map("users") @@ -209,9 +190,6 @@ 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 @@ -306,9 +284,8 @@ 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(OPEN) + status SprintStatus @default(ACTIVE) start_date DateTime? @db.Date end_date DateTime? @db.Date created_at DateTime @default(now()) @@ -317,33 +294,29 @@ model Sprint { tasks Task[] sprint_runs SprintRun[] - @@unique([product_id, code]) @@index([product_id, status]) @@map("sprints") } 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? - 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[] + 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[] @@index([sprint_id, status]) @@index([started_by_id, status]) @@ -351,34 +324,32 @@ 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) - requires_opus Boolean @default(false) + 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") - sprint_task_executions SprintTaskExecution[] + repo_url String? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + claude_questions ClaudeQuestion[] + claude_jobs ClaudeJob[] + sprint_run_failures SprintRun[] @relation("SprintRunFailedTask") @@unique([product_id, code]) @@index([story_id, priority, sort_order]) @@ -388,20 +359,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? @@ -413,10 +384,6 @@ 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? @@ -424,11 +391,9 @@ model ClaudeJob { pr_url String? summary String? error String? - retry_count Int @default(0) - lease_until DateTime? - task_executions SprintTaskExecution[] @relation("SprintJobExecutions") - created_at DateTime @default(now()) - updated_at DateTime @updatedAt + retry_count Int @default(0) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt @@index([user_id, status]) @@index([task_id, status]) @@ -436,41 +401,9 @@ 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 @@ -516,24 +449,22 @@ 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 - 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 + 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 questions ClaudeQuestion[] jobs ClaudeJob[] @@ -638,18 +569,3 @@ 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/cancel/pbi-cascade.ts b/src/cancel/pbi-cascade.ts index 19b1b0e..d7e4a61 100644 --- a/src/cancel/pbi-cascade.ts +++ b/src/cancel/pbi-cascade.ts @@ -47,7 +47,6 @@ async function runCascade(failedJobId: string): Promise { select: { id: true, kind: true, - status: true, product_id: true, task_id: true, branch: true, @@ -66,8 +65,6 @@ 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 @@ -197,21 +194,12 @@ 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: merged }, + data: { error: trace.slice(0, 1900) }, }) } catch (err) { console.warn(`[pbi-cascade] failed to persist trace for ${failedJobId}:`, err) diff --git a/src/git/worktree.ts b/src/git/worktree.ts index a27aca6..4d03443 100644 --- a/src/git/worktree.ts +++ b/src/git/worktree.ts @@ -15,19 +15,6 @@ 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, @@ -88,27 +75,7 @@ export async function createWorktreeForJob(opts: { if (occupant) { await exec('git', ['worktree', 'remove', '--force', occupant], { 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, - }) - } + await exec('git', ['worktree', 'add', worktreePath, branchName], { cwd: repoRoot }) return { worktreePath, branchName } } diff --git a/src/index.ts b/src/index.ts index 03f08d8..d05900c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,8 +12,6 @@ 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' @@ -28,14 +26,9 @@ 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' -// 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' @@ -80,9 +73,6 @@ async function main() { registerCreatePbiTool(server) registerCreateStoryTool(server) registerCreateTaskTool(server) - // PBI-12: sprint lifecycle tools - registerCreateSprintTool(server) - registerUpdateSprintTool(server) registerAskUserQuestionTool(server) registerGetQuestionAnswerTool(server) registerListOpenQuestionsTool(server) @@ -98,15 +88,10 @@ async function main() { registerGetIdeaContextTool(server) registerUpdateIdeaGrillMdTool(server) registerUpdateIdeaPlanMdTool(server) - registerUpdateIdeaPlanReviewedTool(server) registerLogIdeaDecisionTool(server) // 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/idea-prompts.ts b/src/lib/idea-prompts.ts new file mode 100644 index 0000000..bcc8873 --- /dev/null +++ b/src/lib/idea-prompts.ts @@ -0,0 +1,32 @@ +// 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 deleted file mode 100644 index ef7270d..0000000 --- a/src/lib/job-config.ts +++ /dev/null @@ -1,207 +0,0 @@ -// 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' - | '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 -} - -// 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-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: 'acceptEdits', - max_turns: 15, - 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: 'acceptEdits', - max_turns: 20, - allowed_tools: [ - 'Read', 'Grep', 'Glob', 'WebSearch', 'AskUserQuestion', 'Write', - 'mcp__scrum4me__update_idea_plan_md', - 'mcp__scrum4me__log_idea_decision', - '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, - permission_mode: 'acceptEdits', - max_turns: 5, - allowed_tools: [ - 'Read', 'Grep', 'AskUserQuestion', - 'mcp__scrum4me__update_job_status', - ], - }, - TASK_IMPLEMENTATION: { - model: 'claude-sonnet-4-6', - thinking_budget: 6000, - permission_mode: 'bypassPermissions', - max_turns: 50, - allowed_tools: TASK_TOOLS, - }, - SPRINT_IMPLEMENTATION: { - model: 'claude-sonnet-4-6', - thinking_budget: 6000, - permission_mode: 'bypassPermissions', - max_turns: 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', - ], - }, -} - -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, - } -} - -// 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 deleted file mode 100644 index 15a7a16..0000000 --- a/src/lib/kind-prompts.ts +++ /dev/null @@ -1,49 +0,0 @@ -// 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', - IDEA_REVIEW_PLAN: 'idea/review-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 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' && kind !== 'IDEA_REVIEW_PLAN') return '' - return getKindPromptText(kind) -} diff --git a/src/lib/push-trigger.ts b/src/lib/push-trigger.ts deleted file mode 100644 index fb0434a..0000000 --- a/src/lib/push-trigger.ts +++ /dev/null @@ -1,22 +0,0 @@ -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); - } -} diff --git a/src/lib/tasks-status-update.ts b/src/lib/tasks-status-update.ts index 6dde6c5..3549f3d 100644 --- a/src/lib/tasks-status-update.ts +++ b/src/lib/tasks-status-update.ts @@ -38,11 +38,6 @@ 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({ @@ -140,15 +135,15 @@ export async function propagateStatusUpwards( let nextStatus: SprintStatus if (anyPbiFailed) nextStatus = 'FAILED' - else if (allPbisDone) nextStatus = 'CLOSED' - else nextStatus = 'OPEN' + else if (allPbisDone) nextStatus = 'COMPLETED' + else nextStatus = 'ACTIVE' if (nextStatus !== sprint.status) { await tx.sprint.update({ where: { id: sprint.id }, data: { status: nextStatus, - ...(nextStatus === 'CLOSED' ? { completed_at: new Date() } : {}), + ...(nextStatus === 'COMPLETED' ? { completed_at: new Date() } : {}), }, }) sprintChanged = true @@ -156,43 +151,18 @@ export async function propagateStatusUpwards( } } - // 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). + // SprintRun herevalueren — via ClaudeJob.sprint_run_id van deze task let sprintRunChanged = false - if (nextSprintStatus === 'FAILED' || nextSprintStatus === 'CLOSED') { - let resolvedRunId: string | null = sprintRunId ?? null - let cancelExceptJobId: string | null = null + 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 }, + }) - 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) { + if (job?.sprint_run_id) { const sprintRun = await tx.sprintRun.findUnique({ - where: { id: resolvedRunId }, + where: { id: job.sprint_run_id }, select: { id: true, status: true }, }) if ( @@ -210,16 +180,11 @@ 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'] }, - ...(cancelExceptJobId ? { id: { not: cancelExceptJobId } } : {}), + id: { not: job.id }, }, data: { status: 'CANCELLED', @@ -265,16 +230,14 @@ 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, sprintRunId) + const result = await propagateStatusUpwards(taskId, newStatus, client) let storyStatusChange: StoryStatusChange = null if (result.storyChanged) { storyStatusChange = newStatus === 'DONE' ? 'promoted' : 'demoted' @@ -283,6 +246,5 @@ export async function updateTaskStatusWithStoryPromotion( task: result.task, storyStatusChange, storyId: result.storyId, - sprintRunChanged: result.sprintRunChanged, } } diff --git a/src/prompts/idea/grill.md b/src/prompts/idea/grill.md index 13be8d1..d5af711 100644 --- a/src/prompts/idea/grill.md +++ b/src/prompts/idea/grill.md @@ -1,28 +1,21 @@ # Grill-prompt voor IDEA_GRILL-jobs -> 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. +> 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. --- -Je bent een **grill-agent** voor een Scrum4Me-idee. De runner heeft de job -al voor je geclaimd; jouw eerste actie is altijd: +Je bent een **grill-agent** voor Scrum4Me-idee `{idea_code}` (titel: +`{idea_title}`). -``` -Read $PAYLOAD_PATH -``` +Je context (meegegeven in `wait_for_job`-payload): -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` +- `idea`: het volledige idee-record incl. eventueel bestaande `grill_md` - `product`: het gekoppelde product (incl. `repo_url` en `definition_of_done`) -- `primary_worktree_path`: lokale repo om te lezen (je `cwd` zit daar al) +- `repo_url`: lokale repo om te lezen (worker bevindt zich daar al) ## Doel @@ -32,11 +25,11 @@ PBI van kan maken. Eindresultaat is een markdown-document dat je via ## Werkwijze (loop, één vraag per cyclus) -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`. +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. 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`). @@ -46,8 +39,7 @@ 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 })` - — dit sluit de job af. **Verplicht**, ook als de gebruiker afbreekt. +7. Roep `mcp__scrum4me__update_job_status({ job_id, status: 'done', summary })`. ## Stop-conditie @@ -63,7 +55,7 @@ Stop óók als de gebruiker expliciet zegt "klaar" / "genoeg" / "ga door". ## Output-format (strikt) ```markdown -# Idee — +# Idee — {korte titel} ## Scope … diff --git a/src/prompts/idea/make-plan.md b/src/prompts/idea/make-plan.md index 300eaf6..86891a0 100644 --- a/src/prompts/idea/make-plan.md +++ b/src/prompts/idea/make-plan.md @@ -1,29 +1,21 @@ # Make-Plan-prompt voor IDEA_MAKE_PLAN-jobs -> 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. +> 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. --- -Je bent een **planning-agent** voor een Scrum4Me-idee. De runner heeft de -job al voor je geclaimd; jouw eerste actie is altijd: +Je bent een **planning-agent** voor Scrum4Me-idee `{idea_code}`. -``` -Read $PAYLOAD_PATH -``` +Je context (meegegeven in `wait_for_job`-payload): -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 @@ -34,18 +26,13 @@ PBI + stories + taken via `materializeIdeaPlanAction`. ## Werkwijze (single-pass) -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 +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 sectie). Voeg per geraakte file een taak toe vóór de schema/code-edit zelf. -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. +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 })`. ## Dependency-cascade-grep (verplicht bij removal/refactor) diff --git a/src/prompts/idea/review-plan.md b/src/prompts/idea/review-plan.md deleted file mode 100644 index 8df45f6..0000000 --- a/src/prompts/idea/review-plan.md +++ /dev/null @@ -1,210 +0,0 @@ -# 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/prompts/plan-chat/chat.md b/src/prompts/plan-chat/chat.md deleted file mode 100644 index 224d51e..0000000 --- a/src/prompts/plan-chat/chat.md +++ /dev/null @@ -1,16 +0,0 @@ -# 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 deleted file mode 100644 index 9089f8a..0000000 --- a/src/prompts/sprint/implementation.md +++ /dev/null @@ -1,77 +0,0 @@ -# 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 deleted file mode 100644 index fa408ee..0000000 --- a/src/prompts/task/implementation.md +++ /dev/null @@ -1,58 +0,0 @@ -# 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/status.ts b/src/status.ts index dd37dc8..b256252 100644 --- a/src/status.ts +++ b/src/status.ts @@ -6,7 +6,6 @@ const TASK_DB_TO_API = { REVIEW: 'review', DONE: 'done', FAILED: 'failed', - EXCLUDED: 'excluded', } as const satisfies Record const TASK_API_TO_DB: Record = { @@ -15,7 +14,6 @@ const TASK_API_TO_DB: Record = { review: 'REVIEW', done: 'DONE', failed: 'FAILED', - excluded: 'EXCLUDED', } const STORY_DB_TO_API = { diff --git a/src/tools/ask-user-question.ts b/src/tools/ask-user-question.ts index 3618b5e..b4d5a59 100644 --- a/src/tools/ask-user-question.ts +++ b/src/tools/ask-user-question.ts @@ -10,7 +10,6 @@ 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 @@ -128,13 +127,6 @@ 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)) diff --git a/src/tools/create-sprint.ts b/src/tools/create-sprint.ts deleted file mode 100644 index 5d8cd9b..0000000 --- a/src/tools/create-sprint.ts +++ /dev/null @@ -1,113 +0,0 @@ -// 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/create-story.ts b/src/tools/create-story.ts index 37caa59..cfa099e 100644 --- a/src/tools/create-story.ts +++ b/src/tools/create-story.ts @@ -1,9 +1,8 @@ // 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). 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'). +// convention — nooit vertrouwen op client-input). status='OPEN' default; +// landt in de Product Backlog, niet auto in een sprint. import { z } from 'zod' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' @@ -47,108 +46,75 @@ 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. 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.', + '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.', inputSchema, }, - handleCreateStory, + 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') + }), ) } diff --git a/src/tools/get-claude-context.ts b/src/tools/get-claude-context.ts index 80f7a4c..fb450e7 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: 'OPEN' }, + where: { product_id, status: 'ACTIVE' }, orderBy: { created_at: 'desc' }, select: { id: true, sprint_goal: true, status: true }, }) diff --git a/src/tools/job-heartbeat.ts b/src/tools/job-heartbeat.ts deleted file mode 100644 index 36c42a2..0000000 --- a/src/tools/job-heartbeat.ts +++ /dev/null @@ -1,81 +0,0 @@ -// 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-idea-plan-reviewed.ts b/src/tools/update-idea-plan-reviewed.ts deleted file mode 100644 index 2e9f1ac..0000000 --- a/src/tools/update-idea-plan-reviewed.ts +++ /dev/null @@ -1,126 +0,0 @@ -// 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. - -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' - -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 - .enum(['pending', 'approved', 'rejected'] as const) - .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 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, - }, - handleUpdateIdeaPlanReviewed, - ) -} - -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/update-job-status.ts b/src/tools/update-job-status.ts index 9fcd08b..9f60006 100644 --- a/src/tools/update-job-status.ts +++ b/src/tools/update-job-status.ts @@ -1,12 +1,6 @@ -// update_job_status — agent rapporteert voortgang: running | done | failed | skipped. +// update_job_status — agent rapporteert voortgang: running | done | failed. // 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' @@ -24,7 +18,6 @@ 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' @@ -45,7 +38,7 @@ async function fetchConflictFiles(prUrl: string): Promise { const inputSchema = z.object({ job_id: z.string().min(1), - status: z.enum(['running', 'done', 'failed', 'skipped']), + status: z.enum(['running', 'done', 'failed']), branch: z.string().min(1).optional(), summary: z.string().max(1_000).optional(), error: z.string().max(2_000).optional(), @@ -54,13 +47,12 @@ 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( productId: string, jobId: string, - status: 'done' | 'failed' | 'skipped', + status: 'done' | 'failed', branch: string | undefined, ): Promise { const repoRoot = await resolveRepoRoot(productId) @@ -71,57 +63,31 @@ export async function cleanupWorktreeForTerminalStatus( return } - // 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. + // 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. const job = await prisma.claudeJob.findUnique({ where: { id: jobId }, - select: { - task: { select: { story_id: true } }, - sprint_run_id: true, - sprint_run: { select: { pr_strategy: true } }, - }, + select: { task: { select: { story_id: true } } }, }) - - 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({ + if (job?.task) { + const activeSiblings = await prisma.claudeJob.count({ where: { task: { story_id: job.task.story_id }, status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] }, id: { not: jobId }, }, }) - scope = `story ${job.task.story_id}` + 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 + } } - 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') + // Keep branch when job is done and a branch was reported (agent pushed) + const keepBranch = status === 'done' && branch !== undefined try { await removeWorktreeForJob({ repoRoot, jobId, keepBranch }) } catch (err) { @@ -145,25 +111,9 @@ 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 }) @@ -275,147 +225,20 @@ 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', failed: 'FAILED', - skipped: 'SKIPPED', } as const export function resolveNextAction( queueCount: number, - status: 'running' | 'done' | 'failed' | 'skipped', + status: 'running' | 'done' | 'failed', ): 'wait_for_job_again' | 'queue_empty' | 'idle' { if (status === 'running') return 'idle' 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 @@ -446,35 +269,24 @@ 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 - // 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). + // PBI-46 SPRINT-mode: hergebruik 1 draft-PR voor de hele SprintRun. // Mens zet 'm ready-for-review zodra de SprintRun DONE is. if (job?.sprint_run && job.sprint_run.pr_strategy === 'SPRINT') { - const sprintSiblings = await prisma.claudeJob.findMany({ + const sprintSibling = await prisma.claudeJob.findFirst({ where: { sprint_run_id: job.sprint_run_id, pr_url: { not: null }, id: { not: jobId }, }, - select: { pr_url: true, task: { select: { repo_url: true } } }, + select: { pr_url: true }, orderBy: { created_at: 'asc' }, }) - const sameRepoSibling = sprintSiblings.find( - (s) => (s.task?.repo_url ?? null) === thisRepoKey, - ) - if (sameRepoSibling?.pr_url) return sameRepoSibling.pr_url + if (sprintSibling?.pr_url) return sprintSibling.pr_url // Eerste DONE in deze SprintRun → maak draft-PR aan, geen auto-merge. const goal = job.sprint_run.sprint.sprint_goal @@ -496,21 +308,17 @@ export async function maybeCreateAutoPr(opts: { return null } - // 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({ + // STORY-mode (default of legacy): branch-per-story, sibling-tasks delen PR. + const sibling = await prisma.claudeJob.findFirst({ where: { task: { story_id: task.story.id }, pr_url: { not: null }, id: { not: jobId }, }, - select: { pr_url: true, task: { select: { repo_url: true } } }, + select: { pr_url: true }, orderBy: { created_at: 'asc' }, }) - const sameRepoStorySibling = storySiblings.find( - (s) => (s.task?.repo_url ?? null) === thisRepoKey, - ) - if (sameRepoStorySibling?.pr_url) return sameRepoStorySibling.pr_url + if (sibling?.pr_url) return sibling.pr_url const storyTitle = task.story.code ? `${task.story.code}: ${task.story.title}` : task.story.title const body = summary @@ -524,68 +332,6 @@ 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', @@ -593,20 +339,13 @@ 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), skipped (no-op exit). ' + + 'running (start), done (finished), failed (error). ' + '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 ' + '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, ' + @@ -625,7 +364,6 @@ export function registerUpdateJobStatusTool(server: McpServer) { output_tokens, cache_read_tokens, cache_write_tokens, - actual_thinking_tokens, }) => withToolErrors(async () => { const auth = await requireWriteAccess() @@ -636,14 +374,11 @@ 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, task_id: true, idea_id: true, - sprint_run_id: true, kind: true, verify_result: true, task: { select: { verify_only: true, verify_required: true } }, @@ -667,23 +402,6 @@ 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 @@ -701,19 +419,6 @@ 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, @@ -735,7 +440,6 @@ 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' && @@ -756,23 +460,6 @@ 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] @@ -781,11 +468,8 @@ export function registerUpdateJobStatusTool(server: McpServer) { where: { id: job_id }, data: { status: dbStatus, - ...resolveJobTimestamps( - actualStatus, - { claimed_at: job.claimed_at, started_at: job.started_at }, - now, - ), + ...(actualStatus === 'running' ? { started_at: now } : {}), + ...(actualStatus === 'done' || actualStatus === 'failed' ? { finished_at: now } : {}), ...(branchToWrite !== undefined ? { branch: branchToWrite } : {}), ...(pushedAt !== undefined ? { pushed_at: pushedAt } : {}), ...(summary !== undefined ? { summary } : {}), @@ -797,7 +481,6 @@ 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, @@ -810,7 +493,6 @@ export function registerUpdateJobStatusTool(server: McpServer) { error: true, started_at: true, finished_at: true, - head_sha: true, }, }) @@ -979,45 +661,32 @@ export function registerUpdateJobStatusTool(server: McpServer) { try { const pg = new Client({ connectionString: process.env.DATABASE_URL }) await pg.connect() - 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.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, + }), + ], + ) 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') && - !skipWorktreeCleanup - ) { + if ((actualStatus === 'done' || actualStatus === 'failed') && !skipWorktreeCleanup) { await cleanupWorktreeForTerminalStatus(job.product_id, job_id, actualStatus, branchToWrite) } @@ -1025,91 +694,13 @@ 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' || actualStatus === 'skipped') { + if (actualStatus === 'done' || actualStatus === 'failed') { await releaseLocksOnTerminal(job_id) } diff --git a/src/tools/update-sprint.ts b/src/tools/update-sprint.ts deleted file mode 100644 index 04800e3..0000000 --- a/src/tools/update-sprint.ts +++ /dev/null @@ -1,102 +0,0 @@ -// 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, - ) -} diff --git a/src/tools/update-task-execution.ts b/src/tools/update-task-execution.ts deleted file mode 100644 index 8b3213a..0000000 --- a/src/tools/update-task-execution.ts +++ /dev/null @@ -1,110 +0,0 @@ -// 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 8ac8463..d3756ce 100644 --- a/src/tools/update-task-status.ts +++ b/src/tools/update-task-status.ts @@ -1,6 +1,5 @@ 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' @@ -10,10 +9,6 @@ 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) { @@ -22,14 +17,11 @@ export function registerUpdateTaskStatusTool(server: McpServer) { { title: 'Update task status', description: - '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. ' + + 'Set the status of a task. Allowed values: todo, in_progress, review, done. ' + 'Forbidden for demo accounts.', inputSchema, }, - async ({ task_id, status, sprint_run_id }) => + async ({ task_id, status }) => withToolErrors(async () => { const auth = await requireWriteAccess() const dbStatus = taskStatusFromApi(status) @@ -39,74 +31,15 @@ export function registerUpdateTaskStatusTool(server: McpServer) { if (!(await userCanAccessTask(task_id, auth.userId))) { return toolError(`Task ${task_id} not found or not accessible`) } - - // 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 - } - + const { task, storyStatusChange } = await updateTaskStatusWithStoryPromotion( + task_id, + dbStatus, + ) 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 deleted file mode 100644 index fbd62d2..0000000 --- a/src/tools/verify-sprint-task.ts +++ /dev/null @@ -1,151 +0,0 @@ -// 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, - }) - }), - ) -} diff --git a/src/tools/wait-for-job.ts b/src/tools/wait-for-job.ts index f3e11c0..5741ec5 100644 --- a/src/tools/wait-for-job.ts +++ b/src/tools/wait-for-job.ts @@ -15,10 +15,7 @@ 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' -import { resolveJobConfig } from '../lib/job-config.js' /** Parse `https://github.com//(.git)?` → ``. */ export function repoNameFromUrl(repoUrl: string | null | undefined): string | null { @@ -202,18 +199,12 @@ export async function attachWorktreeToJob( } catch (err) { console.warn(`[attachWorktreeToJob] failed to resolve base_sha for ${jobId}:`, err) } - // 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 } : {}), - }, - }) + if (baseSha) { + await prisma.claudeJob.update({ + where: { id: jobId }, + data: { base_sha: baseSha }, + }) + } return { worktree_path: worktreePath, branch_name: actualBranch, reused_branch: reused } } catch (err) { @@ -234,96 +225,45 @@ const inputSchema = z.object({ const STALE_ERROR_MSG = 'agent did not complete job within 2 attempts' export async function resetStaleClaimedJobs(userId: string): Promise { - // 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` + // Jobs that exceeded the retry limit → FAILED + const failedRows = await prisma.$queryRaw< + Array<{ id: string; task_id: string; product_id: string }> + >` UPDATE claude_jobs SET status = 'FAILED', finished_at = NOW(), error = ${STALE_ERROR_MSG} WHERE user_id = ${userId} - AND status IN ('CLAIMED', 'RUNNING') + AND status = 'CLAIMED' + AND claimed_at < NOW() - INTERVAL '30 minutes' AND retry_count >= 2 - 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 + RETURNING id, task_id, product_id ` + // Jobs under the retry limit → back to QUEUED, increment retry_count const requeuedRows = await prisma.$queryRaw< - (StaleRow & { retry_count: number })[] + Array<{ id: string; task_id: string; product_id: string; 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 IN ('CLAIMED', 'RUNNING') + AND status = 'CLAIMED' + AND claimed_at < NOW() - INTERVAL '30 minutes' AND retry_count < 2 - 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 + RETURNING id, task_id, product_id, 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 }) @@ -368,15 +308,12 @@ export async function tryClaimJob( ): Promise { // Atomic claim in a single transaction — also captures plan_snapshot from task. // - // 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. + // 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. // 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< @@ -390,10 +327,8 @@ export async function tryClaimJob( AND cj.product_id = ${productId} AND cj.status = 'QUEUED' AND ( - 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')) + cj.task_id IS NULL + OR (cj.sprint_run_id IS NOT NULL AND sr.status IN ('QUEUED', 'RUNNING')) ) ORDER BY cj.created_at ASC LIMIT 1 @@ -409,10 +344,8 @@ export async function tryClaimJob( WHERE cj.user_id = ${userId} AND cj.status = 'QUEUED' AND ( - 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')) + cj.task_id IS NULL + OR (cj.sprint_run_id IS NOT NULL AND sr.status IN ('QUEUED', 'RUNNING')) ) ORDER BY cj.created_at ASC LIMIT 1 @@ -429,8 +362,7 @@ export async function tryClaimJob( SET status = 'CLAIMED', claimed_by_token_id = ${tokenId}, claimed_at = NOW(), - plan_snapshot = ${snapshot}, - lease_until = NOW() + INTERVAL '5 minutes' + plan_snapshot = ${snapshot} WHERE id = ${jobId} ` @@ -452,7 +384,7 @@ export async function tryClaimJob( return rows.length > 0 ? rows[0].id : null } -export async function getFullJobContext(jobId: string) { +async function getFullJobContext(jobId: string) { const job = await prisma.claudeJob.findUnique({ where: { id: jobId }, include: { @@ -474,44 +406,17 @@ export async function getFullJobContext(jobId: string) { }, }, }, - product: { - select: { - id: true, - name: true, - repo_url: true, - definition_of_done: true, - preferred_model: true, - thinking_budget_default: true, - preferred_permission_mode: true, - }, - }, + product: { select: { id: true, name: true, repo_url: true, definition_of_done: 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' || job.kind === 'IDEA_REVIEW_PLAN') { + if (job.kind === 'IDEA_GRILL' || job.kind === 'IDEA_MAKE_PLAN') { if (!job.idea) return null const { idea } = job - const { getIdeaPromptText } = await import('../lib/kind-prompts.js') + const { getIdeaPromptText } = await import('../lib/idea-prompts.js') // Setup persistent product-worktrees for this idea-job (PBI-9). // Primary product is gated by repo_url via resolveRepoRoot returning null. @@ -549,7 +454,6 @@ export async function getFullJobContext(jobId: string) { job_id: job.id, kind: job.kind, status: 'claimed', - config, idea: { id: idea.id, code: idea.code, @@ -569,11 +473,7 @@ 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()}-${(() => { - if (job.kind === 'IDEA_GRILL') return 'grill' - if (job.kind === 'IDEA_REVIEW_PLAN') return 'review' - return 'plan' - })()}`, + branch_suggestion: `feat/idea-${idea.code.toLowerCase()}-${job.kind === 'IDEA_GRILL' ? 'grill' : 'plan'}`, product_worktrees: worktrees.map((w) => ({ product_id: w.productId, worktree_path: w.worktreePath, @@ -582,178 +482,6 @@ export 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', - config, - 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 @@ -764,7 +492,6 @@ export async function getFullJobContext(jobId: string) { job_id: job.id, kind: job.kind, status: 'claimed', - config, task: { id: task.id, title: task.title, diff --git a/src/verify/classify.ts b/src/verify/classify.ts index 429bfe3..3fe99f5 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 (looksLikePath(p)) paths.add(p) + if ((p.includes('/') || p.includes('.')) && !p.includes(' ') && p.length > 3) paths.add(p) } const bulletRe = /^[-*]\s+\*{0,2}([^\s*][^\s]*)\.([a-zA-Z]{1,6})\*{0,2}\s*[:\n]/gm @@ -38,20 +38,6 @@ 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, '/') diff --git a/vendor/scrum4me b/vendor/scrum4me index 7bb252c..77617e8 160000 --- a/vendor/scrum4me +++ b/vendor/scrum4me @@ -1 +1 @@ -Subproject commit 7bb252c528d810584bcb46a56cff3d26ebf392ff +Subproject commit 77617e89ac830bc4a86fa7d41f16a5122a1d9689