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:
parent
79eb13a210
commit
fdf3dc4471
18 changed files with 1140 additions and 146 deletions
|
|
@ -28,3 +28,13 @@ export async function userCanAccessStory(storyId: string, userId: string): Promi
|
|||
if (!story) return false
|
||||
return userCanAccessProduct(story.product_id, userId)
|
||||
}
|
||||
|
||||
// M12: idee is strikt user_id-only (geen productAccessFilter — Q8).
|
||||
// Idea-questions, idea-jobs, en idea-md-mutaties scopen op de eigenaar.
|
||||
export async function userOwnsIdea(ideaId: string, userId: string): Promise<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 { registerSetPbiPrTool } from './tools/set-pbi-pr.js'
|
||||
import { registerMarkPbiPrMergedTool } from './tools/mark-pbi-pr-merged.js'
|
||||
import { registerGetIdeaContextTool } from './tools/get-idea-context.js'
|
||||
import { registerUpdateIdeaGrillMdTool } from './tools/update-idea-grill-md.js'
|
||||
import { registerUpdateIdeaPlanMdTool } from './tools/update-idea-plan-md.js'
|
||||
import { registerLogIdeaDecisionTool } from './tools/log-idea-decision.js'
|
||||
import { registerImplementNextStoryPrompt } from './prompts/implement-next-story.js'
|
||||
import { getAuth } from './auth.js'
|
||||
import { registerWorker } from './presence/worker.js'
|
||||
import { startHeartbeat } from './presence/heartbeat.js'
|
||||
import { registerShutdownHandlers } from './presence/shutdown.js'
|
||||
|
||||
const VERSION = '0.3.0'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
// Read version dynamically from package.json — voorheen hardcoded en
|
||||
// veroorzaakte sync-issues bij deployment. Lees op module-load.
|
||||
function readPkgVersion(): string {
|
||||
try {
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const pkgPath = join(here, '..', 'package.json')
|
||||
return (JSON.parse(readFileSync(pkgPath, 'utf8')) as { version?: string }).version ?? '0.0.0'
|
||||
} catch {
|
||||
return '0.0.0'
|
||||
}
|
||||
}
|
||||
const VERSION = readPkgVersion()
|
||||
|
||||
async function main() {
|
||||
const server = new McpServer(
|
||||
|
|
@ -65,6 +84,11 @@ async function main() {
|
|||
registerCheckQueueEmptyTool(server)
|
||||
registerSetPbiPrTool(server)
|
||||
registerMarkPbiPrMergedTool(server)
|
||||
// M12: idee-job tools
|
||||
registerGetIdeaContextTool(server)
|
||||
registerUpdateIdeaGrillMdTool(server)
|
||||
registerUpdateIdeaPlanMdTool(server)
|
||||
registerLogIdeaDecisionTool(server)
|
||||
registerImplementNextStoryPrompt(server)
|
||||
|
||||
// Presence bootstrap MUST run before server.connect — the stdio transport
|
||||
|
|
|
|||
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 { prisma } from '../prisma.js'
|
||||
import { requireWriteAccess } from '../auth.js'
|
||||
import { userCanAccessStory } from '../access.js'
|
||||
import { userCanAccessStory, userOwnsIdea } from '../access.js'
|
||||
import { toolError, toolJson, withToolErrors } from '../errors.js'
|
||||
|
||||
const PENDING_TTL_HOURS = 24
|
||||
const POLL_INTERVAL_MS = 2_000
|
||||
const MAX_WAIT_SECONDS = 600
|
||||
|
||||
const inputSchema = z.object({
|
||||
story_id: z.string().min(1),
|
||||
question: z.string().min(1).max(4_000),
|
||||
options: z.array(z.string().min(1)).max(8).optional(),
|
||||
task_id: z.string().min(1).optional(),
|
||||
wait_seconds: z.number().int().min(0).max(MAX_WAIT_SECONDS).optional(),
|
||||
})
|
||||
// M12: schema accepteert exact één van story_id of idea_id (xor refine).
|
||||
const inputSchema = z
|
||||
.object({
|
||||
story_id: z.string().min(1).optional(),
|
||||
idea_id: z.string().min(1).optional(),
|
||||
question: z.string().min(1).max(4_000),
|
||||
options: z.array(z.string().min(1)).max(8).optional(),
|
||||
task_id: z.string().min(1).optional(),
|
||||
wait_seconds: z.number().int().min(0).max(MAX_WAIT_SECONDS).optional(),
|
||||
})
|
||||
.refine((d) => Boolean(d.story_id) !== Boolean(d.idea_id), {
|
||||
message: 'Provide exactly one of story_id or idea_id',
|
||||
})
|
||||
|
||||
function summarize(q: {
|
||||
id: string
|
||||
|
|
@ -57,36 +63,60 @@ export function registerAskUserQuestionTool(server: McpServer) {
|
|||
'demo accounts.',
|
||||
inputSchema,
|
||||
},
|
||||
async ({ story_id, question, options, task_id, wait_seconds }) =>
|
||||
async ({ story_id, idea_id, question, options, task_id, wait_seconds }) =>
|
||||
withToolErrors(async () => {
|
||||
const auth = await requireWriteAccess()
|
||||
if (!(await userCanAccessStory(story_id, auth.userId))) {
|
||||
return toolError(`Story ${story_id} not found or not accessible`)
|
||||
}
|
||||
|
||||
const story = await prisma.story.findUnique({
|
||||
where: { id: story_id },
|
||||
select: { product_id: true },
|
||||
})
|
||||
if (!story) {
|
||||
return toolError(`Story ${story_id} not found`)
|
||||
}
|
||||
|
||||
if (task_id) {
|
||||
const task = await prisma.task.findFirst({
|
||||
where: { id: task_id, story_id },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!task) {
|
||||
return toolError(`Task ${task_id} does not belong to story ${story_id}`)
|
||||
// M12: branch on which scope was provided. story_id en idea_id sluiten
|
||||
// elkaar uit (zod-refine in inputSchema).
|
||||
let productId: string
|
||||
if (idea_id) {
|
||||
if (!(await userOwnsIdea(idea_id, auth.userId))) {
|
||||
return toolError(`Idea ${idea_id} not found`)
|
||||
}
|
||||
const idea = await prisma.idea.findUnique({
|
||||
where: { id: idea_id },
|
||||
select: { product_id: true },
|
||||
})
|
||||
if (!idea?.product_id) {
|
||||
// Idee zonder product mag pas Q&A starten als product gekoppeld is
|
||||
// (M12 grill-keuze 3: product met repo verplicht voor grill).
|
||||
return toolError(`Idea ${idea_id} has no linked product`)
|
||||
}
|
||||
productId = idea.product_id
|
||||
} else if (story_id) {
|
||||
if (!(await userCanAccessStory(story_id, auth.userId))) {
|
||||
return toolError(`Story ${story_id} not found or not accessible`)
|
||||
}
|
||||
const story = await prisma.story.findUnique({
|
||||
where: { id: story_id },
|
||||
select: { product_id: true },
|
||||
})
|
||||
if (!story) {
|
||||
return toolError(`Story ${story_id} not found`)
|
||||
}
|
||||
productId = story.product_id
|
||||
|
||||
if (task_id) {
|
||||
const task = await prisma.task.findFirst({
|
||||
where: { id: task_id, story_id },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!task) {
|
||||
return toolError(`Task ${task_id} does not belong to story ${story_id}`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Mag niet voorkomen door de zod-refine, maar TS-narrow.
|
||||
return toolError('Provide exactly one of story_id or idea_id')
|
||||
}
|
||||
|
||||
const created = await prisma.claudeQuestion.create({
|
||||
data: {
|
||||
story_id,
|
||||
story_id: story_id ?? null,
|
||||
idea_id: idea_id ?? null,
|
||||
task_id: task_id ?? null,
|
||||
product_id: story.product_id,
|
||||
product_id: productId,
|
||||
asked_by: auth.userId,
|
||||
question,
|
||||
// Prisma's `Json?`-veld accepteert geen `null`-literal in `data`;
|
||||
|
|
|
|||
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 type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { prisma } from '../prisma.js'
|
||||
import { toolJson, withToolErrors } from '../errors.js'
|
||||
|
||||
const VERSION = '0.1.0'
|
||||
// Read once at module-load. Health is hot-path enough that we don't want
|
||||
// disk-IO per call, and the version string is fixed for the running process.
|
||||
function readPkgVersion(): string {
|
||||
try {
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
// src/tools/health.ts → src/tools → src → repo-root
|
||||
const pkgPath = join(here, '..', '..', 'package.json')
|
||||
const raw = readFileSync(pkgPath, 'utf8')
|
||||
return (JSON.parse(raw) as { version?: string }).version ?? '0.0.0'
|
||||
} catch {
|
||||
return '0.0.0'
|
||||
}
|
||||
}
|
||||
const VERSION = readPkgVersion()
|
||||
|
||||
export function registerHealthTool(server: McpServer) {
|
||||
server.registerTool(
|
||||
|
|
|
|||
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 },
|
||||
select: { task: { select: { story_id: true } } },
|
||||
})
|
||||
if (job) {
|
||||
if (job?.task) {
|
||||
const activeSiblings = await prisma.claudeJob.count({
|
||||
where: {
|
||||
task: { story_id: job.task.story_id },
|
||||
|
|
@ -283,6 +283,8 @@ export function registerUpdateJobStatusTool(server: McpServer) {
|
|||
user_id: true,
|
||||
product_id: true,
|
||||
task_id: true,
|
||||
idea_id: true,
|
||||
kind: true,
|
||||
verify_result: true,
|
||||
task: { select: { verify_only: true, verify_required: true } },
|
||||
},
|
||||
|
|
@ -320,9 +322,16 @@ export function registerUpdateJobStatusTool(server: McpServer) {
|
|||
skipWorktreeCleanup = plan.skipWorktreeCleanup
|
||||
}
|
||||
|
||||
// Auto-PR: best-effort, only when push actually happened
|
||||
// Auto-PR: best-effort, only when push actually happened.
|
||||
// M12: idee-jobs hebben geen task_id en geen branch — skip auto-PR.
|
||||
let prUrl: string | null = null
|
||||
if (actualStatus === 'done' && pushedAt && branchToWrite) {
|
||||
if (
|
||||
actualStatus === 'done' &&
|
||||
pushedAt &&
|
||||
branchToWrite &&
|
||||
job.kind === 'TASK_IMPLEMENTATION' &&
|
||||
job.task_id
|
||||
) {
|
||||
const worktreeDir =
|
||||
process.env.SCRUM4ME_AGENT_WORKTREE_DIR ??
|
||||
path.join(os.homedir(), '.scrum4me-agent-worktrees')
|
||||
|
|
@ -367,6 +376,34 @@ export function registerUpdateJobStatusTool(server: McpServer) {
|
|||
},
|
||||
})
|
||||
|
||||
// M12: bij failed voor IDEA_*-jobs: zet idea.status op
|
||||
// GRILL_FAILED / PLAN_FAILED + log JOB_EVENT. Bij done laten we de
|
||||
// idea-status met rust — die wordt door update_idea_*_md gezet.
|
||||
if (actualStatus === 'failed' && job.idea_id) {
|
||||
const newIdeaStatus =
|
||||
job.kind === 'IDEA_GRILL'
|
||||
? 'GRILL_FAILED'
|
||||
: job.kind === 'IDEA_MAKE_PLAN'
|
||||
? 'PLAN_FAILED'
|
||||
: null
|
||||
if (newIdeaStatus) {
|
||||
await prisma.$transaction([
|
||||
prisma.idea.update({
|
||||
where: { id: job.idea_id },
|
||||
data: { status: newIdeaStatus },
|
||||
}),
|
||||
prisma.ideaLog.create({
|
||||
data: {
|
||||
idea_id: job.idea_id,
|
||||
type: 'JOB_EVENT',
|
||||
content: `${job.kind} failed`,
|
||||
metadata: { job_id, error: errorToWrite ?? null },
|
||||
},
|
||||
}),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// Notify UI via SSE
|
||||
try {
|
||||
const pg = new Client({ connectionString: process.env.DATABASE_URL })
|
||||
|
|
|
|||
|
|
@ -305,17 +305,58 @@ async function getFullJobContext(jobId: string) {
|
|||
},
|
||||
},
|
||||
},
|
||||
product: { select: { id: true, name: true, repo_url: true } },
|
||||
idea: {
|
||||
include: {
|
||||
pbi: { select: { id: true, code: true, title: true } },
|
||||
},
|
||||
},
|
||||
product: { select: { id: true, name: true, repo_url: true, definition_of_done: true } },
|
||||
},
|
||||
})
|
||||
if (!job) return null
|
||||
|
||||
// M12: branch on kind. Idea-jobs hebben geen task/story/pbi/sprint; ze
|
||||
// hebben in plaats daarvan idea + embedded prompt_text.
|
||||
if (job.kind === 'IDEA_GRILL' || job.kind === 'IDEA_MAKE_PLAN') {
|
||||
if (!job.idea) return null
|
||||
const { idea } = job
|
||||
const { getIdeaPromptText } = await import('../lib/idea-prompts.js')
|
||||
return {
|
||||
job_id: job.id,
|
||||
kind: job.kind,
|
||||
status: 'claimed',
|
||||
idea: {
|
||||
id: idea.id,
|
||||
code: idea.code,
|
||||
title: idea.title,
|
||||
description: idea.description,
|
||||
grill_md: idea.grill_md,
|
||||
plan_md: idea.plan_md,
|
||||
status: idea.status,
|
||||
product_id: idea.product_id,
|
||||
},
|
||||
product: {
|
||||
id: job.product.id,
|
||||
name: job.product.name,
|
||||
repo_url: job.product.repo_url,
|
||||
definition_of_done: job.product.definition_of_done,
|
||||
},
|
||||
pbi: idea.pbi,
|
||||
repo_url: job.product.repo_url,
|
||||
prompt_text: getIdeaPromptText(job.kind),
|
||||
branch_suggestion: `feat/idea-${idea.code.toLowerCase()}-${job.kind === 'IDEA_GRILL' ? 'grill' : 'plan'}`,
|
||||
}
|
||||
}
|
||||
|
||||
// TASK_IMPLEMENTATION (default) — bestaande gedrag onaangetast.
|
||||
const { task } = job
|
||||
if (!task) return null
|
||||
const { story } = task
|
||||
const { pbi, sprint } = story
|
||||
|
||||
return {
|
||||
job_id: job.id,
|
||||
kind: job.kind,
|
||||
status: 'claimed',
|
||||
task: {
|
||||
id: task.id,
|
||||
|
|
@ -378,9 +419,23 @@ export function registerWaitForJobTool(server: McpServer) {
|
|||
if (jobId) {
|
||||
const ctx = await getFullJobContext(jobId)
|
||||
if (!ctx) return toolError('Job claimed but context fetch failed')
|
||||
const wt = await attachWorktreeToJob(ctx.product.id, jobId, ctx.story.id, ctx.task.repo_url)
|
||||
if ('error' in wt) return toolError(wt.error)
|
||||
return toolJson({ ...ctx, worktree_path: wt.worktree_path, branch_name: wt.branch_name })
|
||||
// M12: idee-jobs hebben geen worktree nodig — de agent werkt in de
|
||||
// bestaande user-repo (geen branch/commit-flow). Alleen task-jobs
|
||||
// krijgen een worktree.
|
||||
if (ctx.kind === 'TASK_IMPLEMENTATION') {
|
||||
if (!ctx.story || !ctx.task) {
|
||||
return toolError('Task-job claimed but story/task context is incomplete')
|
||||
}
|
||||
const wt = await attachWorktreeToJob(
|
||||
ctx.product.id,
|
||||
jobId,
|
||||
ctx.story.id,
|
||||
ctx.task.repo_url,
|
||||
)
|
||||
if ('error' in wt) return toolError(wt.error)
|
||||
return toolJson({ ...ctx, worktree_path: wt.worktree_path, branch_name: wt.branch_name })
|
||||
}
|
||||
return toolJson(ctx)
|
||||
}
|
||||
|
||||
// 3. No job available — LISTEN and poll until timeout
|
||||
|
|
@ -416,9 +471,20 @@ export function registerWaitForJobTool(server: McpServer) {
|
|||
if (jobId) {
|
||||
const ctx = await getFullJobContext(jobId)
|
||||
if (!ctx) return toolError('Job claimed but context fetch failed')
|
||||
const wt = await attachWorktreeToJob(ctx.product.id, jobId, ctx.story.id, ctx.task.repo_url)
|
||||
if ('error' in wt) return toolError(wt.error)
|
||||
return toolJson({ ...ctx, worktree_path: wt.worktree_path, branch_name: wt.branch_name })
|
||||
if (ctx.kind === 'TASK_IMPLEMENTATION') {
|
||||
if (!ctx.story || !ctx.task) {
|
||||
return toolError('Task-job claimed but story/task context is incomplete')
|
||||
}
|
||||
const wt = await attachWorktreeToJob(
|
||||
ctx.product.id,
|
||||
jobId,
|
||||
ctx.story.id,
|
||||
ctx.task.repo_url,
|
||||
)
|
||||
if ('error' in wt) return toolError(wt.error)
|
||||
return toolJson({ ...ctx, worktree_path: wt.worktree_path, branch_name: wt.branch_name })
|
||||
}
|
||||
return toolJson(ctx)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue