Merge pull request #20 from madhura68/feat/m12-idea-jobs
M12 — Idea-job support (v0.6.0)
This commit is contained in:
commit
63e095f756
19 changed files with 1141 additions and 147 deletions
35
CHANGELOG.md
Normal file
35
CHANGELOG.md
Normal 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
20
package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
26
src/index.ts
26
src/index.ts
|
|
@ -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
|
||||||
|
|
|
||||||
97
src/lib/idea-plan-parser.ts
Normal file
97
src/lib/idea-plan-parser.ts
Normal 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
32
src/lib/idea-prompts.ts
Normal 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
98
src/prompts/idea/grill.md
Normal 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** (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"],
|
||||||
|
})
|
||||||
|
```
|
||||||
129
src/prompts/idea/make-plan.md
Normal file
129
src/prompts/idea/make-plan.md
Normal 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`: 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"
|
||||||
|
|
@ -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`;
|
||||||
|
|
|
||||||
121
src/tools/get-idea-context.ts
Normal file
121
src/tools/get-idea-context.ts
Normal 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
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
57
src/tools/log-idea-decision.ts
Normal file
57
src/tools/log-idea-decision.ts
Normal 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(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
57
src/tools/update-idea-grill-md.ts
Normal file
57
src/tools/update-idea-grill-md.ts
Normal 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],
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
90
src/tools/update-idea-plan-md.ts
Normal file
90
src/tools/update-idea-plan-md.ts
Normal 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],
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
2
vendor/scrum4me
vendored
2
vendor/scrum4me
vendored
|
|
@ -1 +1 @@
|
||||||
Subproject commit 90343573f399544e386b2833d23a74f0fa122fa6
|
Subproject commit 2893573004cf1df28ff5ad69752ddcf8b66ddb1e
|
||||||
Loading…
Add table
Add a link
Reference in a new issue