feat: M12 idea-job support — version 0.6.0

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) <noreply@anthropic.com>
This commit is contained in:
Madhura68 2026-05-04 22:12:36 +02:00
parent 79eb13a210
commit fdf3dc4471
18 changed files with 1140 additions and 146 deletions

35
CHANGELOG.md Normal file
View file

@ -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).

20
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "scrum4me-mcp", "name": "scrum4me-mcp",
"version": "0.3.0", "version": "0.5.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "scrum4me-mcp", "name": "scrum4me-mcp",
"version": "0.3.0", "version": "0.5.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -14,6 +14,7 @@
"@prisma/adapter-pg": "^7.8.0", "@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0", "@prisma/client": "^7.8.0",
"pg": "^8.13.1", "pg": "^8.13.1",
"yaml": "^2.8.4",
"zod": "^4.0.0" "zod": "^4.0.0"
}, },
"bin": { "bin": {
@ -4105,6 +4106,21 @@
"node": ">=0.4" "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": { "node_modules/zeptomatch": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz",

View file

@ -1,6 +1,6 @@
{ {
"name": "scrum4me-mcp", "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", "description": "MCP server for Scrum4Me — exposes dev-flow tools and prompts via the Model Context Protocol",
"type": "module", "type": "module",
"bin": { "bin": {
@ -33,6 +33,7 @@
"@prisma/adapter-pg": "^7.8.0", "@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0", "@prisma/client": "^7.8.0",
"pg": "^8.13.1", "pg": "^8.13.1",
"yaml": "^2.8.4",
"zod": "^4.0.0" "zod": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {

View file

@ -70,30 +70,58 @@ enum SprintStatus {
COMPLETED 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 { model User {
id String @id @default(cuid()) id String @id @default(cuid())
username String @unique username String @unique
email String? @unique email String? @unique
password_hash String password_hash String
is_demo Boolean @default(false) is_demo Boolean @default(false)
bio String? @db.VarChar(160) bio String? @db.VarChar(160)
bio_detail String? @db.VarChar(2000) bio_detail String? @db.VarChar(2000)
avatar_data Bytes? avatar_data Bytes?
active_product_id String? active_product_id String?
active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull) active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull)
created_at DateTime @default(now()) idea_code_counter Int @default(0)
updated_at DateTime @updatedAt created_at DateTime @default(now())
roles UserRole[] updated_at DateTime @updatedAt
api_tokens ApiToken[] roles UserRole[]
products Product[] api_tokens ApiToken[]
todos Todo[] products Product[]
product_members ProductMember[] todos Todo[]
assigned_stories Story[] @relation("StoryAssignee") ideas Idea[]
login_pairings LoginPairing[] product_members ProductMember[]
asked_questions ClaudeQuestion[] @relation("ClaudeQuestionAsker") assigned_stories Story[] @relation("StoryAssignee")
answered_questions ClaudeQuestion[] @relation("ClaudeQuestionAnswerer") login_pairings LoginPairing[]
claude_jobs ClaudeJob[] asked_questions ClaudeQuestion[] @relation("ClaudeQuestionAsker")
claude_workers ClaudeWorker[] answered_questions ClaudeQuestion[] @relation("ClaudeQuestionAnswerer")
claude_jobs ClaudeJob[]
claude_workers ClaudeWorker[]
@@index([active_product_id]) @@index([active_product_id])
@@map("users") @@map("users")
@ -110,33 +138,33 @@ model UserRole {
} }
model ApiToken { model ApiToken {
id String @id @default(cuid()) id String @id @default(cuid())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade) user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
user_id String user_id String
token_hash String @unique token_hash String @unique
label String? label String?
created_at DateTime @default(now()) created_at DateTime @default(now())
revoked_at DateTime? revoked_at DateTime?
claimed_jobs ClaudeJob[] claimed_jobs ClaudeJob[]
claude_worker ClaudeWorker? claude_worker ClaudeWorker?
@@index([token_hash]) @@index([token_hash])
@@map("api_tokens") @@map("api_tokens")
} }
model Product { model Product {
id String @id @default(cuid()) id String @id @default(cuid())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade) user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
user_id String user_id String
name String name String
code String? @db.VarChar(30) code String? @db.VarChar(30)
description String? description String?
repo_url String? repo_url String?
definition_of_done String definition_of_done String
auto_pr Boolean @default(false) auto_pr Boolean @default(false)
archived Boolean @default(false) archived Boolean @default(false)
created_at DateTime @default(now()) created_at DateTime @default(now())
updated_at DateTime @updatedAt updated_at DateTime @updatedAt
pbis Pbi[] pbis Pbi[]
sprints Sprint[] sprints Sprint[]
stories Story[] stories Story[]
@ -146,6 +174,7 @@ model Product {
active_for_users User[] @relation("UserActiveProduct") active_for_users User[] @relation("UserActiveProduct")
claude_questions ClaudeQuestion[] claude_questions ClaudeQuestion[]
claude_jobs ClaudeJob[] claude_jobs ClaudeJob[]
ideas Idea[]
@@unique([user_id, name]) @@unique([user_id, name])
@@unique([user_id, code]) @@unique([user_id, code])
@ -154,20 +183,21 @@ model Product {
} }
model Pbi { model Pbi {
id String @id @default(cuid()) id String @id @default(cuid())
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
product_id String product_id String
code String @db.VarChar(30) code String @db.VarChar(30)
title String title String
description String? description String?
priority Int priority Int
sort_order Float sort_order Float
status PbiStatus @default(READY) status PbiStatus @default(READY)
pr_url String? pr_url String?
pr_merged_at DateTime? pr_merged_at DateTime?
created_at DateTime @default(now()) created_at DateTime @default(now())
updated_at DateTime @updatedAt updated_at DateTime @updatedAt
stories Story[] stories Story[]
idea Idea?
@@unique([product_id, code]) @@unique([product_id, code])
@@index([product_id, priority, sort_order]) @@index([product_id, priority, sort_order])
@ -176,24 +206,24 @@ model Pbi {
} }
model Story { model Story {
id String @id @default(cuid()) id String @id @default(cuid())
pbi Pbi @relation(fields: [pbi_id], references: [id], onDelete: Cascade) pbi Pbi @relation(fields: [pbi_id], references: [id], onDelete: Cascade)
pbi_id String pbi_id String
product Product @relation(fields: [product_id], references: [id]) product Product @relation(fields: [product_id], references: [id])
product_id String product_id String
sprint Sprint? @relation(fields: [sprint_id], references: [id]) sprint Sprint? @relation(fields: [sprint_id], references: [id])
sprint_id String? 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? assignee_id String?
code String @db.VarChar(30) code String @db.VarChar(30)
title String title String
description String? description String?
acceptance_criteria String? acceptance_criteria String?
priority Int priority Int
sort_order Float sort_order Float
status StoryStatus @default(OPEN) status StoryStatus @default(OPEN)
created_at DateTime @default(now()) created_at DateTime @default(now())
updated_at DateTime @updatedAt updated_at DateTime @updatedAt
logs StoryLog[] logs StoryLog[]
tasks Task[] tasks Task[]
claude_questions ClaudeQuestion[] claude_questions ClaudeQuestion[]
@ -240,29 +270,29 @@ model Sprint {
} }
model Task { model Task {
id String @id @default(cuid()) id String @id @default(cuid())
story Story @relation(fields: [story_id], references: [id], onDelete: Cascade) story Story @relation(fields: [story_id], references: [id], onDelete: Cascade)
story_id String 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 product_id String
sprint Sprint? @relation(fields: [sprint_id], references: [id]) sprint Sprint? @relation(fields: [sprint_id], references: [id])
sprint_id String? sprint_id String?
code String @db.VarChar(30) code String @db.VarChar(30)
title String title String
description String? description String?
implementation_plan String? implementation_plan String?
priority Int priority Int
sort_order Float sort_order Float
status TaskStatus @default(TO_DO) status TaskStatus @default(TO_DO)
verify_only Boolean @default(false) verify_only Boolean @default(false)
verify_required VerifyRequired @default(ALIGNED_OR_PARTIAL) verify_required VerifyRequired @default(ALIGNED_OR_PARTIAL)
// Override product.repo_url for branch/worktree/push purposes. Set when // Override product.repo_url for branch/worktree/push purposes. Set when
// a task targets a different repo than its parent product (e.g. an // 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 // MCP-server task tracked under the main product's PBI). Falls back to
// product.repo_url when null. // product.repo_url when null.
repo_url String? repo_url String?
created_at DateTime @default(now()) created_at DateTime @default(now())
updated_at DateTime @updatedAt updated_at DateTime @updatedAt
claude_questions ClaudeQuestion[] claude_questions ClaudeQuestion[]
claude_jobs ClaudeJob[] claude_jobs ClaudeJob[]
@ -279,8 +309,11 @@ model ClaudeJob {
user_id String 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 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 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) status ClaudeJobStatus @default(QUEUED)
claimed_by_token ApiToken? @relation(fields: [claimed_by_token_id], references: [id], onDelete: SetNull) claimed_by_token ApiToken? @relation(fields: [claimed_by_token_id], references: [id], onDelete: SetNull)
claimed_by_token_id String? claimed_by_token_id String?
@ -300,20 +333,21 @@ model ClaudeJob {
@@index([user_id, status]) @@index([user_id, status])
@@index([task_id, status]) @@index([task_id, status])
@@index([idea_id, status])
@@index([status, claimed_at]) @@index([status, claimed_at])
@@index([status, finished_at]) @@index([status, finished_at])
@@map("claude_jobs") @@map("claude_jobs")
} }
model ClaudeWorker { model ClaudeWorker {
id String @id @default(cuid()) id String @id @default(cuid())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade) user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
user_id String 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 token_id String
product_id String? product_id String?
started_at DateTime @default(now()) started_at DateTime @default(now())
last_seen_at DateTime @default(now()) last_seen_at DateTime @default(now())
@@unique([token_id]) @@unique([token_id])
@@index([user_id, last_seen_at]) @@index([user_id, last_seen_at])
@ -334,23 +368,64 @@ model ProductMember {
} }
model Todo { model Todo {
id String @id @default(cuid()) id String @id @default(cuid())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade) user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
user_id String user_id String
product Product? @relation(fields: [product_id], references: [id], onDelete: SetNull) product Product? @relation(fields: [product_id], references: [id], onDelete: SetNull)
product_id String? product_id String?
title String title String
description String? @db.VarChar(2000) description String? @db.VarChar(2000)
done Boolean @default(false) done Boolean @default(false)
archived Boolean @default(false) archived Boolean @default(false)
created_at DateTime @default(now()) created_at DateTime @default(now())
updated_at DateTime @updatedAt updated_at DateTime @updatedAt
@@index([user_id, done, archived]) @@index([user_id, done, archived])
@@index([user_id, product_id]) @@index([user_id, product_id])
@@map("todos") @@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 { model LoginPairing {
id String @id @default(cuid()) id String @id @default(cuid())
secret_hash String secret_hash String
@ -371,26 +446,29 @@ model LoginPairing {
} }
model ClaudeQuestion { model ClaudeQuestion {
id String @id @default(cuid()) id String @id @default(cuid())
story Story @relation(fields: [story_id], references: [id], onDelete: Cascade) story Story? @relation(fields: [story_id], references: [id], onDelete: Cascade)
story_id String story_id String?
task Task? @relation(fields: [task_id], references: [id], onDelete: SetNull) task Task? @relation(fields: [task_id], references: [id], onDelete: SetNull)
task_id String? task_id String?
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) idea Idea? @relation(fields: [idea_id], references: [id], onDelete: Cascade)
product_id String // gedenormaliseerd uit story.product_id voor SSE-filter idea_id String?
asker User @relation("ClaudeQuestionAsker", fields: [asked_by], references: [id]) product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
asked_by String // user_id van token-houder (= Claude-token) product_id String // gedenormaliseerd uit story.product_id voor SSE-filter
question String @db.Text asker User @relation("ClaudeQuestionAsker", fields: [asked_by], references: [id])
options Json? // string[] voor multi-choice; null voor free-text asked_by String // user_id van token-houder (= Claude-token)
status String // 'open' | 'answered' | 'cancelled' | 'expired' question String @db.Text
answer String? @db.Text options Json? // string[] voor multi-choice; null voor free-text
answerer User? @relation("ClaudeQuestionAnswerer", fields: [answered_by], references: [id]) status String // 'open' | 'answered' | 'cancelled' | 'expired'
answered_by String? answer String? @db.Text
answered_at DateTime? answerer User? @relation("ClaudeQuestionAnswerer", fields: [answered_by], references: [id])
created_at DateTime @default(now()) answered_by String?
expires_at DateTime // ingesteld door MCP-tool, default now() + 24h answered_at DateTime?
created_at DateTime @default(now())
expires_at DateTime // ingesteld door MCP-tool, default now() + 24h
@@index([story_id, status]) @@index([story_id, status])
@@index([idea_id, status])
@@index([product_id, status]) @@index([product_id, status])
@@index([status, expires_at]) @@index([status, expires_at])
@@map("claude_questions") @@map("claude_questions")

View file

@ -28,3 +28,13 @@ export async function userCanAccessStory(storyId: string, userId: string): Promi
if (!story) return false if (!story) return false
return userCanAccessProduct(story.product_id, userId) 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<boolean> {
const idea = await prisma.idea.findUnique({
where: { id: ideaId },
select: { user_id: true },
})
return idea !== null && idea.user_id === userId
}

View file

@ -24,13 +24,32 @@ import { registerCleanupMyWorktreesTool } from './tools/cleanup-my-worktrees.js'
import { registerCheckQueueEmptyTool } from './tools/check-queue-empty.js' import { registerCheckQueueEmptyTool } from './tools/check-queue-empty.js'
import { registerSetPbiPrTool } from './tools/set-pbi-pr.js' import { registerSetPbiPrTool } from './tools/set-pbi-pr.js'
import { registerMarkPbiPrMergedTool } from './tools/mark-pbi-pr-merged.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 { registerImplementNextStoryPrompt } from './prompts/implement-next-story.js'
import { getAuth } from './auth.js' import { getAuth } from './auth.js'
import { registerWorker } from './presence/worker.js' import { registerWorker } from './presence/worker.js'
import { startHeartbeat } from './presence/heartbeat.js' import { startHeartbeat } from './presence/heartbeat.js'
import { registerShutdownHandlers } from './presence/shutdown.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() { async function main() {
const server = new McpServer( const server = new McpServer(
@ -65,6 +84,11 @@ async function main() {
registerCheckQueueEmptyTool(server) registerCheckQueueEmptyTool(server)
registerSetPbiPrTool(server) registerSetPbiPrTool(server)
registerMarkPbiPrMergedTool(server) registerMarkPbiPrMergedTool(server)
// M12: idee-job tools
registerGetIdeaContextTool(server)
registerUpdateIdeaGrillMdTool(server)
registerUpdateIdeaPlanMdTool(server)
registerLogIdeaDecisionTool(server)
registerImplementNextStoryPrompt(server) registerImplementNextStoryPrompt(server)
// Presence bootstrap MUST run before server.connect — the stdio transport // Presence bootstrap MUST run before server.connect — the stdio transport

View file

@ -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<typeof ideaPlanMdFrontmatterSchema>
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('.') || '<root>'}: ${iss.message}`,
})),
}
}
return { ok: true, plan: validation.data, body: body.trimStart() }
}

32
src/lib/idea-prompts.ts Normal file
View file

@ -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 ''
}

98
src/prompts/idea/grill.md Normal file
View file

@ -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** (13 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"],
})
```

View file

@ -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`: 1200 chars, **verplicht**.
- `pbi.priority`, `story.priority`, `task.priority`: integer 14.
- 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.
- 26 stories per PBI (te veel = te grote PBI; splits dan in idee-niveau).
- 25 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"

View file

@ -8,20 +8,26 @@ import { z } from 'zod'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { prisma } from '../prisma.js' import { prisma } from '../prisma.js'
import { requireWriteAccess } from '../auth.js' import { requireWriteAccess } from '../auth.js'
import { userCanAccessStory } from '../access.js' import { userCanAccessStory, userOwnsIdea } from '../access.js'
import { toolError, toolJson, withToolErrors } from '../errors.js' import { toolError, toolJson, withToolErrors } from '../errors.js'
const PENDING_TTL_HOURS = 24 const PENDING_TTL_HOURS = 24
const POLL_INTERVAL_MS = 2_000 const POLL_INTERVAL_MS = 2_000
const MAX_WAIT_SECONDS = 600 const MAX_WAIT_SECONDS = 600
const inputSchema = z.object({ // M12: schema accepteert exact één van story_id of idea_id (xor refine).
story_id: z.string().min(1), const inputSchema = z
question: z.string().min(1).max(4_000), .object({
options: z.array(z.string().min(1)).max(8).optional(), story_id: z.string().min(1).optional(),
task_id: z.string().min(1).optional(), idea_id: z.string().min(1).optional(),
wait_seconds: z.number().int().min(0).max(MAX_WAIT_SECONDS).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: { function summarize(q: {
id: string id: string
@ -57,36 +63,60 @@ export function registerAskUserQuestionTool(server: McpServer) {
'demo accounts.', 'demo accounts.',
inputSchema, inputSchema,
}, },
async ({ story_id, question, options, task_id, wait_seconds }) => async ({ story_id, idea_id, question, options, task_id, wait_seconds }) =>
withToolErrors(async () => { withToolErrors(async () => {
const auth = await requireWriteAccess() 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({ // M12: branch on which scope was provided. story_id en idea_id sluiten
where: { id: story_id }, // elkaar uit (zod-refine in inputSchema).
select: { product_id: true }, let productId: string
}) if (idea_id) {
if (!story) { if (!(await userOwnsIdea(idea_id, auth.userId))) {
return toolError(`Story ${story_id} not found`) return toolError(`Idea ${idea_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}`)
} }
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({ const created = await prisma.claudeQuestion.create({
data: { data: {
story_id, story_id: story_id ?? null,
idea_id: idea_id ?? null,
task_id: task_id ?? null, task_id: task_id ?? null,
product_id: story.product_id, product_id: productId,
asked_by: auth.userId, asked_by: auth.userId,
question, question,
// Prisma's `Json?`-veld accepteert geen `null`-literal in `data`; // Prisma's `Json?`-veld accepteert geen `null`-literal in `data`;

View file

@ -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
}),
)
}

View file

@ -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 { z } from 'zod'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { prisma } from '../prisma.js' import { prisma } from '../prisma.js'
import { toolJson, withToolErrors } from '../errors.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) { export function registerHealthTool(server: McpServer) {
server.registerTool( server.registerTool(

View file

@ -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(),
},
})
}),
)
}

View file

@ -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],
})
}),
)
}

View file

@ -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],
})
}),
)
}

View file

@ -44,7 +44,7 @@ export async function cleanupWorktreeForTerminalStatus(
where: { id: jobId }, where: { id: jobId },
select: { task: { select: { story_id: true } } }, select: { task: { select: { story_id: true } } },
}) })
if (job) { if (job?.task) {
const activeSiblings = await prisma.claudeJob.count({ const activeSiblings = await prisma.claudeJob.count({
where: { where: {
task: { story_id: job.task.story_id }, task: { story_id: job.task.story_id },
@ -283,6 +283,8 @@ export function registerUpdateJobStatusTool(server: McpServer) {
user_id: true, user_id: true,
product_id: true, product_id: true,
task_id: true, task_id: true,
idea_id: true,
kind: true,
verify_result: true, verify_result: true,
task: { select: { verify_only: true, verify_required: true } }, task: { select: { verify_only: true, verify_required: true } },
}, },
@ -320,9 +322,16 @@ export function registerUpdateJobStatusTool(server: McpServer) {
skipWorktreeCleanup = plan.skipWorktreeCleanup 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 let prUrl: string | null = null
if (actualStatus === 'done' && pushedAt && branchToWrite) { if (
actualStatus === 'done' &&
pushedAt &&
branchToWrite &&
job.kind === 'TASK_IMPLEMENTATION' &&
job.task_id
) {
const worktreeDir = const worktreeDir =
process.env.SCRUM4ME_AGENT_WORKTREE_DIR ?? process.env.SCRUM4ME_AGENT_WORKTREE_DIR ??
path.join(os.homedir(), '.scrum4me-agent-worktrees') 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 // Notify UI via SSE
try { try {
const pg = new Client({ connectionString: process.env.DATABASE_URL }) const pg = new Client({ connectionString: process.env.DATABASE_URL })

View file

@ -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 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 const { task } = job
if (!task) return null
const { story } = task const { story } = task
const { pbi, sprint } = story const { pbi, sprint } = story
return { return {
job_id: job.id, job_id: job.id,
kind: job.kind,
status: 'claimed', status: 'claimed',
task: { task: {
id: task.id, id: task.id,
@ -378,9 +419,23 @@ export function registerWaitForJobTool(server: McpServer) {
if (jobId) { if (jobId) {
const ctx = await getFullJobContext(jobId) const ctx = await getFullJobContext(jobId)
if (!ctx) return toolError('Job claimed but context fetch failed') 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) // M12: idee-jobs hebben geen worktree nodig — de agent werkt in de
if ('error' in wt) return toolError(wt.error) // bestaande user-repo (geen branch/commit-flow). Alleen task-jobs
return toolJson({ ...ctx, worktree_path: wt.worktree_path, branch_name: wt.branch_name }) // 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 // 3. No job available — LISTEN and poll until timeout
@ -416,9 +471,20 @@ export function registerWaitForJobTool(server: McpServer) {
if (jobId) { if (jobId) {
const ctx = await getFullJobContext(jobId) const ctx = await getFullJobContext(jobId)
if (!ctx) return toolError('Job claimed but context fetch failed') 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 (ctx.kind === 'TASK_IMPLEMENTATION') {
if ('error' in wt) return toolError(wt.error) if (!ctx.story || !ctx.task) {
return toolJson({ ...ctx, worktree_path: wt.worktree_path, branch_name: wt.branch_name }) 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 { } finally {