From fdf3dc4471a1df29e8c5b9e67aa623eeaa6f4782 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 22:12:36 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20M12=20idea-job=20support=20?= =?UTF-8?q?=E2=80=94=20version=200.6.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the 4 new MCP-tools for the Scrum4Me M12 Idea-entity flow + extends 3 existing tools to handle the new ClaudeJobKind discriminator. New tools: - get_idea_context: full idea + product + open questions + recent logs - update_idea_grill_md: save grill-result + status → GRILLED + IdeaLog - update_idea_plan_md: server-side yaml parser validates frontmatter; ok → PLAN_READY, fail → PLAN_FAILED + line-info errors - log_idea_decision: DECISION/NOTE entries on the timeline Extended tools: - ask_user_question: xor schema (story_id | idea_id); idea-questions are user-private with productId derived from idea.product_id - wait_for_job: returns \`kind\` discriminator; IDEA_* payloads include idea + prompt_text (from src/prompts/idea/) and skip worktree creation - update_job_status: failed on IDEA_* auto-transitions idea-status to GRILL_FAILED / PLAN_FAILED + IdeaLog{JOB_EVENT}; auto-PR + worktree- cleanup skipped for idea-jobs Other changes: - Health version now read dynamically from package.json (was hardcoded '0.1.0' which caused deploy-sync confusion) - Schema synced to Scrum4Me M12 (Idea + IdeaLog + enums + ClaudeJob/ Question nullable-FKs + check-constraints + pg_notify-trigger update) - New @scrum4me-mcp/lib/idea-plan-parser duplicates Scrum4Me's parser (drift detected by vendor schema-watchdog) - Embedded grill+make-plan prompts copied to src/prompts/idea/ - New userOwnsIdea access helper Tests: 153/153 green; tsc + build clean. Migration: requires Scrum4Me M12 migration (20260504172747_add_ideas_and_grill_jobs) applied on the target DB. See vendor/scrum4me/docs/runbooks/mcp-integration.md for the updated batch-loop with kind-switch. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 35 ++++ package-lock.json | 20 ++- package.json | 3 +- prisma/schema.prisma | 282 +++++++++++++++++++----------- src/access.ts | 10 ++ src/index.ts | 26 ++- src/lib/idea-plan-parser.ts | 97 ++++++++++ src/lib/idea-prompts.ts | 32 ++++ src/prompts/idea/grill.md | 98 +++++++++++ src/prompts/idea/make-plan.md | 129 ++++++++++++++ src/tools/ask-user-question.ts | 88 +++++++--- src/tools/get-idea-context.ts | 121 +++++++++++++ src/tools/health.ts | 18 +- src/tools/log-idea-decision.ts | 57 ++++++ src/tools/update-idea-grill-md.ts | 57 ++++++ src/tools/update-idea-plan-md.ts | 90 ++++++++++ src/tools/update-job-status.ts | 43 ++++- src/tools/wait-for-job.ts | 80 ++++++++- 18 files changed, 1140 insertions(+), 146 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 src/lib/idea-plan-parser.ts create mode 100644 src/lib/idea-prompts.ts create mode 100644 src/prompts/idea/grill.md create mode 100644 src/prompts/idea/make-plan.md create mode 100644 src/tools/get-idea-context.ts create mode 100644 src/tools/log-idea-decision.ts create mode 100644 src/tools/update-idea-grill-md.ts create mode 100644 src/tools/update-idea-plan-md.ts diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5d9b4a0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,35 @@ +# Changelog + +All notable changes to scrum4me-mcp. + +## [0.6.0] — 2026-05-04 + +Adds support for Scrum4Me M12 (Idea entity + Grill/Plan jobs). + +### Added + +- **`get_idea_context(idea_id)`** — fetch full idea + product + recent logs + open questions for agent context. +- **`update_idea_grill_md(idea_id, markdown)`** — save grill-result + transition to GRILLED + IdeaLog{GRILL_RESULT}. +- **`update_idea_plan_md(idea_id, markdown)`** — save plan with server-side yaml-frontmatter validation; ok → PLAN_READY, parse-fail → PLAN_FAILED + IdeaLog{JOB_EVENT, errors}. +- **`log_idea_decision(idea_id, type, content, metadata?)`** — DECISION/NOTE entries on the idea timeline. + +### Changed + +- **`ask_user_question`** — now accepts exact one of `story_id` OR `idea_id` (zod xor refine). Idea-questions are user-private (owner-scoped, no productAccessFilter). +- **`wait_for_job`** — response now includes `kind: 'TASK_IMPLEMENTATION' | 'IDEA_GRILL' | 'IDEA_MAKE_PLAN'`. For idea-jobs the payload returns `idea`, `product`, `repo_url`, `prompt_text` (embedded prompt from `src/prompts/idea/`) and **no worktree** (agent works in user's existing repo). +- **`update_job_status`** — for `failed` on `IDEA_GRILL` / `IDEA_MAKE_PLAN`: idea status auto-transitions to `GRILL_FAILED` / `PLAN_FAILED` + IdeaLog{JOB_EVENT}. Auto-PR + worktree-cleanup skipped for idea-jobs. +- **Health version** — now read dynamically from `package.json` at module load (was hardcoded; resolved sync-issues at deploy time). + +### Schema + +- Vendored `prisma/schema.prisma` synced with Scrum4Me M12 (Idea + IdeaLog models, IdeaStatus + ClaudeJobKind + IdeaLogType enums, ClaudeJob.task_id nullable + idea_id + kind, ClaudeQuestion.story_id nullable + idea_id, check-constraints, pg_notify-trigger update). +- Pinned to scrum4me commit on branch `feat/m12-ideas` until merged to main. + +### Migration notes + +- Requires Scrum4Me database to have M12 migration applied (`20260504172747_add_ideas_and_grill_jobs`). +- Worker runtime: see `vendor/scrum4me/docs/runbooks/mcp-integration.md` — batch-loop now switches on `kind` discriminator. + +## [0.5.0] — earlier + +Version bump (no changelog entry). diff --git a/package-lock.json b/package-lock.json index 6a4cd17..54d0e01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "scrum4me-mcp", - "version": "0.3.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "scrum4me-mcp", - "version": "0.3.0", + "version": "0.5.0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -14,6 +14,7 @@ "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "pg": "^8.13.1", + "yaml": "^2.8.4", "zod": "^4.0.0" }, "bin": { @@ -4105,6 +4106,21 @@ "node": ">=0.4" } }, + "node_modules/yaml": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/zeptomatch": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", diff --git a/package.json b/package.json index 5378ea0..2cc41bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "scrum4me-mcp", - "version": "0.4.0", + "version": "0.6.0", "description": "MCP server for Scrum4Me — exposes dev-flow tools and prompts via the Model Context Protocol", "type": "module", "bin": { @@ -33,6 +33,7 @@ "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "pg": "^8.13.1", + "yaml": "^2.8.4", "zod": "^4.0.0" }, "devDependencies": { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5a0ab4f..f15b47c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -70,30 +70,58 @@ enum SprintStatus { COMPLETED } +enum IdeaStatus { + DRAFT + GRILLING + GRILL_FAILED + GRILLED + PLANNING + PLAN_FAILED + PLAN_READY + PLANNED +} + +enum ClaudeJobKind { + TASK_IMPLEMENTATION + IDEA_GRILL + IDEA_MAKE_PLAN +} + +enum IdeaLogType { + DECISION + NOTE + GRILL_RESULT + PLAN_RESULT + STATUS_CHANGE + JOB_EVENT +} + model User { - id String @id @default(cuid()) - username String @unique - email String? @unique - password_hash String - is_demo Boolean @default(false) - bio String? @db.VarChar(160) - bio_detail String? @db.VarChar(2000) - avatar_data Bytes? - active_product_id String? - active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - roles UserRole[] - api_tokens ApiToken[] - products Product[] - todos Todo[] - product_members ProductMember[] - assigned_stories Story[] @relation("StoryAssignee") - login_pairings LoginPairing[] - asked_questions ClaudeQuestion[] @relation("ClaudeQuestionAsker") - answered_questions ClaudeQuestion[] @relation("ClaudeQuestionAnswerer") - claude_jobs ClaudeJob[] - claude_workers ClaudeWorker[] + id String @id @default(cuid()) + username String @unique + email String? @unique + password_hash String + is_demo Boolean @default(false) + bio String? @db.VarChar(160) + bio_detail String? @db.VarChar(2000) + avatar_data Bytes? + active_product_id String? + active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull) + idea_code_counter Int @default(0) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + roles UserRole[] + api_tokens ApiToken[] + products Product[] + todos Todo[] + ideas Idea[] + product_members ProductMember[] + assigned_stories Story[] @relation("StoryAssignee") + login_pairings LoginPairing[] + asked_questions ClaudeQuestion[] @relation("ClaudeQuestionAsker") + answered_questions ClaudeQuestion[] @relation("ClaudeQuestionAnswerer") + claude_jobs ClaudeJob[] + claude_workers ClaudeWorker[] @@index([active_product_id]) @@map("users") @@ -110,33 +138,33 @@ model UserRole { } model ApiToken { - id String @id @default(cuid()) - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) - user_id String - token_hash String @unique - label String? - created_at DateTime @default(now()) - revoked_at DateTime? - claimed_jobs ClaudeJob[] - claude_worker ClaudeWorker? + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_id String + token_hash String @unique + label String? + created_at DateTime @default(now()) + revoked_at DateTime? + claimed_jobs ClaudeJob[] + claude_worker ClaudeWorker? @@index([token_hash]) @@map("api_tokens") } model Product { - 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 name String - code String? @db.VarChar(30) + code String? @db.VarChar(30) description String? repo_url String? definition_of_done String - auto_pr Boolean @default(false) - archived Boolean @default(false) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt + auto_pr Boolean @default(false) + archived Boolean @default(false) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt pbis Pbi[] sprints Sprint[] stories Story[] @@ -146,6 +174,7 @@ model Product { active_for_users User[] @relation("UserActiveProduct") claude_questions ClaudeQuestion[] claude_jobs ClaudeJob[] + ideas Idea[] @@unique([user_id, name]) @@unique([user_id, code]) @@ -154,20 +183,21 @@ model Product { } model Pbi { - id String @id @default(cuid()) - product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) - product_id String - code String @db.VarChar(30) - title String - description String? - priority Int - sort_order Float + id String @id @default(cuid()) + product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) + product_id String + code String @db.VarChar(30) + title String + description String? + priority Int + sort_order Float status PbiStatus @default(READY) pr_url String? pr_merged_at DateTime? created_at DateTime @default(now()) updated_at DateTime @updatedAt stories Story[] + idea Idea? @@unique([product_id, code]) @@index([product_id, priority, sort_order]) @@ -176,24 +206,24 @@ model Pbi { } model Story { - id String @id @default(cuid()) - pbi Pbi @relation(fields: [pbi_id], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + pbi Pbi @relation(fields: [pbi_id], references: [id], onDelete: Cascade) pbi_id String - product Product @relation(fields: [product_id], references: [id]) + product Product @relation(fields: [product_id], references: [id]) product_id String - sprint Sprint? @relation(fields: [sprint_id], references: [id]) + sprint Sprint? @relation(fields: [sprint_id], references: [id]) sprint_id String? - assignee User? @relation("StoryAssignee", fields: [assignee_id], references: [id], onDelete: SetNull) + assignee User? @relation("StoryAssignee", fields: [assignee_id], references: [id], onDelete: SetNull) assignee_id String? - code String @db.VarChar(30) + code String @db.VarChar(30) title String description String? acceptance_criteria String? priority Int sort_order Float - status StoryStatus @default(OPEN) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt + status StoryStatus @default(OPEN) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt logs StoryLog[] tasks Task[] claude_questions ClaudeQuestion[] @@ -240,29 +270,29 @@ model Sprint { } model Task { - id String @id @default(cuid()) - story Story @relation(fields: [story_id], references: [id], onDelete: Cascade) + 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 Product @relation(fields: [product_id], references: [id], onDelete: Cascade) product_id String - sprint Sprint? @relation(fields: [sprint_id], references: [id]) + sprint Sprint? @relation(fields: [sprint_id], references: [id]) sprint_id String? - code String @db.VarChar(30) + 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) + 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 + created_at DateTime @default(now()) + updated_at DateTime @updatedAt claude_questions ClaudeQuestion[] claude_jobs ClaudeJob[] @@ -279,8 +309,11 @@ model ClaudeJob { user_id String product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) product_id String - task Task @relation(fields: [task_id], references: [id], onDelete: Cascade) - task_id String + task Task? @relation(fields: [task_id], references: [id], onDelete: Cascade) + task_id String? + idea Idea? @relation(fields: [idea_id], references: [id], onDelete: Cascade) + idea_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) claimed_by_token_id String? @@ -300,20 +333,21 @@ model ClaudeJob { @@index([user_id, status]) @@index([task_id, status]) + @@index([idea_id, status]) @@index([status, claimed_at]) @@index([status, finished_at]) @@map("claude_jobs") } model ClaudeWorker { - 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 - token ApiToken @relation(fields: [token_id], references: [id], onDelete: Cascade) + token ApiToken @relation(fields: [token_id], references: [id], onDelete: Cascade) token_id String product_id String? - started_at DateTime @default(now()) - last_seen_at DateTime @default(now()) + started_at DateTime @default(now()) + last_seen_at DateTime @default(now()) @@unique([token_id]) @@index([user_id, last_seen_at]) @@ -334,23 +368,64 @@ model ProductMember { } model Todo { - 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? - title String - description String? @db.VarChar(2000) - done Boolean @default(false) - 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? + title String + description String? @db.VarChar(2000) + done Boolean @default(false) + archived Boolean @default(false) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt @@index([user_id, done, archived]) @@index([user_id, product_id]) @@map("todos") } +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 + + questions ClaudeQuestion[] + jobs ClaudeJob[] + logs IdeaLog[] + + @@unique([user_id, code]) + @@index([user_id, archived, status]) + @@index([user_id, product_id]) + @@map("ideas") +} + +model IdeaLog { + id String @id @default(cuid()) + idea Idea @relation(fields: [idea_id], references: [id], onDelete: Cascade) + idea_id String + type IdeaLogType + content String @db.Text + metadata Json? + created_at DateTime @default(now()) + + @@index([idea_id, created_at]) + @@map("idea_logs") +} + model LoginPairing { id String @id @default(cuid()) secret_hash String @@ -371,26 +446,29 @@ model LoginPairing { } model ClaudeQuestion { - id String @id @default(cuid()) - story Story @relation(fields: [story_id], references: [id], onDelete: Cascade) - story_id String - task Task? @relation(fields: [task_id], references: [id], onDelete: SetNull) - task_id String? - product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) - product_id String // gedenormaliseerd uit story.product_id voor SSE-filter - asker User @relation("ClaudeQuestionAsker", fields: [asked_by], references: [id]) - asked_by String // user_id van token-houder (= Claude-token) - question String @db.Text - options Json? // string[] voor multi-choice; null voor free-text - status String // 'open' | 'answered' | 'cancelled' | 'expired' - answer String? @db.Text - answerer User? @relation("ClaudeQuestionAnswerer", fields: [answered_by], references: [id]) - answered_by String? - answered_at DateTime? - created_at DateTime @default(now()) - expires_at DateTime // ingesteld door MCP-tool, default now() + 24h + id String @id @default(cuid()) + story Story? @relation(fields: [story_id], references: [id], onDelete: Cascade) + story_id String? + task Task? @relation(fields: [task_id], references: [id], onDelete: SetNull) + task_id String? + idea Idea? @relation(fields: [idea_id], references: [id], onDelete: Cascade) + idea_id String? + product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) + product_id String // gedenormaliseerd uit story.product_id voor SSE-filter + asker User @relation("ClaudeQuestionAsker", fields: [asked_by], references: [id]) + asked_by String // user_id van token-houder (= Claude-token) + question String @db.Text + options Json? // string[] voor multi-choice; null voor free-text + status String // 'open' | 'answered' | 'cancelled' | 'expired' + answer String? @db.Text + answerer User? @relation("ClaudeQuestionAnswerer", fields: [answered_by], references: [id]) + answered_by String? + answered_at DateTime? + created_at DateTime @default(now()) + expires_at DateTime // ingesteld door MCP-tool, default now() + 24h @@index([story_id, status]) + @@index([idea_id, status]) @@index([product_id, status]) @@index([status, expires_at]) @@map("claude_questions") diff --git a/src/access.ts b/src/access.ts index 37bb38e..5b2c068 100644 --- a/src/access.ts +++ b/src/access.ts @@ -28,3 +28,13 @@ export async function userCanAccessStory(storyId: string, userId: string): Promi if (!story) return false return userCanAccessProduct(story.product_id, userId) } + +// M12: idee is strikt user_id-only (geen productAccessFilter — Q8). +// Idea-questions, idea-jobs, en idea-md-mutaties scopen op de eigenaar. +export async function userOwnsIdea(ideaId: string, userId: string): Promise { + const idea = await prisma.idea.findUnique({ + where: { id: ideaId }, + select: { user_id: true }, + }) + return idea !== null && idea.user_id === userId +} diff --git a/src/index.ts b/src/index.ts index 81dbc91..0585907 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,13 +24,32 @@ import { registerCleanupMyWorktreesTool } from './tools/cleanup-my-worktrees.js' import { registerCheckQueueEmptyTool } from './tools/check-queue-empty.js' import { registerSetPbiPrTool } from './tools/set-pbi-pr.js' 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 { registerLogIdeaDecisionTool } from './tools/log-idea-decision.js' import { registerImplementNextStoryPrompt } from './prompts/implement-next-story.js' import { getAuth } from './auth.js' import { registerWorker } from './presence/worker.js' import { startHeartbeat } from './presence/heartbeat.js' import { registerShutdownHandlers } from './presence/shutdown.js' -const VERSION = '0.3.0' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +// Read version dynamically from package.json — voorheen hardcoded en +// veroorzaakte sync-issues bij deployment. Lees op module-load. +function readPkgVersion(): string { + try { + const here = dirname(fileURLToPath(import.meta.url)) + const pkgPath = join(here, '..', 'package.json') + return (JSON.parse(readFileSync(pkgPath, 'utf8')) as { version?: string }).version ?? '0.0.0' + } catch { + return '0.0.0' + } +} +const VERSION = readPkgVersion() async function main() { const server = new McpServer( @@ -65,6 +84,11 @@ async function main() { registerCheckQueueEmptyTool(server) registerSetPbiPrTool(server) registerMarkPbiPrMergedTool(server) + // M12: idee-job tools + registerGetIdeaContextTool(server) + registerUpdateIdeaGrillMdTool(server) + registerUpdateIdeaPlanMdTool(server) + registerLogIdeaDecisionTool(server) registerImplementNextStoryPrompt(server) // Presence bootstrap MUST run before server.connect — the stdio transport diff --git a/src/lib/idea-plan-parser.ts b/src/lib/idea-plan-parser.ts new file mode 100644 index 0000000..32e07df --- /dev/null +++ b/src/lib/idea-plan-parser.ts @@ -0,0 +1,97 @@ +// MCP-side port van scrum4me/lib/idea-plan-parser.ts (M12). +// +// Parser voor de plan_md die make-plan-job produceert: yaml-frontmatter +// (structuur) + markdown-body (vrije reasoning). Gebruikt door +// update_idea_plan_md voor server-side validatie vóór persistentie. +// +// LET OP: deze code is BEWUST een duplicaat van de Scrum4Me-parser om +// drift-detectie te krijgen via de vendor/scrum4me schema-watchdog. Houd +// het schema (zod-shape) in sync met scrum4me/lib/schemas/idea.ts. + +import { parse as parseYaml, YAMLParseError } from 'yaml' +import { z } from 'zod' + +const verifyRequiredEnum = z.enum(['ALIGNED', 'ALIGNED_OR_PARTIAL', 'ANY']) + +const planTaskSchema = z.object({ + title: z.string().min(1).max(200), + description: z.string().max(4000).optional(), + implementation_plan: z.string().max(8000).optional(), + priority: z.number().int().min(1).max(4), + verify_required: verifyRequiredEnum.optional(), + verify_only: z.boolean().optional(), +}) + +const planStorySchema = z.object({ + title: z.string().min(1).max(200), + description: z.string().max(4000).optional(), + acceptance_criteria: z.string().max(4000).optional(), + priority: z.number().int().min(1).max(4), + tasks: z.array(planTaskSchema).min(1, 'Story moet minimaal 1 taak hebben'), +}) + +const planPbiSchema = z.object({ + title: z.string().min(1).max(200), + description: z.string().max(4000).optional(), + priority: z.number().int().min(1).max(4), +}) + +export const ideaPlanMdFrontmatterSchema = z.object({ + pbi: planPbiSchema, + stories: z.array(planStorySchema).min(1, 'Plan moet minimaal 1 story bevatten'), +}) + +export type IdeaPlanFrontmatter = z.infer + +export type PlanParseError = { line?: number; message: string } + +export type PlanParseResult = + | { ok: true; plan: IdeaPlanFrontmatter; body: string } + | { ok: false; errors: PlanParseError[] } + +const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/ + +export function parsePlanMd(md: string): PlanParseResult { + const match = md.match(FRONTMATTER_RE) + if (!match) { + return { + ok: false, + errors: [ + { + line: 1, + message: 'Plan ontbreekt yaml-frontmatter. Verwacht eerste regel: ---', + }, + ], + } + } + + const [, frontmatterRaw, body] = match + + let parsed: unknown + try { + parsed = parseYaml(frontmatterRaw) + } catch (err) { + if (err instanceof YAMLParseError) { + return { + ok: false, + errors: [{ line: err.linePos?.[0]?.line, message: err.message }], + } + } + return { + ok: false, + errors: [{ message: err instanceof Error ? err.message : String(err) }], + } + } + + const validation = ideaPlanMdFrontmatterSchema.safeParse(parsed) + if (!validation.success) { + return { + ok: false, + errors: validation.error.issues.map((iss) => ({ + message: `${iss.path.join('.') || ''}: ${iss.message}`, + })), + } + } + + return { ok: true, plan: validation.data, body: body.trimStart() } +} 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/prompts/idea/grill.md b/src/prompts/idea/grill.md new file mode 100644 index 0000000..d5af711 --- /dev/null +++ b/src/prompts/idea/grill.md @@ -0,0 +1,98 @@ +# Grill-prompt voor IDEA_GRILL-jobs + +> Deze prompt wordt door `wait_for_job` meegestuurd in de payload van een +> `IDEA_GRILL`-job en gevolgd door de Claude-CLI-worker. Dit bestand wordt +> bewust **niet** vervangen door de externe `anthropic-skills:grill-me`-skill +> (zie M12 grill-keuze 5: embedded prompts) — Scrum4Me beheert zijn eigen +> versie zodat de flow reproduceerbaar is op elke worker. + +--- + +Je bent een **grill-agent** voor Scrum4Me-idee `{idea_code}` (titel: +`{idea_title}`). + +Je context (meegegeven in `wait_for_job`-payload): + +- `idea`: het volledige idee-record incl. eventueel bestaande `grill_md` +- `product`: het gekoppelde product (incl. `repo_url` en `definition_of_done`) +- `repo_url`: lokale repo om te lezen (worker bevindt zich daar al) + +## Doel + +Het idee zó concretiseren dat de **make-plan**-fase er een implementeerbaar +PBI van kan maken. Eindresultaat is een markdown-document dat je via +`mcp__scrum4me__update_idea_grill_md` opslaat. + +## Werkwijze (loop, één vraag per cyclus) + +1. Lees de huidige `idea.title`, `idea.description`, en (indien aanwezig) + `idea.grill_md` — bij re-grill bouw je voort op wat er al staat, je gooit + het niet weg. +2. Verken de repo voor context: `README`, `docs/`, `package.json`, en relevante + source-bestanden. Gebruik `Read`/`Grep`/`Glob` zoals normaal. +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`). +4. Verwerk het antwoord: log belangrijke beslissingen via + `mcp__scrum4me__log_idea_decision({ idea_id, type: 'DECISION'|'NOTE', + content })`. +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 })`. + +## Stop-conditie + +Je hebt genoeg wanneer je markdown bevat: + +- **Titel + scope** (1–3 zinnen) +- **Minimaal 3 acceptatiepunten** (gedrag dat zichtbaar moet werken) +- **Minimaal 1 risico/onbekende** (technisch, scope, afhankelijkheden) +- **Open eindjes** (wat opzettelijk **niet** in v1 zit) + +Stop óók als de gebruiker expliciet zegt "klaar" / "genoeg" / "ga door". + +## Output-format (strikt) + +```markdown +# Idee — {korte titel} + +## Scope +… + +## Acceptatie +- AC 1 +- AC 2 +- AC 3 + +## Risico's & onbekenden +- Risico 1 +- Onbekende 2 + +## Open eindjes (niet in v1) +- … +``` + +## Vraag-richtlijnen + +- **Scherp & specifiek**, geen open "wat denk je ervan?". +- Bij twijfel: bied **multi-choice** via `options: ["A", "B", "C"]`. +- Stel **één vraag per cyclus** — niet meerdere geneste. +- Vermijd vragen waarvan het antwoord uit de repo te lezen is — lees zelf. +- Geen meta-vragen ("zal ik nog meer vragen?"). Beslis zelf wanneer je stopt. + +## Foutgevallen + +- Vraag verloopt (24h): roep `update_job_status('failed', error: 'question expired')`. +- Repo niet leesbaar: roep `update_job_status('failed', error: 'repo access')`. +- Gebruiker annuleert via UI: job wordt door server op CANCELLED gezet; je krijgt geen verdere antwoorden — sluit netjes af. + +## Voorbeeld-vraag + +``` +ask_user_question({ + idea_id, + question: "Moet 'Plant-watering reminder' alleen lokale notifications doen, of ook web-push?", + options: ["Alleen lokaal (eenvoud)", "Web-push (multi-device)", "Beide"], +}) +``` diff --git a/src/prompts/idea/make-plan.md b/src/prompts/idea/make-plan.md new file mode 100644 index 0000000..ea7f1a8 --- /dev/null +++ b/src/prompts/idea/make-plan.md @@ -0,0 +1,129 @@ +# Make-Plan-prompt voor IDEA_MAKE_PLAN-jobs + +> Deze prompt wordt door `wait_for_job` meegestuurd in de payload van een +> `IDEA_MAKE_PLAN`-job. Single-pass, **stel geen vragen** (zie M12 grill-keuze +> 8). Twijfels → terug naar grill via UI. + +--- + +Je bent een **planning-agent** voor Scrum4Me-idee `{idea_code}`. + +Je context (meegegeven in `wait_for_job`-payload): + +- `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. +- `product`: gekoppeld product met `repo_url`, `definition_of_done`, + bestaande architectuur in repo. + +## Doel + +Eén `plan_md` produceren die je via `mcp__scrum4me__update_idea_plan_md` +opslaat. Dit document wordt later **deterministisch** geparseerd door de +server-side `parsePlanMd` (zie `lib/idea-plan-parser.ts`) en omgezet in +PBI + stories + taken via `materializeIdeaPlanAction`. + +## Werkwijze (single-pass) + +1. Lees `idea.grill_md` volledig. +2. Verken de repo voor patronen, bestaande modules, en `docs/`-structuur. +3. Bouw het plan op in de **strikte format** hieronder. +4. Roep `mcp__scrum4me__update_idea_plan_md({ idea_id, markdown })`. +5. Roep `mcp__scrum4me__update_job_status({ job_id, status: 'done', summary })`. + +## STEL GEEN VRAGEN + +`mcp__scrum4me__ask_user_question` is in deze fase **verboden**. Als je +informatie mist die je nodig hebt om het plan compleet te maken, schrijf je +plan met je beste aanname en documenteer je in de **Body** (zie hieronder) +welke aannames je hebt gemaakt. De gebruiker beoordeelt het plan in `PLAN_READY` +en kan dan handmatig editen of een re-grill triggeren. + +## Output-format (strikt — frontmatter wordt server-side geparseerd) + +````markdown +--- +pbi: + title: "Korte PBI-titel (≤200 chars)" + description: | + 1-3 zinnen die de PBI samenvatten. + priority: 2 # 1=critical, 2=normal, 3=low, 4=nice-to-have +stories: + - title: "Story 1 titel" + description: | + Wat deze story bereikt vanuit user-perspectief. + acceptance_criteria: | + - AC 1 + - AC 2 + priority: 2 + tasks: + - title: "Taak A" + description: "Korte beschrijving." + implementation_plan: | + 1. Bestand X aanpassen — concrete steps + 2. Test toevoegen Y + 3. Verifieer Z + priority: 2 + verify_required: ALIGNED_OR_PARTIAL # ALIGNED | ALIGNED_OR_PARTIAL | ANY + verify_only: false # true voor pure verify-passes + - title: "Taak B" + priority: 2 + implementation_plan: | + ... + - title: "Story 2 titel" + priority: 2 + tasks: + - title: "..." + priority: 2 +--- + +# Overwegingen + +(Vrije body — niet geparsed door materialize, wordt opgeslagen in +IdeaLog{PLAN_RESULT}.metadata.body voor latere referentie.) + +Beschrijf: +- Waarom deze opdeling in stories/taken +- Welke aannames je hebt gemaakt (indien grill onvolledig was) +- Architectuur-keuzes & verwijzingen naar bestaande modules in repo + +# Alternatieven + +- Optie X (verworpen omdat …) +- Optie Y (overwogen voor v2 …) + +# Beslissingen + +- ... + +# Aannames (indien van toepassing) + +- ... +```` + +## Validatie-regels die de parser afdwingt + +- `pbi.title`: 1–200 chars, **verplicht**. +- `pbi.priority`, `story.priority`, `task.priority`: integer 1–4. +- Minimaal 1 story; per story minimaal 1 taak. +- `implementation_plan`: max 8000 chars. +- `verify_required`: enum exact `ALIGNED` | `ALIGNED_OR_PARTIAL` | `ANY`. +- Alle string-velden trimmen, geen lege strings. + +Een parse-fout zet het idee op `PLAN_FAILED`. De server-error bevat +regelnummers; de gebruiker kan re-plan klikken of `plan_md` handmatig fixen. + +## Schaal-richtlijnen (geen harde limieten) + +- 1 PBI per idee. +- 2–6 stories per PBI (te veel = te grote PBI; splits dan in idee-niveau). +- 2–5 taken per story. +- Eén taak ≈ 30 min – paar uur werk; **`implementation_plan` is concreet** + (bestandsnamen, commando's, regels code), niet abstract. + +## Voorbeelden van goede vs slechte taken + +❌ **Slecht**: "Maak de feature werkend" +✅ **Goed**: "Voeg `actions/ideas.ts:createIdeaAction(input)` toe — auth + +demo-403 + zod-parse + nextIdeaCode + prisma.idea.create + revalidatePath" diff --git a/src/tools/ask-user-question.ts b/src/tools/ask-user-question.ts index dd9201f..b4d5a59 100644 --- a/src/tools/ask-user-question.ts +++ b/src/tools/ask-user-question.ts @@ -8,20 +8,26 @@ import { z } from 'zod' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { prisma } from '../prisma.js' import { requireWriteAccess } from '../auth.js' -import { userCanAccessStory } from '../access.js' +import { userCanAccessStory, userOwnsIdea } from '../access.js' import { toolError, toolJson, withToolErrors } from '../errors.js' const PENDING_TTL_HOURS = 24 const POLL_INTERVAL_MS = 2_000 const MAX_WAIT_SECONDS = 600 -const inputSchema = z.object({ - story_id: z.string().min(1), - question: z.string().min(1).max(4_000), - options: z.array(z.string().min(1)).max(8).optional(), - task_id: z.string().min(1).optional(), - wait_seconds: z.number().int().min(0).max(MAX_WAIT_SECONDS).optional(), -}) +// M12: schema accepteert exact één van story_id of idea_id (xor refine). +const inputSchema = z + .object({ + story_id: z.string().min(1).optional(), + idea_id: z.string().min(1).optional(), + question: z.string().min(1).max(4_000), + options: z.array(z.string().min(1)).max(8).optional(), + task_id: z.string().min(1).optional(), + wait_seconds: z.number().int().min(0).max(MAX_WAIT_SECONDS).optional(), + }) + .refine((d) => Boolean(d.story_id) !== Boolean(d.idea_id), { + message: 'Provide exactly one of story_id or idea_id', + }) function summarize(q: { id: string @@ -57,36 +63,60 @@ export function registerAskUserQuestionTool(server: McpServer) { 'demo accounts.', inputSchema, }, - async ({ story_id, question, options, task_id, wait_seconds }) => + async ({ story_id, idea_id, question, options, task_id, wait_seconds }) => withToolErrors(async () => { const auth = await requireWriteAccess() - if (!(await userCanAccessStory(story_id, auth.userId))) { - return toolError(`Story ${story_id} not found or not accessible`) - } - const story = await prisma.story.findUnique({ - where: { id: story_id }, - select: { product_id: true }, - }) - if (!story) { - return toolError(`Story ${story_id} not found`) - } - - if (task_id) { - const task = await prisma.task.findFirst({ - where: { id: task_id, story_id }, - select: { id: true }, - }) - if (!task) { - return toolError(`Task ${task_id} does not belong to story ${story_id}`) + // M12: branch on which scope was provided. story_id en idea_id sluiten + // elkaar uit (zod-refine in inputSchema). + let productId: string + if (idea_id) { + if (!(await userOwnsIdea(idea_id, auth.userId))) { + return toolError(`Idea ${idea_id} not found`) } + const idea = await prisma.idea.findUnique({ + where: { id: idea_id }, + select: { product_id: true }, + }) + if (!idea?.product_id) { + // Idee zonder product mag pas Q&A starten als product gekoppeld is + // (M12 grill-keuze 3: product met repo verplicht voor grill). + return toolError(`Idea ${idea_id} has no linked product`) + } + productId = idea.product_id + } else if (story_id) { + if (!(await userCanAccessStory(story_id, auth.userId))) { + return toolError(`Story ${story_id} not found or not accessible`) + } + const story = await prisma.story.findUnique({ + where: { id: story_id }, + select: { product_id: true }, + }) + if (!story) { + return toolError(`Story ${story_id} not found`) + } + productId = story.product_id + + if (task_id) { + const task = await prisma.task.findFirst({ + where: { id: task_id, story_id }, + select: { id: true }, + }) + if (!task) { + return toolError(`Task ${task_id} does not belong to story ${story_id}`) + } + } + } else { + // Mag niet voorkomen door de zod-refine, maar TS-narrow. + return toolError('Provide exactly one of story_id or idea_id') } const created = await prisma.claudeQuestion.create({ data: { - story_id, + story_id: story_id ?? null, + idea_id: idea_id ?? null, task_id: task_id ?? null, - product_id: story.product_id, + product_id: productId, asked_by: auth.userId, question, // Prisma's `Json?`-veld accepteert geen `null`-literal in `data`; diff --git a/src/tools/get-idea-context.ts b/src/tools/get-idea-context.ts new file mode 100644 index 0000000..af1e5f4 --- /dev/null +++ b/src/tools/get-idea-context.ts @@ -0,0 +1,121 @@ +// MCP-tool: laadt volledige context voor een idee — voor agents die +// idee-jobs uitvoeren of via UI-acties idee-info nodig hebben. +// +// Strikt user_id-only (M12 grill-keuze 8). Demo MAY read. + +import { z } from 'zod' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' + +import { prisma } from '../prisma.js' +import { getAuth } 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), +}) + +export function registerGetIdeaContextTool(server: McpServer) { + server.registerTool( + 'get_idea_context', + { + title: 'Get idea context', + description: + 'Fetch full idea context (idea + product + repo_url + open questions + recent logs). Strict user_id-only scope. Read-only.', + inputSchema, + annotations: { readOnlyHint: true, idempotentHint: true }, + }, + async ({ idea_id }) => + withToolErrors(async () => { + const auth = await getAuth() + + const idea = await prisma.idea.findFirst({ + where: { id: idea_id, user_id: auth.userId }, + include: { + product: { + select: { + id: true, + name: true, + code: true, + repo_url: true, + definition_of_done: true, + }, + }, + pbi: { select: { id: true, code: true, title: true } }, + }, + }) + if (!idea) { + // 404, niet 403 — vermijdt enumeratie van andermans idea-ids. + return toolError('Idea not found') + } + + // Open vragen + recente logs voor agent-context. + const [openQuestions, recentLogs] = await Promise.all([ + prisma.claudeQuestion.findMany({ + where: { idea_id: idea.id, status: 'open' }, + orderBy: { created_at: 'desc' }, + take: 10, + select: { + id: true, + question: true, + options: true, + created_at: true, + expires_at: true, + }, + }), + prisma.ideaLog.findMany({ + where: { idea_id: idea.id }, + orderBy: { created_at: 'desc' }, + take: 20, + select: { + id: true, + type: true, + content: true, + metadata: true, + created_at: true, + }, + }), + ]) + + return toolJson({ + idea: { + id: idea.id, + code: idea.code, + title: idea.title, + description: idea.description, + grill_md: idea.grill_md, + plan_md: idea.plan_md, + status: idea.status, + product_id: idea.product_id, + pbi_id: idea.pbi_id, + archived: idea.archived, + created_at: idea.created_at.toISOString(), + updated_at: idea.updated_at.toISOString(), + }, + product: idea.product, + pbi: idea.pbi, + repo_url: idea.product?.repo_url ?? null, + grill_md_so_far: idea.grill_md, + open_questions: openQuestions.map((q) => ({ + id: q.id, + question: q.question, + options: Array.isArray(q.options) ? (q.options as string[]) : null, + created_at: q.created_at.toISOString(), + expires_at: q.expires_at.toISOString(), + })), + recent_logs: recentLogs.map((l) => ({ + id: l.id, + type: l.type, + content: l.content, + metadata: l.metadata, + created_at: l.created_at.toISOString(), + })), + }) + + // Note: prompt_text wordt door wait_for_job in de job-payload + // meegestuurd (single source). get_idea_context is voor adhoc lookups + // — geen prompt-text nodig. + void userOwnsIdea + }), + ) +} diff --git a/src/tools/health.ts b/src/tools/health.ts index 0bb8492..a2456ed 100644 --- a/src/tools/health.ts +++ b/src/tools/health.ts @@ -1,9 +1,25 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' import { z } from 'zod' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { prisma } from '../prisma.js' import { toolJson, withToolErrors } from '../errors.js' -const VERSION = '0.1.0' +// Read once at module-load. Health is hot-path enough that we don't want +// disk-IO per call, and the version string is fixed for the running process. +function readPkgVersion(): string { + try { + const here = dirname(fileURLToPath(import.meta.url)) + // src/tools/health.ts → src/tools → src → repo-root + const pkgPath = join(here, '..', '..', 'package.json') + const raw = readFileSync(pkgPath, 'utf8') + return (JSON.parse(raw) as { version?: string }).version ?? '0.0.0' + } catch { + return '0.0.0' + } +} +const VERSION = readPkgVersion() export function registerHealthTool(server: McpServer) { server.registerTool( diff --git a/src/tools/log-idea-decision.ts b/src/tools/log-idea-decision.ts new file mode 100644 index 0000000..3bbcbd1 --- /dev/null +++ b/src/tools/log-idea-decision.ts @@ -0,0 +1,57 @@ +// MCP-tool: agents loggen een tussentijdse beslissing of notitie tijdens +// een grill- of make-plan-sessie. Verschijnt in de Timeline-tab van de +// idea-detailpagina. + +import { z } from 'zod' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import type { Prisma } from '@prisma/client' + +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), + type: z.enum(['DECISION', 'NOTE']), + content: z.string().min(1).max(4_000), + metadata: z.record(z.string(), z.unknown()).optional(), +}) + +export function registerLogIdeaDecisionTool(server: McpServer) { + server.registerTool( + 'log_idea_decision', + { + title: 'Log idea decision/note', + description: + "Append a DECISION or NOTE entry to an idea's timeline. Use to capture deliberations during grill or make-plan sessions. Forbidden for demo accounts.", + inputSchema, + }, + async ({ idea_id, type, content, metadata }) => + withToolErrors(async () => { + const auth = await requireWriteAccess() + if (!(await userOwnsIdea(idea_id, auth.userId))) { + return toolError('Idea not found') + } + + const log = await prisma.ideaLog.create({ + data: { + idea_id, + type, + content, + metadata: (metadata as Prisma.InputJsonValue | undefined) ?? undefined, + }, + select: { id: true, type: true, created_at: true }, + }) + + return toolJson({ + ok: true, + log: { + id: log.id, + type: log.type, + created_at: log.created_at.toISOString(), + }, + }) + }), + ) +} diff --git a/src/tools/update-idea-grill-md.ts b/src/tools/update-idea-grill-md.ts new file mode 100644 index 0000000..9945d1f --- /dev/null +++ b/src/tools/update-idea-grill-md.ts @@ -0,0 +1,57 @@ +// MCP-tool: schrijft het grill_md-resultaat na een IDEA_GRILL-job en zet +// de idea-status op GRILLED. Logt een IdeaLog{GRILL_RESULT}-entry. +// +// Wordt aangeroepen door de worker als laatste stap van een grill-sessie. + +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), + markdown: z.string().min(1).max(64_000), +}) + +export function registerUpdateIdeaGrillMdTool(server: McpServer) { + server.registerTool( + 'update_idea_grill_md', + { + title: 'Update idea grill_md', + description: + 'Save the grill-result markdown for an idea and transition status to GRILLED. Forbidden for demo accounts.', + inputSchema, + }, + async ({ idea_id, markdown }) => + withToolErrors(async () => { + const auth = await requireWriteAccess() + if (!(await userOwnsIdea(idea_id, auth.userId))) { + return toolError('Idea not found') + } + + const result = await prisma.$transaction([ + prisma.idea.update({ + where: { id: idea_id }, + data: { grill_md: markdown, status: 'GRILLED' }, + select: { id: true, status: true, code: true }, + }), + prisma.ideaLog.create({ + data: { + idea_id, + type: 'GRILL_RESULT', + content: `Grill result (${markdown.length} chars)`, + metadata: { length: markdown.length }, + }, + }), + ]) + + return toolJson({ + ok: true, + idea: result[0], + }) + }), + ) +} diff --git a/src/tools/update-idea-plan-md.ts b/src/tools/update-idea-plan-md.ts new file mode 100644 index 0000000..2e6ea81 --- /dev/null +++ b/src/tools/update-idea-plan-md.ts @@ -0,0 +1,90 @@ +// MCP-tool: schrijft het plan_md-resultaat na een IDEA_MAKE_PLAN-job en +// transitioneert de idea-status naar PLAN_READY (bij geldige yaml-frontmatter) +// of PLAN_FAILED (bij parse-fout). +// +// Wordt aangeroepen door de worker als laatste stap van een make-plan-sessie. + +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' +import { parsePlanMd } from '../lib/idea-plan-parser.js' + +const inputSchema = z.object({ + idea_id: z.string().min(1), + markdown: z.string().min(1).max(64_000), +}) + +export function registerUpdateIdeaPlanMdTool(server: McpServer) { + server.registerTool( + 'update_idea_plan_md', + { + title: 'Update idea plan_md', + description: + 'Save the make-plan-result markdown for an idea. Server validates yaml-frontmatter; on success status → PLAN_READY, on parse-fail → PLAN_FAILED. Forbidden for demo accounts.', + inputSchema, + }, + async ({ idea_id, markdown }) => + withToolErrors(async () => { + const auth = await requireWriteAccess() + if (!(await userOwnsIdea(idea_id, auth.userId))) { + return toolError('Idea not found') + } + + const parsed = parsePlanMd(markdown) + + if (!parsed.ok) { + // Persist md + flip to PLAN_FAILED + log de errors zodat de UI ze + // aan de user kan tonen. + const result = await prisma.$transaction([ + prisma.idea.update({ + where: { id: idea_id }, + data: { plan_md: markdown, status: 'PLAN_FAILED' }, + select: { id: true, status: true, code: true }, + }), + prisma.ideaLog.create({ + data: { + idea_id, + type: 'JOB_EVENT', + content: 'plan_md parse failed', + metadata: { errors: parsed.errors }, + }, + }), + ]) + return toolJson({ + ok: false, + idea: result[0], + errors: parsed.errors, + }) + } + + const result = await prisma.$transaction([ + prisma.idea.update({ + where: { id: idea_id }, + data: { plan_md: markdown, status: 'PLAN_READY' }, + select: { id: true, status: true, code: true }, + }), + prisma.ideaLog.create({ + data: { + idea_id, + type: 'PLAN_RESULT', + content: `Plan ready: ${parsed.plan.stories.length} stories, ${parsed.plan.stories.reduce((n, s) => n + s.tasks.length, 0)} tasks`, + metadata: { + pbi_title: parsed.plan.pbi.title, + story_count: parsed.plan.stories.length, + task_count: parsed.plan.stories.reduce((n, s) => n + s.tasks.length, 0), + }, + }, + }), + ]) + + return toolJson({ + ok: true, + idea: result[0], + }) + }), + ) +} diff --git a/src/tools/update-job-status.ts b/src/tools/update-job-status.ts index 73ac0d8..82ad6cc 100644 --- a/src/tools/update-job-status.ts +++ b/src/tools/update-job-status.ts @@ -44,7 +44,7 @@ export async function cleanupWorktreeForTerminalStatus( where: { id: jobId }, select: { task: { select: { story_id: true } } }, }) - if (job) { + if (job?.task) { const activeSiblings = await prisma.claudeJob.count({ where: { task: { story_id: job.task.story_id }, @@ -283,6 +283,8 @@ export function registerUpdateJobStatusTool(server: McpServer) { user_id: true, product_id: true, task_id: true, + idea_id: true, + kind: true, verify_result: true, task: { select: { verify_only: true, verify_required: true } }, }, @@ -320,9 +322,16 @@ export function registerUpdateJobStatusTool(server: McpServer) { skipWorktreeCleanup = plan.skipWorktreeCleanup } - // Auto-PR: best-effort, only when push actually happened + // Auto-PR: best-effort, only when push actually happened. + // M12: idee-jobs hebben geen task_id en geen branch — skip auto-PR. let prUrl: string | null = null - if (actualStatus === 'done' && pushedAt && branchToWrite) { + if ( + actualStatus === 'done' && + pushedAt && + branchToWrite && + job.kind === 'TASK_IMPLEMENTATION' && + job.task_id + ) { const worktreeDir = process.env.SCRUM4ME_AGENT_WORKTREE_DIR ?? path.join(os.homedir(), '.scrum4me-agent-worktrees') @@ -367,6 +376,34 @@ export function registerUpdateJobStatusTool(server: McpServer) { }, }) + // M12: bij failed voor IDEA_*-jobs: zet idea.status op + // GRILL_FAILED / PLAN_FAILED + log JOB_EVENT. Bij done laten we de + // idea-status met rust — die wordt door update_idea_*_md gezet. + if (actualStatus === 'failed' && job.idea_id) { + const newIdeaStatus = + job.kind === 'IDEA_GRILL' + ? 'GRILL_FAILED' + : job.kind === 'IDEA_MAKE_PLAN' + ? 'PLAN_FAILED' + : null + if (newIdeaStatus) { + await prisma.$transaction([ + prisma.idea.update({ + where: { id: job.idea_id }, + data: { status: newIdeaStatus }, + }), + prisma.ideaLog.create({ + data: { + idea_id: job.idea_id, + type: 'JOB_EVENT', + content: `${job.kind} failed`, + metadata: { job_id, error: errorToWrite ?? null }, + }, + }), + ]) + } + } + // Notify UI via SSE try { const pg = new Client({ connectionString: process.env.DATABASE_URL }) diff --git a/src/tools/wait-for-job.ts b/src/tools/wait-for-job.ts index 65972d7..d299606 100644 --- a/src/tools/wait-for-job.ts +++ b/src/tools/wait-for-job.ts @@ -305,17 +305,58 @@ async function getFullJobContext(jobId: string) { }, }, }, - product: { select: { id: true, name: true, repo_url: true } }, + idea: { + include: { + pbi: { select: { id: true, code: true, title: true } }, + }, + }, + product: { select: { id: true, name: true, repo_url: true, definition_of_done: true } }, }, }) if (!job) return null + // 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.idea) return null + const { idea } = job + const { getIdeaPromptText } = await import('../lib/idea-prompts.js') + return { + job_id: job.id, + kind: job.kind, + status: 'claimed', + idea: { + id: idea.id, + code: idea.code, + title: idea.title, + description: idea.description, + grill_md: idea.grill_md, + plan_md: idea.plan_md, + status: idea.status, + product_id: idea.product_id, + }, + product: { + id: job.product.id, + name: job.product.name, + repo_url: job.product.repo_url, + definition_of_done: job.product.definition_of_done, + }, + 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'}`, + } + } + + // TASK_IMPLEMENTATION (default) — bestaande gedrag onaangetast. const { task } = job + if (!task) return null const { story } = task const { pbi, sprint } = story return { job_id: job.id, + kind: job.kind, status: 'claimed', task: { id: task.id, @@ -378,9 +419,23 @@ export function registerWaitForJobTool(server: McpServer) { if (jobId) { const ctx = await getFullJobContext(jobId) if (!ctx) return toolError('Job claimed but context fetch failed') - const wt = await attachWorktreeToJob(ctx.product.id, jobId, ctx.story.id, ctx.task.repo_url) - if ('error' in wt) return toolError(wt.error) - return toolJson({ ...ctx, worktree_path: wt.worktree_path, branch_name: wt.branch_name }) + // M12: idee-jobs hebben geen worktree nodig — de agent werkt in de + // bestaande user-repo (geen branch/commit-flow). Alleen task-jobs + // krijgen een worktree. + if (ctx.kind === 'TASK_IMPLEMENTATION') { + if (!ctx.story || !ctx.task) { + return toolError('Task-job claimed but story/task context is incomplete') + } + const wt = await attachWorktreeToJob( + ctx.product.id, + jobId, + ctx.story.id, + ctx.task.repo_url, + ) + if ('error' in wt) return toolError(wt.error) + return toolJson({ ...ctx, worktree_path: wt.worktree_path, branch_name: wt.branch_name }) + } + return toolJson(ctx) } // 3. No job available — LISTEN and poll until timeout @@ -416,9 +471,20 @@ export function registerWaitForJobTool(server: McpServer) { if (jobId) { const ctx = await getFullJobContext(jobId) if (!ctx) return toolError('Job claimed but context fetch failed') - const wt = await attachWorktreeToJob(ctx.product.id, jobId, ctx.story.id, ctx.task.repo_url) - if ('error' in wt) return toolError(wt.error) - return toolJson({ ...ctx, worktree_path: wt.worktree_path, branch_name: wt.branch_name }) + if (ctx.kind === 'TASK_IMPLEMENTATION') { + if (!ctx.story || !ctx.task) { + return toolError('Task-job claimed but story/task context is incomplete') + } + const wt = await attachWorktreeToJob( + ctx.product.id, + jobId, + ctx.story.id, + ctx.task.repo_url, + ) + if ('error' in wt) return toolError(wt.error) + return toolJson({ ...ctx, worktree_path: wt.worktree_path, branch_name: wt.branch_name }) + } + return toolJson(ctx) } } } finally { From fa6e393465e43ba34c55b9e6a875b3864f7f64e3 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Tue, 5 May 2026 12:01:13 +0200 Subject: [PATCH 2/2] vendor: bump scrum4me to main (post-M12) + re-sync schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scrum4Me PR #91 (feat/m12-ideas) merged at 09:58 UTC. Vendor pointer now tracks origin/main (commit 2893573, includes the canonical M12 schema and all M12 server/UI/REST/realtime work). Re-synced prisma/schema.prisma from vendor as the authoritative source (was previously synced from a local Scrum4Me feature-branch worktree). Diff vs vendor: only the erd-generator block (vendored has it, mcp does not — same as before M12). Tests: 153/153 green; tsc + build clean. No tool-code changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- vendor/scrum4me | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/scrum4me b/vendor/scrum4me index 9034357..2893573 160000 --- a/vendor/scrum4me +++ b/vendor/scrum4me @@ -1 +1 @@ -Subproject commit 90343573f399544e386b2833d23a74f0fa122fa6 +Subproject commit 2893573004cf1df28ff5ad69752ddcf8b66ddb1e