diff --git a/__tests__/create-sprint.test.ts b/__tests__/create-sprint.test.ts index 72d400d..5837d6e 100644 --- a/__tests__/create-sprint.test.ts +++ b/__tests__/create-sprint.test.ts @@ -104,10 +104,13 @@ describe('handleCreateSprint', () => { }) it('auto-code increments past existing same-day sprints', async () => { + // Codes moeten relatief aan "vandaag" zijn: generateNextSprintCode telt + // alleen same-day sprints. Hardcoded datums maakten deze test datum-flaky. + const today = new Date().toISOString().slice(0, 10) mockPrisma.sprint.findMany.mockResolvedValue([ - { code: 'S-2026-05-11-1' }, - { code: 'S-2026-05-11-3' }, - { code: 'S-2026-05-10-7' }, + { code: `S-${today}-1` }, + { code: `S-${today}-3` }, + { code: 'S-2020-01-01-7' }, ]) mockPrisma.sprint.create.mockResolvedValue({ id: 'spr-3', code: 'X', sprint_goal: 'g', status: 'OPEN', start_date: new Date(), created_at: new Date(), @@ -115,7 +118,6 @@ describe('handleCreateSprint', () => { await handleCreateSprint({ product_id: PRODUCT_ID, sprint_goal: 'g' }) - const today = new Date().toISOString().slice(0, 10) expect(mockPrisma.sprint.create.mock.calls[0][0].data.code).toBe(`S-${today}-4`) }) diff --git a/__tests__/create-story.test.ts b/__tests__/create-story.test.ts new file mode 100644 index 0000000..2bf1222 --- /dev/null +++ b/__tests__/create-story.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../src/prisma.js', () => ({ + prisma: { + pbi: { findUnique: vi.fn() }, + sprint: { findUnique: vi.fn() }, + story: { + findFirst: vi.fn(), + findMany: vi.fn(), + create: vi.fn(), + }, + }, +})) + +vi.mock('../src/auth.js', () => ({ + requireWriteAccess: vi.fn(), + PermissionDeniedError: class PermissionDeniedError extends Error { + constructor(message = 'Demo accounts cannot perform write operations') { + super(message) + this.name = 'PermissionDeniedError' + } + }, +})) + +vi.mock('../src/access.js', () => ({ + userCanAccessProduct: vi.fn(), +})) + +import { prisma } from '../src/prisma.js' +import { requireWriteAccess } from '../src/auth.js' +import { userCanAccessProduct } from '../src/access.js' +import { handleCreateStory } from '../src/tools/create-story.js' + +const mockPrisma = prisma as unknown as { + pbi: { findUnique: ReturnType } + sprint: { findUnique: ReturnType } + story: { + findFirst: ReturnType + findMany: ReturnType + create: ReturnType + } +} +const mockRequireWriteAccess = requireWriteAccess as ReturnType +const mockUserCanAccessProduct = userCanAccessProduct as ReturnType + +const PRODUCT_ID = 'prod-1' +const PBI_ID = 'pbi-1' +const SPRINT_ID = 'spr-1' +const USER_ID = 'user-1' + +beforeEach(() => { + vi.clearAllMocks() + mockRequireWriteAccess.mockResolvedValue({ userId: USER_ID, tokenId: 'tok-1', username: 'alice', isDemo: false }) + mockUserCanAccessProduct.mockResolvedValue(true) + mockPrisma.pbi.findUnique.mockResolvedValue({ product_id: PRODUCT_ID }) + mockPrisma.story.findMany.mockResolvedValue([]) + mockPrisma.story.findFirst.mockResolvedValue(null) + mockPrisma.story.create.mockImplementation((args: { data: Record }) => + Promise.resolve({ id: 'story-1', created_at: new Date('2026-05-14T10:00:00Z'), ...args.data }), + ) +}) + +function parseResult(result: Awaited>) { + const text = result.content?.[0]?.type === 'text' ? result.content[0].text : '' + try { return JSON.parse(text) } catch { return text } +} + +function errorText(result: Awaited>): string { + return result.content?.[0]?.type === 'text' ? result.content[0].text : '' +} + +describe('handleCreateStory', () => { + it('without sprint_id: creates story with status OPEN and no sprint', async () => { + const result = await handleCreateStory({ pbi_id: PBI_ID, title: 'A story', priority: 2 }) + + expect(mockPrisma.sprint.findUnique).not.toHaveBeenCalled() + const data = mockPrisma.story.create.mock.calls[0][0].data + expect(data.status).toBe('OPEN') + expect(data.sprint_id).toBeNull() + expect(data.product_id).toBe(PRODUCT_ID) + expect(parseResult(result).status).toBe('OPEN') + }) + + it('with valid sprint_id: links story to sprint with status IN_SPRINT', async () => { + mockPrisma.sprint.findUnique.mockResolvedValue({ product_id: PRODUCT_ID }) + + const result = await handleCreateStory({ + pbi_id: PBI_ID, + title: 'A story', + priority: 2, + sprint_id: SPRINT_ID, + }) + + expect(mockPrisma.sprint.findUnique).toHaveBeenCalledWith({ + where: { id: SPRINT_ID }, + select: { product_id: true }, + }) + const data = mockPrisma.story.create.mock.calls[0][0].data + expect(data.status).toBe('IN_SPRINT') + expect(data.sprint_id).toBe(SPRINT_ID) + expect(parseResult(result).sprint_id).toBe(SPRINT_ID) + }) + + it('rejects a non-existent sprint_id', async () => { + mockPrisma.sprint.findUnique.mockResolvedValue(null) + + const result = await handleCreateStory({ + pbi_id: PBI_ID, + title: 'A story', + priority: 2, + sprint_id: 'missing', + }) + + expect(mockPrisma.story.create).not.toHaveBeenCalled() + expect(errorText(result)).toMatch(/Sprint missing not found/) + }) + + it('rejects a sprint from a different product', async () => { + mockPrisma.sprint.findUnique.mockResolvedValue({ product_id: 'other-product' }) + + const result = await handleCreateStory({ + pbi_id: PBI_ID, + title: 'A story', + priority: 2, + sprint_id: SPRINT_ID, + }) + + expect(mockPrisma.story.create).not.toHaveBeenCalled() + expect(errorText(result)).toMatch(/different product/) + }) + + it('returns error when PBI not found', async () => { + mockPrisma.pbi.findUnique.mockResolvedValue(null) + + const result = await handleCreateStory({ pbi_id: 'missing', title: 'A story', priority: 2 }) + + expect(mockPrisma.sprint.findUnique).not.toHaveBeenCalled() + expect(mockPrisma.story.create).not.toHaveBeenCalled() + expect(errorText(result)).toMatch(/PBI missing not found/) + }) +}) diff --git a/package.json b/package.json index de00265..0cbcf56 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "scrum4me-mcp", - "version": "0.7.0", + "version": "0.8.0", "description": "MCP server for Scrum4Me — exposes dev-flow tools and prompts via the Model Context Protocol", "type": "module", "bin": { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4f6b086..d854a58 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -100,6 +100,9 @@ enum IdeaStatus { PLANNING PLAN_FAILED PLAN_READY + REVIEWING_PLAN + PLAN_REVIEW_FAILED + PLAN_REVIEWED PLANNED } @@ -107,6 +110,7 @@ enum ClaudeJobKind { TASK_IMPLEMENTATION IDEA_GRILL IDEA_MAKE_PLAN + IDEA_REVIEW_PLAN PLAN_CHAT SPRINT_IMPLEMENTATION } @@ -124,6 +128,7 @@ enum IdeaLogType { NOTE GRILL_RESULT PLAN_RESULT + PLAN_REVIEW_RESULT STATUS_CHANGE JOB_EVENT } @@ -147,6 +152,7 @@ model User { active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull) idea_code_counter Int @default(0) min_quota_pct Int @default(20) + settings Json @default("{}") created_at DateTime @default(now()) updated_at DateTime @updatedAt roles UserRole[] @@ -510,22 +516,24 @@ model ProductMember { } model Idea { - id String @id @default(cuid()) - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) - user_id String - product Product? @relation(fields: [product_id], references: [id], onDelete: SetNull) - product_id String? - code String @db.VarChar(30) - title String - description String? @db.VarChar(4000) - grill_md String? @db.Text - plan_md String? @db.Text - pbi Pbi? @relation(fields: [pbi_id], references: [id], onDelete: SetNull) - pbi_id String? @unique - status IdeaStatus @default(DRAFT) - archived Boolean @default(false) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_id String + product Product? @relation(fields: [product_id], references: [id], onDelete: SetNull) + product_id String? + code String @db.VarChar(30) + title String + description String? @db.VarChar(4000) + grill_md String? @db.Text + plan_md String? @db.Text + plan_review_log Json? // ReviewLog from orchestrator (all rounds, convergence metrics, approval status) + reviewed_at DateTime? // When last reviewed + pbi Pbi? @relation(fields: [pbi_id], references: [id], onDelete: SetNull) + pbi_id String? @unique + status IdeaStatus @default(DRAFT) + archived Boolean @default(false) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt questions ClaudeQuestion[] jobs ClaudeJob[] diff --git a/src/index.ts b/src/index.ts index 06cefba..03f08d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,7 @@ import { registerMarkPbiPrMergedTool } from './tools/mark-pbi-pr-merged.js' import { registerGetIdeaContextTool } from './tools/get-idea-context.js' import { registerUpdateIdeaGrillMdTool } from './tools/update-idea-grill-md.js' import { registerUpdateIdeaPlanMdTool } from './tools/update-idea-plan-md.js' +import { registerUpdateIdeaPlanReviewedTool } from './tools/update-idea-plan-reviewed.js' import { registerLogIdeaDecisionTool } from './tools/log-idea-decision.js' import { registerGetWorkerSettingsTool } from './tools/get-worker-settings.js' import { registerWorkerHeartbeatTool } from './tools/worker-heartbeat.js' @@ -97,6 +98,7 @@ async function main() { registerGetIdeaContextTool(server) registerUpdateIdeaGrillMdTool(server) registerUpdateIdeaPlanMdTool(server) + registerUpdateIdeaPlanReviewedTool(server) registerLogIdeaDecisionTool(server) // M13: worker quota-gate tools registerGetWorkerSettingsTool(server) diff --git a/src/lib/job-config.ts b/src/lib/job-config.ts index 811e365..ef7270d 100644 --- a/src/lib/job-config.ts +++ b/src/lib/job-config.ts @@ -101,6 +101,19 @@ const KIND_DEFAULTS: Record = { 'mcp__scrum4me__update_job_status', ], }, + IDEA_REVIEW_PLAN: { + model: 'claude-opus-4-7', + thinking_budget: 6000, + permission_mode: 'acceptEdits', + max_turns: 1, + allowed_tools: [ + 'Read', 'Write', 'Grep', 'Glob', + 'mcp__scrum4me__update_idea_plan_reviewed', + 'mcp__scrum4me__log_idea_decision', + 'mcp__scrum4me__update_job_status', + 'mcp__scrum4me__ask_user_question', + ], + }, PLAN_CHAT: { model: 'claude-sonnet-4-6', thinking_budget: 6000, diff --git a/src/lib/kind-prompts.ts b/src/lib/kind-prompts.ts index f7e03c1..15a7a16 100644 --- a/src/lib/kind-prompts.ts +++ b/src/lib/kind-prompts.ts @@ -25,6 +25,7 @@ function loadPrompt(rel: string): string { const KIND_TO_PROMPT_PATH: Partial> = { IDEA_GRILL: 'idea/grill.md', IDEA_MAKE_PLAN: 'idea/make-plan.md', + IDEA_REVIEW_PLAN: 'idea/review-plan.md', TASK_IMPLEMENTATION: 'task/implementation.md', SPRINT_IMPLEMENTATION: 'sprint/implementation.md', PLAN_CHAT: 'plan-chat/chat.md', @@ -40,9 +41,9 @@ export function getKindPromptText(kind: ClaudeJobKind): string { } // Back-compat re-export. wait-for-job.ts roept getIdeaPromptText aan voor -// de twee idea-kinds; behouden zodat we de bestaande call-site niet hoeven +// de drie idea-kinds; behouden zodat we de bestaande call-site niet hoeven // te wijzigen tot een aparte cleanup-pass. export function getIdeaPromptText(kind: ClaudeJobKind): string { - if (kind !== 'IDEA_GRILL' && kind !== 'IDEA_MAKE_PLAN') return '' + if (kind !== 'IDEA_GRILL' && kind !== 'IDEA_MAKE_PLAN' && kind !== 'IDEA_REVIEW_PLAN') return '' return getKindPromptText(kind) } diff --git a/src/prompts/idea/review-plan.md b/src/prompts/idea/review-plan.md new file mode 100644 index 0000000..8df45f6 --- /dev/null +++ b/src/prompts/idea/review-plan.md @@ -0,0 +1,210 @@ +# Review-Plan-prompt voor IDEA_REVIEW_PLAN-jobs + +> Deze prompt wordt door `wait_for_job` meegestuurd in de payload van een +> `IDEA_REVIEW_PLAN`-job. Dit is een **iteratieve review met actieve plan-revisie** +> en convergence-detectie. Je coördineert drie review-rondes, herschrijft het plan +> na elke ronde, en slaat het review-log op via `update_idea_plan_reviewed`. + +--- + +Je bent een **plan-review-orchestrator** voor Scrum4Me-idee `{idea_code}`. + +Je context (meegegeven in `wait_for_job`-payload): + +- `idea.plan_md`: het te reviewen plan-document (YAML frontmatter + body) +- `idea.grill_md`: context uit de grill-fase (scope, acceptatie, risico's) +- `product`: gekoppeld product met `definition_of_done` en repo-context +- `repo_url`: lokale repo om bestaande patronen/code te raadplegen + +## Doel + +Drie iteratieve review-rondes uitvoeren, gericht op verschillende aspecten. Na +elke ronde herschrijf je het plan actief en sla je de herziene versie op in de +database. De reviews werken op convergentie af: zodra het plan stabiel is +(< 5% wijzigingen twee rondes achter elkaar), vraag je om goedkeuring. + +**Belangrijk:** het plan wordt bij elke ronde daadwerkelijk verbeterd en +gepersisteerd via `update_idea_plan_md`. Dit is geen passieve review — je +coördineert een actief verbeterproces. + +## Werkwijze + +### Setup (voor ronde 1) + +1. Lees `idea.plan_md` volledig — dit is de startversie van het plan. +2. Lees `idea.grill_md` voor scope/acceptatiecriteria-context. +3. **Laad codex** (verplicht, niet optioneel): + - Glob + Read alle `docs/patterns/**/*.md` → architectuurpatronen + - Glob + Read alle `docs/architecture/**/*.md` → systeemdesign + - Read `CLAUDE.md` → hardstop-regels (nooit schenden) + - Gebruik deze als leidraad bij elke review-ronde +4. Initialiseer `review_log`: + ```json + { "plan_file": "{idea_code}", "created_at": "", + "rounds": [], "approval": { "status": "pending" } } + ``` + +### Per Review-Ronde + +**Ronde 1 — Structuur & Syntax (Haiku-perspectief: snel en scherp)** +- Rol: structuur-reviewer — focus op correctheid, niet op inhoud +- Controleer: YAML parseable, alle verplichte velden aanwezig, geen lege strings, + priority-waarden valid (1–4), markdown-structuur intact +- Herschrijf plan_md: corrigeer structuurfouten en formatting +- *Opmerking multi-model:* directe Haiku API-call is momenteel niet beschikbaar + via job-config; voer deze rol zelf uit met een compacte, syntax-gerichte blik + +**Ronde 2 — Logica & Patronen (Sonnet-perspectief: diep en patroon-bewust)** +- Rol: architectuur-reviewer — focus op logica, volledigheid en patroonconformiteit +- Controleer: stories volgen uit grill-criteria, tasks zijn concreet + (bestandsnamen, commando's), patterns uit `docs/patterns/` worden gevolgd, + `verify_required` coherent, dependency-cascades geadresseerd +- Herschrijf plan_md: vul gaten aan, maak tasks specifieker, voeg missende stappen toe + +**Ronde 3 — Risico & Edge Cases (Opus-perspectief: kritisch en breed)** +- Rol: risico-reviewer — focus op wat mis kan gaan +- Controleer: grote taken gesplitst, refactors hebben undo-strategie, + schema-changes hebben migratie-taken, type-checking expliciet, concurrency + geadresseerd, error-handling per actie, feature-flags voor grote changes +- Herschrijf plan_md: voeg risico-mitigatie toe, split te grote taken + +### Plan Revision (na elke ronde — verplicht) + +Na het uitvoeren van de review-criteria: + +1. Sla de huidige versie op als `plan_before` in `review_log.rounds[N]`. +2. Herschrijf `plan_md` — integreer de gevonden verbeteringen. +3. Bereken `diff_pct = changed_lines / total_lines * 100`. +4. Sla de herziene versie op als `plan_after` in `review_log.rounds[N]`. +5. **Persisteer de herziene versie** via: + ``` + update_idea_plan_md({ idea_id: , plan_md: }) + ``` + Dit slaat het verbeterde plan op in de database zodat de gebruiker + de progressie ziet. Sla dit stap niet over — ook al zijn er weinig + wijzigingen. + +### Convergence Detection + +Na elke ronde (m.u.v. ronde 0): +``` +diff_pct_this_round = changed_lines / total_lines * 100 +if diff_pct_this_round < 5 AND prev_round_diff_pct < 5: + → CONVERGED +``` + +Indien converged (of na ronde 2 als max bereikt): +- Sla op: `review_log.convergence = { stable_at_round: N, final_diff_pct, convergence_metric: "plan_stability" }` +- Vraag goedkeuring via `ask_user_question` + +## Review-Criteria per Ronde + +### Ronde 1 — Structuur & Syntax +- [ ] Frontmatter YAML parseable +- [ ] Alle verplichte velden aanwezig (`pbi.title`, `stories`, `tasks`) +- [ ] Priority-waarden valid (1–4) +- [ ] Geen lege strings in verplichte velden +- [ ] Markdown-structuur correct (headers, code-blocks) + +### Ronde 2 — Logica & Patronen +- [ ] Stories volgen logisch uit grill-acceptance-criteria +- [ ] Tasks zijn concreet (bestandsnamen, commando's, niet abstract) +- [ ] Dependency-cascade-checks uitgevoerd (bij removal/refactor) +- [ ] Patronen uit `docs/patterns/` worden gevolgd +- [ ] Implementatie-plan per task is actionable +- [ ] `verify_required` waarden coherent met task-scope + +### Ronde 3 — Risico & Edge Cases +- [ ] Grote taken (> 4u) zijn gesplitst in subtaken +- [ ] Refactors hebben een undo/rollback-strategie +- [ ] Schema-changes hebben migratie-taken +- [ ] Type-checking wordt expliciet geverifieerd (einde-taak) +- [ ] Concurrency-issues / race-conditions geadresseerd +- [ ] Error-handling per actie duidelijk +- [ ] Feature-flags ingebouwd voor grote of riskante changes + +## Stappen (uitgebreid algoritme) + +1. **Init** + - Lees plan_md + grill_md. + - Laad codex (docs/patterns, docs/architecture, CLAUDE.md). + - Initialiseer `review_log`. + +2. **Loop: for round in [0, 1, 2]** + - Voer review uit (focus per ronde: structuur / logica / risico). + - Sla `plan_before` op. + - Herschrijf plan_md op basis van bevindingen. + - Roep `update_idea_plan_md` aan met de herziene tekst. + - Sla `plan_after` + `issues` + `score` + `diff_pct` op in review_log. + - Check convergence (na ronde 1+). + - Break indien converged. + +3. **Approval Gate** + - Vraag via `ask_user_question`: + "Plan beoordeeld ({N} rondes, {X}% eindwijziging). Goedkeuren?" + - Opties: `["Ja, accepteren", "Nee, aanpassingen gewenst", "Opnieuw reviewen"]` + - "Ja": `approval.status = 'approved'` → ga door naar Save & Close. + - "Nee": `approval.status = 'rejected'` → sluit af (user kan handmatig editen). + - "Opnieuw": max 2 extra rondes (rondes 3–4), dan dwingend approval vragen. + +4. **Save & Close** + - Call `update_idea_plan_reviewed({ idea_id, review_log, approval_status })`. + - Call `update_job_status({ job_id, status: 'done', summary: review_log.summary })`. + +## Output-format review_log (strikt JSON) + +```json +{ + "plan_file": "IDEA-016", + "created_at": "ISO8601", + "rounds": [ + { + "round": 0, + "model": "claude-opus-4-7", + "role": "Structure Review", + "focus": "YAML parsing, format, syntax", + "plan_before": "", + "plan_after": "", + "issues": [ + { + "category": "structure|logic|risk|pattern", + "severity": "error|warning|info", + "suggestion": "wat te fixen" + } + ], + "score": 75, + "plan_diff_lines": 12, + "converged": false, + "timestamp": "ISO8601" + } + ], + "convergence": { + "stable_at_round": 2, + "final_diff_pct": 2.1, + "convergence_metric": "plan_stability" + }, + "approval": { + "status": "pending|approved|rejected", + "timestamp": "ISO8601" + }, + "summary": "1–2 zinnen samenvatting: X rondes, Y% wijziging, status" +} +``` + +## Foutgevallen + +- **Plan parse-fout**: `update_job_status('failed', error: 'plan_parse_failed')` — stop. +- **update_idea_plan_md mislukt**: log error in review_log, ga door met review — niet fataal. +- **Gebruiker annuleert**: sluit netjes af; job wordt door server op CANCELLED gezet. +- **Vraag verloopt**: sla partial review-log op via `update_idea_plan_reviewed`, markeer als `rejected`. + +## Aannames & Limieten + +- **Multi-model:** directe Haiku/Sonnet API-calls zijn niet beschikbaar via de huidige + job-config architectuur. Alle rondes draaien op het geconfigureerde Opus model. + De rollen (structuur / logica / risico) worden wel strikt gescheiden gehouden. + Toekomst: directe model-switching via Anthropic API. +- Plan bevat geen versleutelde data (review-log opgeslagen als JSON in DB). +- Repo is leesbaar; geen network-fouts verwacht. +- Max 2 extra review-rondes buiten de initiële 3 (max 5 rondes totaal). +- Per ronde: max 10 issues gelogd (overige → samenvatting in `summary`). diff --git a/src/tools/create-story.ts b/src/tools/create-story.ts index cfa099e..37caa59 100644 --- a/src/tools/create-story.ts +++ b/src/tools/create-story.ts @@ -1,8 +1,9 @@ // MCP authoring tool: create een Story onder een bestaande PBI. // // product_id wordt afgeleid uit de PBI (denormalized FK conform CLAUDE.md -// convention — nooit vertrouwen op client-input). status='OPEN' default; -// landt in de Product Backlog, niet auto in een sprint. +// convention — nooit vertrouwen op client-input). Zonder sprint_id is +// status='OPEN' en landt de story in de Product Backlog; mét sprint_id +// wordt de story direct aan die sprint gekoppeld (status='IN_SPRINT'). import { z } from 'zod' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' @@ -46,75 +47,108 @@ const inputSchema = z.object({ acceptance_criteria: z.string().max(4000).optional(), priority: z.number().int().min(1).max(4), sort_order: z.number().optional(), + // Optionele sprint-koppeling: bij creatie de story direct aan een sprint + // hangen (status=IN_SPRINT). De sprint moet bij hetzelfde product horen. + sprint_id: z.string().min(1).optional(), }) +export async function handleCreateStory( + { + pbi_id, + title, + description, + acceptance_criteria, + priority, + sort_order, + sprint_id, + }: z.infer, +) { + return withToolErrors(async () => { + const auth = await requireWriteAccess() + + const pbi = await prisma.pbi.findUnique({ + where: { id: pbi_id }, + select: { product_id: true }, + }) + if (!pbi) return toolError(`PBI ${pbi_id} not found`) + if (!(await userCanAccessProduct(pbi.product_id, auth.userId))) { + return toolError(`PBI ${pbi_id} not accessible`) + } + + // Optionele sprint-koppeling: valideer dat de sprint bestaat én bij + // hetzelfde product hoort — voorkomt een cross-product koppeling. + if (sprint_id !== undefined) { + const sprint = await prisma.sprint.findUnique({ + where: { id: sprint_id }, + select: { product_id: true }, + }) + if (!sprint) return toolError(`Sprint ${sprint_id} not found`) + if (sprint.product_id !== pbi.product_id) { + return toolError( + `Sprint ${sprint_id} belongs to a different product than PBI ${pbi_id}`, + ) + } + } + + let resolvedSortOrder = sort_order + if (resolvedSortOrder === undefined) { + const last = await prisma.story.findFirst({ + where: { pbi_id, priority }, + orderBy: { sort_order: 'desc' }, + select: { sort_order: true }, + }) + resolvedSortOrder = (last?.sort_order ?? 0) + 1.0 + } + + let lastError: unknown + for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) { + const code = await generateNextStoryCode(pbi.product_id) + try { + const story = await prisma.story.create({ + data: { + pbi_id, + product_id: pbi.product_id, // denormalized uit DB-parent, niet uit input + sprint_id: sprint_id ?? null, + code, + title, + description: description ?? null, + acceptance_criteria: acceptance_criteria ?? null, + priority, + sort_order: resolvedSortOrder, + status: sprint_id ? 'IN_SPRINT' : 'OPEN', + }, + select: { + id: true, + code: true, + title: true, + description: true, + acceptance_criteria: true, + priority: true, + sort_order: true, + status: true, + sprint_id: true, + created_at: true, + }, + }) + return toolJson(story) + } catch (e) { + if (isCodeUniqueConflict(e)) { lastError = e; continue } + throw e + } + } + throw lastError ?? new Error('Kon geen unieke Story-code genereren') + }) +} + export function registerCreateStoryTool(server: McpServer) { server.registerTool( 'create_story', { title: 'Create story', description: - 'Add a story under an existing PBI. Status defaults to OPEN (lands in product backlog, not in a sprint). Sort_order auto-set to last+1 within the PBI/priority group if not provided. Forbidden for demo accounts.', + 'Add a story under an existing PBI. Optionally link it to a sprint via sprint_id — when given, the story is created with status=IN_SPRINT and the sprint must belong to the same product as the PBI; otherwise status=OPEN and the story lands in the product backlog. Sort_order auto-set to last+1 within the PBI/priority group if not provided. Forbidden for demo accounts.', inputSchema, }, - async ({ pbi_id, title, description, acceptance_criteria, priority, sort_order }) => - withToolErrors(async () => { - const auth = await requireWriteAccess() - - const pbi = await prisma.pbi.findUnique({ - where: { id: pbi_id }, - select: { product_id: true }, - }) - if (!pbi) return toolError(`PBI ${pbi_id} not found`) - if (!(await userCanAccessProduct(pbi.product_id, auth.userId))) { - return toolError(`PBI ${pbi_id} not accessible`) - } - - let resolvedSortOrder = sort_order - if (resolvedSortOrder === undefined) { - const last = await prisma.story.findFirst({ - where: { pbi_id, priority }, - orderBy: { sort_order: 'desc' }, - select: { sort_order: true }, - }) - resolvedSortOrder = (last?.sort_order ?? 0) + 1.0 - } - - let lastError: unknown - for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) { - const code = await generateNextStoryCode(pbi.product_id) - try { - const story = await prisma.story.create({ - data: { - pbi_id, - product_id: pbi.product_id, // denormalized uit DB-parent, niet uit input - code, - title, - description: description ?? null, - acceptance_criteria: acceptance_criteria ?? null, - priority, - sort_order: resolvedSortOrder, - status: 'OPEN', - }, - select: { - id: true, - code: true, - title: true, - description: true, - acceptance_criteria: true, - priority: true, - sort_order: true, - status: true, - created_at: true, - }, - }) - return toolJson(story) - } catch (e) { - if (isCodeUniqueConflict(e)) { lastError = e; continue } - throw e - } - } - throw lastError ?? new Error('Kon geen unieke Story-code genereren') - }), + handleCreateStory, ) } diff --git a/src/tools/update-idea-plan-reviewed.ts b/src/tools/update-idea-plan-reviewed.ts new file mode 100644 index 0000000..0217c22 --- /dev/null +++ b/src/tools/update-idea-plan-reviewed.ts @@ -0,0 +1,116 @@ +// MCP-tool: writes the review-log result after a IDEA_REVIEW_PLAN grill-job +// and transitions the idea.status to PLAN_REVIEWED (on success) or +// PLAN_REVIEW_FAILED (on failure). +// +// Called by the worker as the final step of a review-plan session. + +import { z } from 'zod' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' + +import { prisma } from '../prisma.js' +import { requireWriteAccess } from '../auth.js' +import { userOwnsIdea } from '../access.js' +import { toolError, toolJson, withToolErrors } from '../errors.js' + +const inputSchema = z.object({ + idea_id: z.string().min(1), + review_log: z.object({}).passthrough(), // Full ReviewLog from orchestrator (JSON object) + approval_status: z + .enum(['pending', 'approved', 'rejected'] as const) + .optional(), +}) + +export function registerUpdateIdeaPlanReviewedTool(server: McpServer) { + server.registerTool( + 'update_idea_plan_reviewed', + { + title: 'Mark plan as reviewed', + description: + 'Save review-log after plan review cycle and transition idea.status to PLAN_REVIEWED (if approved) or PLAN_REVIEW_FAILED (if rejected/pending requires manual approval). Forbidden for demo accounts.', + inputSchema, + }, + async ({ idea_id, review_log, approval_status }) => + withToolErrors(async () => { + const auth = await requireWriteAccess() + if (!(await userOwnsIdea(idea_id, auth.userId))) { + return toolError('Idea not found') + } + + // Determine target status based on approval + const nextStatus = + approval_status === 'approved' + ? 'PLAN_REVIEWED' + : approval_status === 'rejected' + ? 'PLAN_REVIEW_FAILED' + : 'PLAN_REVIEWED' // Default to approved if not specified + + // Log summary metrics from review_log + const logSummary = buildReviewLogSummary(review_log) + + const result = await prisma.$transaction([ + prisma.idea.update({ + where: { id: idea_id }, + data: { + plan_review_log: review_log as any, + reviewed_at: new Date(), + status: nextStatus, + }, + select: { id: true, status: true, code: true }, + }), + prisma.ideaLog.create({ + data: { + idea_id, + type: 'PLAN_REVIEW_RESULT', + content: logSummary.summary, + metadata: { + approval_status, + convergence_status: logSummary.convergence_status, + final_score: logSummary.final_score, + rounds_completed: logSummary.rounds_completed, + }, + }, + }), + ]) + + return toolJson({ + ok: true, + idea: result[0], + review_log_summary: logSummary, + }) + }), + ) +} + +function buildReviewLogSummary( + reviewLog: Record, +): { + summary: string + convergence_status: string + final_score: number + rounds_completed: number +} { + const rounds = Array.isArray(reviewLog.rounds) ? reviewLog.rounds : [] + const convergence = reviewLog.convergence || {} + const finalScore = + rounds.length > 0 ? rounds[rounds.length - 1].score ?? 0 : 0 + + const convergenceStatus = + convergence.stable_at_round !== undefined + ? `stable at round ${convergence.stable_at_round}` + : convergence.final_diff_pct !== undefined + ? `${convergence.final_diff_pct}% diff` + : 'pending' + + const summary = + `Plan reviewed in ${rounds.length} rounds. ` + + `Convergence: ${convergenceStatus}. ` + + `Final score: ${finalScore}/100. ` + + `Status: ${reviewLog.approval?.status || 'pending'}.` + + return { + summary, + convergence_status: convergenceStatus, + final_score: finalScore, + rounds_completed: rounds.length, + } +} diff --git a/src/tools/wait-for-job.ts b/src/tools/wait-for-job.ts index 96c11ba..f3e11c0 100644 --- a/src/tools/wait-for-job.ts +++ b/src/tools/wait-for-job.ts @@ -508,7 +508,7 @@ export async function getFullJobContext(jobId: string) { // M12: branch on kind. Idea-jobs hebben geen task/story/pbi/sprint; ze // hebben in plaats daarvan idea + embedded prompt_text. - if (job.kind === 'IDEA_GRILL' || job.kind === 'IDEA_MAKE_PLAN') { + if (job.kind === 'IDEA_GRILL' || job.kind === 'IDEA_MAKE_PLAN' || job.kind === 'IDEA_REVIEW_PLAN') { if (!job.idea) return null const { idea } = job const { getIdeaPromptText } = await import('../lib/kind-prompts.js') @@ -569,7 +569,11 @@ export async function getFullJobContext(jobId: string) { pbi: idea.pbi, repo_url: job.product.repo_url, prompt_text: getIdeaPromptText(job.kind), - branch_suggestion: `feat/idea-${idea.code.toLowerCase()}-${job.kind === 'IDEA_GRILL' ? 'grill' : 'plan'}`, + branch_suggestion: `feat/idea-${idea.code.toLowerCase()}-${(() => { + if (job.kind === 'IDEA_GRILL') return 'grill' + if (job.kind === 'IDEA_REVIEW_PLAN') return 'review' + return 'plan' + })()}`, product_worktrees: worktrees.map((w) => ({ product_id: w.productId, worktree_path: w.worktreePath, diff --git a/vendor/scrum4me b/vendor/scrum4me index 3c77342..7bb252c 160000 --- a/vendor/scrum4me +++ b/vendor/scrum4me @@ -1 +1 @@ -Subproject commit 3c773421dacaf506bf35a8270249822cf509ccf3 +Subproject commit 7bb252c528d810584bcb46a56cff3d26ebf392ff