Compare commits
20 commits
feat/pbi-6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fba2d67796 | ||
|
|
51fc65e715 | ||
|
|
84c194d4e5 | ||
|
|
55fa133150 | ||
|
|
93d881318d | ||
|
|
9ffa25f053 | ||
|
|
da1fe415c4 | ||
|
|
7d217cf443 | ||
|
|
51533cf48e | ||
|
|
ed94d5b7e1 | ||
|
|
0a18f565d2 | ||
|
|
32929eee93 | ||
|
|
233e0ef3b6 | ||
|
|
69fabc58f6 | ||
|
|
ae017b8644 | ||
|
|
e13a470024 | ||
|
|
e64ece3d41 | ||
|
|
52c167c0b3 | ||
|
|
96f5b0dd03 | ||
|
|
2fbb36bdbe |
38 changed files with 2273 additions and 212 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -12,3 +12,6 @@ prisma/generated
|
||||||
# Editor
|
# Editor
|
||||||
.vscode
|
.vscode
|
||||||
.idea
|
.idea
|
||||||
|
|
||||||
|
# Claude Code worktrees (per-session, never tracked)
|
||||||
|
.claude/worktrees/
|
||||||
|
|
|
||||||
165
__tests__/create-sprint.test.ts
Normal file
165
__tests__/create-sprint.test.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { Prisma } from '@prisma/client'
|
||||||
|
|
||||||
|
vi.mock('../src/prisma.js', () => ({
|
||||||
|
prisma: {
|
||||||
|
sprint: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../src/auth.js', () => ({
|
||||||
|
requireWriteAccess: vi.fn(),
|
||||||
|
PermissionDeniedError: class PermissionDeniedError extends Error {
|
||||||
|
constructor(message = 'Demo accounts cannot perform write operations') {
|
||||||
|
super(message)
|
||||||
|
this.name = 'PermissionDeniedError'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../src/access.js', () => ({
|
||||||
|
userCanAccessProduct: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { prisma } from '../src/prisma.js'
|
||||||
|
import { requireWriteAccess } from '../src/auth.js'
|
||||||
|
import { userCanAccessProduct } from '../src/access.js'
|
||||||
|
import { handleCreateSprint } from '../src/tools/create-sprint.js'
|
||||||
|
|
||||||
|
const mockPrisma = prisma as unknown as {
|
||||||
|
sprint: {
|
||||||
|
findMany: ReturnType<typeof vi.fn>
|
||||||
|
create: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const mockRequireWriteAccess = requireWriteAccess as ReturnType<typeof vi.fn>
|
||||||
|
const mockUserCanAccessProduct = userCanAccessProduct as ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
const PRODUCT_ID = 'prod-1'
|
||||||
|
const USER_ID = 'user-1'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockRequireWriteAccess.mockResolvedValue({ userId: USER_ID, tokenId: 'tok-1', username: 'alice', isDemo: false })
|
||||||
|
mockUserCanAccessProduct.mockResolvedValue(true)
|
||||||
|
mockPrisma.sprint.findMany.mockResolvedValue([])
|
||||||
|
})
|
||||||
|
|
||||||
|
function parseResult(result: Awaited<ReturnType<typeof handleCreateSprint>>) {
|
||||||
|
const text = result.content?.[0]?.type === 'text' ? result.content[0].text : ''
|
||||||
|
try { return JSON.parse(text) } catch { return text }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('handleCreateSprint', () => {
|
||||||
|
it('happy path: creates sprint with auto-generated code', async () => {
|
||||||
|
mockPrisma.sprint.create.mockResolvedValue({
|
||||||
|
id: 'spr-1',
|
||||||
|
code: 'S-2026-05-11-1',
|
||||||
|
sprint_goal: 'My goal',
|
||||||
|
status: 'OPEN',
|
||||||
|
start_date: new Date('2026-05-11'),
|
||||||
|
created_at: new Date('2026-05-11T10:00:00Z'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await handleCreateSprint({
|
||||||
|
product_id: PRODUCT_ID,
|
||||||
|
sprint_goal: 'My goal',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockPrisma.sprint.create).toHaveBeenCalledTimes(1)
|
||||||
|
const callArgs = mockPrisma.sprint.create.mock.calls[0][0]
|
||||||
|
expect(callArgs.data.product_id).toBe(PRODUCT_ID)
|
||||||
|
expect(callArgs.data.status).toBe('OPEN')
|
||||||
|
expect(callArgs.data.sprint_goal).toBe('My goal')
|
||||||
|
expect(callArgs.data.code).toMatch(/^S-\d{4}-\d{2}-\d{2}-1$/)
|
||||||
|
expect(callArgs.data.start_date).toBeInstanceOf(Date)
|
||||||
|
|
||||||
|
const parsed = parseResult(result)
|
||||||
|
expect(parsed.id).toBe('spr-1')
|
||||||
|
expect(parsed.status).toBe('OPEN')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses user-provided code when given', async () => {
|
||||||
|
mockPrisma.sprint.create.mockResolvedValue({
|
||||||
|
id: 'spr-2',
|
||||||
|
code: 'CUSTOM-CODE',
|
||||||
|
sprint_goal: 'g',
|
||||||
|
status: 'OPEN',
|
||||||
|
start_date: new Date(),
|
||||||
|
created_at: new Date(),
|
||||||
|
})
|
||||||
|
|
||||||
|
await handleCreateSprint({
|
||||||
|
product_id: PRODUCT_ID,
|
||||||
|
code: 'CUSTOM-CODE',
|
||||||
|
sprint_goal: 'g',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockPrisma.sprint.create).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockPrisma.sprint.findMany).not.toHaveBeenCalled()
|
||||||
|
expect(mockPrisma.sprint.create.mock.calls[0][0].data.code).toBe('CUSTOM-CODE')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('auto-code increments past existing same-day sprints', async () => {
|
||||||
|
// Codes moeten relatief aan "vandaag" zijn: generateNextSprintCode telt
|
||||||
|
// alleen same-day sprints. Hardcoded datums maakten deze test datum-flaky.
|
||||||
|
const today = new Date().toISOString().slice(0, 10)
|
||||||
|
mockPrisma.sprint.findMany.mockResolvedValue([
|
||||||
|
{ code: `S-${today}-1` },
|
||||||
|
{ code: `S-${today}-3` },
|
||||||
|
{ code: 'S-2020-01-01-7' },
|
||||||
|
])
|
||||||
|
mockPrisma.sprint.create.mockResolvedValue({
|
||||||
|
id: 'spr-3', code: 'X', sprint_goal: 'g', status: 'OPEN', start_date: new Date(), created_at: new Date(),
|
||||||
|
})
|
||||||
|
|
||||||
|
await handleCreateSprint({ product_id: PRODUCT_ID, sprint_goal: 'g' })
|
||||||
|
|
||||||
|
expect(mockPrisma.sprint.create.mock.calls[0][0].data.code).toBe(`S-${today}-4`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retries on P2002 unique conflict', async () => {
|
||||||
|
const conflict = new Prisma.PrismaClientKnownRequestError('unique', {
|
||||||
|
code: 'P2002', clientVersion: 'x', meta: { target: ['product_id', 'code'] },
|
||||||
|
})
|
||||||
|
mockPrisma.sprint.create
|
||||||
|
.mockRejectedValueOnce(conflict)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
id: 'spr-r', code: 'S-2026-05-11-2', sprint_goal: 'g', status: 'OPEN',
|
||||||
|
start_date: new Date(), created_at: new Date(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await handleCreateSprint({ product_id: PRODUCT_ID, sprint_goal: 'g' })
|
||||||
|
|
||||||
|
expect(mockPrisma.sprint.create).toHaveBeenCalledTimes(2)
|
||||||
|
expect(parseResult(result).id).toBe('spr-r')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns error when user cannot access product', async () => {
|
||||||
|
mockUserCanAccessProduct.mockResolvedValue(false)
|
||||||
|
const result = await handleCreateSprint({ product_id: PRODUCT_ID, sprint_goal: 'g' })
|
||||||
|
|
||||||
|
expect(mockPrisma.sprint.create).not.toHaveBeenCalled()
|
||||||
|
const text = result.content?.[0]?.type === 'text' ? result.content[0].text : ''
|
||||||
|
expect(text).toMatch(/not found or not accessible/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses provided start_date when given', async () => {
|
||||||
|
mockPrisma.sprint.create.mockResolvedValue({
|
||||||
|
id: 'spr-d', code: 'X', sprint_goal: 'g', status: 'OPEN',
|
||||||
|
start_date: new Date('2026-01-01'), created_at: new Date(),
|
||||||
|
})
|
||||||
|
|
||||||
|
await handleCreateSprint({
|
||||||
|
product_id: PRODUCT_ID,
|
||||||
|
sprint_goal: 'g',
|
||||||
|
start_date: '2026-01-01',
|
||||||
|
})
|
||||||
|
|
||||||
|
const callArgs = mockPrisma.sprint.create.mock.calls[0][0]
|
||||||
|
expect(callArgs.data.start_date.toISOString().slice(0, 10)).toBe('2026-01-01')
|
||||||
|
})
|
||||||
|
})
|
||||||
141
__tests__/create-story.test.ts
Normal file
141
__tests__/create-story.test.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('../src/prisma.js', () => ({
|
||||||
|
prisma: {
|
||||||
|
pbi: { findUnique: vi.fn() },
|
||||||
|
sprint: { findUnique: vi.fn() },
|
||||||
|
story: {
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
findMany: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../src/auth.js', () => ({
|
||||||
|
requireWriteAccess: vi.fn(),
|
||||||
|
PermissionDeniedError: class PermissionDeniedError extends Error {
|
||||||
|
constructor(message = 'Demo accounts cannot perform write operations') {
|
||||||
|
super(message)
|
||||||
|
this.name = 'PermissionDeniedError'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../src/access.js', () => ({
|
||||||
|
userCanAccessProduct: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { prisma } from '../src/prisma.js'
|
||||||
|
import { requireWriteAccess } from '../src/auth.js'
|
||||||
|
import { userCanAccessProduct } from '../src/access.js'
|
||||||
|
import { handleCreateStory } from '../src/tools/create-story.js'
|
||||||
|
|
||||||
|
const mockPrisma = prisma as unknown as {
|
||||||
|
pbi: { findUnique: ReturnType<typeof vi.fn> }
|
||||||
|
sprint: { findUnique: ReturnType<typeof vi.fn> }
|
||||||
|
story: {
|
||||||
|
findFirst: ReturnType<typeof vi.fn>
|
||||||
|
findMany: ReturnType<typeof vi.fn>
|
||||||
|
create: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const mockRequireWriteAccess = requireWriteAccess as ReturnType<typeof vi.fn>
|
||||||
|
const mockUserCanAccessProduct = userCanAccessProduct as ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
const PRODUCT_ID = 'prod-1'
|
||||||
|
const PBI_ID = 'pbi-1'
|
||||||
|
const SPRINT_ID = 'spr-1'
|
||||||
|
const USER_ID = 'user-1'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockRequireWriteAccess.mockResolvedValue({ userId: USER_ID, tokenId: 'tok-1', username: 'alice', isDemo: false })
|
||||||
|
mockUserCanAccessProduct.mockResolvedValue(true)
|
||||||
|
mockPrisma.pbi.findUnique.mockResolvedValue({ product_id: PRODUCT_ID })
|
||||||
|
mockPrisma.story.findMany.mockResolvedValue([])
|
||||||
|
mockPrisma.story.findFirst.mockResolvedValue(null)
|
||||||
|
mockPrisma.story.create.mockImplementation((args: { data: Record<string, unknown> }) =>
|
||||||
|
Promise.resolve({ id: 'story-1', created_at: new Date('2026-05-14T10:00:00Z'), ...args.data }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function parseResult(result: Awaited<ReturnType<typeof handleCreateStory>>) {
|
||||||
|
const text = result.content?.[0]?.type === 'text' ? result.content[0].text : ''
|
||||||
|
try { return JSON.parse(text) } catch { return text }
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorText(result: Awaited<ReturnType<typeof handleCreateStory>>): string {
|
||||||
|
return result.content?.[0]?.type === 'text' ? result.content[0].text : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('handleCreateStory', () => {
|
||||||
|
it('without sprint_id: creates story with status OPEN and no sprint', async () => {
|
||||||
|
const result = await handleCreateStory({ pbi_id: PBI_ID, title: 'A story', priority: 2 })
|
||||||
|
|
||||||
|
expect(mockPrisma.sprint.findUnique).not.toHaveBeenCalled()
|
||||||
|
const data = mockPrisma.story.create.mock.calls[0][0].data
|
||||||
|
expect(data.status).toBe('OPEN')
|
||||||
|
expect(data.sprint_id).toBeNull()
|
||||||
|
expect(data.product_id).toBe(PRODUCT_ID)
|
||||||
|
expect(parseResult(result).status).toBe('OPEN')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('with valid sprint_id: links story to sprint with status IN_SPRINT', async () => {
|
||||||
|
mockPrisma.sprint.findUnique.mockResolvedValue({ product_id: PRODUCT_ID })
|
||||||
|
|
||||||
|
const result = await handleCreateStory({
|
||||||
|
pbi_id: PBI_ID,
|
||||||
|
title: 'A story',
|
||||||
|
priority: 2,
|
||||||
|
sprint_id: SPRINT_ID,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockPrisma.sprint.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { id: SPRINT_ID },
|
||||||
|
select: { product_id: true },
|
||||||
|
})
|
||||||
|
const data = mockPrisma.story.create.mock.calls[0][0].data
|
||||||
|
expect(data.status).toBe('IN_SPRINT')
|
||||||
|
expect(data.sprint_id).toBe(SPRINT_ID)
|
||||||
|
expect(parseResult(result).sprint_id).toBe(SPRINT_ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a non-existent sprint_id', async () => {
|
||||||
|
mockPrisma.sprint.findUnique.mockResolvedValue(null)
|
||||||
|
|
||||||
|
const result = await handleCreateStory({
|
||||||
|
pbi_id: PBI_ID,
|
||||||
|
title: 'A story',
|
||||||
|
priority: 2,
|
||||||
|
sprint_id: 'missing',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockPrisma.story.create).not.toHaveBeenCalled()
|
||||||
|
expect(errorText(result)).toMatch(/Sprint missing not found/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a sprint from a different product', async () => {
|
||||||
|
mockPrisma.sprint.findUnique.mockResolvedValue({ product_id: 'other-product' })
|
||||||
|
|
||||||
|
const result = await handleCreateStory({
|
||||||
|
pbi_id: PBI_ID,
|
||||||
|
title: 'A story',
|
||||||
|
priority: 2,
|
||||||
|
sprint_id: SPRINT_ID,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockPrisma.story.create).not.toHaveBeenCalled()
|
||||||
|
expect(errorText(result)).toMatch(/different product/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns error when PBI not found', async () => {
|
||||||
|
mockPrisma.pbi.findUnique.mockResolvedValue(null)
|
||||||
|
|
||||||
|
const result = await handleCreateStory({ pbi_id: 'missing', title: 'A story', priority: 2 })
|
||||||
|
|
||||||
|
expect(mockPrisma.sprint.findUnique).not.toHaveBeenCalled()
|
||||||
|
expect(mockPrisma.story.create).not.toHaveBeenCalled()
|
||||||
|
expect(errorText(result)).toMatch(/PBI missing not found/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -113,6 +113,71 @@ describe('createWorktreeForJob', () => {
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow('Worktree path already exists')
|
).rejects.toThrow('Worktree path already exists')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('reuseBranch: reuses an existing local branch', async () => {
|
||||||
|
const { repoDir, originDir } = await setupRepo()
|
||||||
|
tmpDirs.push(repoDir, originDir)
|
||||||
|
await makeWorktreeParent()
|
||||||
|
|
||||||
|
// Sibling already created the branch locally.
|
||||||
|
await git(['branch', 'feat/sprint-abc', 'origin/main'], repoDir)
|
||||||
|
|
||||||
|
const result = await createWorktreeForJob({
|
||||||
|
repoRoot: repoDir,
|
||||||
|
jobId: 'job-reuse-local',
|
||||||
|
branchName: 'feat/sprint-abc',
|
||||||
|
baseRef: 'origin/main',
|
||||||
|
reuseBranch: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { stdout } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], result.worktreePath)
|
||||||
|
expect(stdout.trim()).toBe('feat/sprint-abc')
|
||||||
|
expect(result.branchName).toBe('feat/sprint-abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reuseBranch: recreates a local branch from origin when only the remote has it', async () => {
|
||||||
|
const { repoDir, originDir } = await setupRepo()
|
||||||
|
tmpDirs.push(repoDir, originDir)
|
||||||
|
await makeWorktreeParent()
|
||||||
|
|
||||||
|
// Branch exists on origin (a sibling pushed it, or the container was
|
||||||
|
// recreated and the local clone is fresh) but not as a local branch.
|
||||||
|
await git(['branch', 'feat/sprint-xyz', 'origin/main'], repoDir)
|
||||||
|
await git(['push', 'origin', 'feat/sprint-xyz'], repoDir)
|
||||||
|
await git(['branch', '-D', 'feat/sprint-xyz'], repoDir)
|
||||||
|
|
||||||
|
const result = await createWorktreeForJob({
|
||||||
|
repoRoot: repoDir,
|
||||||
|
jobId: 'job-reuse-origin',
|
||||||
|
branchName: 'feat/sprint-xyz',
|
||||||
|
baseRef: 'origin/main',
|
||||||
|
reuseBranch: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { stdout } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], result.worktreePath)
|
||||||
|
expect(stdout.trim()).toBe('feat/sprint-xyz')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reuseBranch: falls back to a fresh branch when it exists nowhere (cross-repo sprint)', async () => {
|
||||||
|
const { repoDir, originDir } = await setupRepo()
|
||||||
|
tmpDirs.push(repoDir, originDir)
|
||||||
|
await makeWorktreeParent()
|
||||||
|
|
||||||
|
// reuseBranch is decided sprint-wide; for the first job targeting THIS
|
||||||
|
// repo the branch exists neither locally nor on origin. Must not throw
|
||||||
|
// "invalid reference" — should create it fresh from baseRef.
|
||||||
|
const result = await createWorktreeForJob({
|
||||||
|
repoRoot: repoDir,
|
||||||
|
jobId: 'job-reuse-fresh',
|
||||||
|
branchName: 'feat/sprint-newrepo',
|
||||||
|
baseRef: 'origin/main',
|
||||||
|
reuseBranch: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { stdout } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], result.worktreePath)
|
||||||
|
expect(stdout.trim()).toBe('feat/sprint-newrepo')
|
||||||
|
expect(result.branchName).toBe('feat/sprint-newrepo')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('removeWorktreeForJob', () => {
|
describe('removeWorktreeForJob', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { getKindDefault, resolveJobConfig } from '../src/lib/job-config.js'
|
import { getKindDefault, resolveJobConfig, mapBudgetToEffort } from '../src/lib/job-config.js'
|
||||||
|
|
||||||
const KIND_EXPECTED = {
|
const KIND_EXPECTED = {
|
||||||
IDEA_GRILL: { model: 'claude-sonnet-4-6', thinking_budget: 12000, permission_mode: 'plan', max_turns: 15 },
|
IDEA_GRILL: { model: 'claude-sonnet-4-6', thinking_budget: 12000, permission_mode: 'acceptEdits', max_turns: 15 },
|
||||||
IDEA_MAKE_PLAN: { model: 'claude-opus-4-7', thinking_budget: 24000, permission_mode: 'plan', max_turns: 20 },
|
IDEA_MAKE_PLAN: { model: 'claude-opus-4-7', thinking_budget: 24000, permission_mode: 'acceptEdits', max_turns: 20 },
|
||||||
PLAN_CHAT: { model: 'claude-sonnet-4-6', thinking_budget: 6000, permission_mode: 'plan', max_turns: 5 },
|
PLAN_CHAT: { model: 'claude-sonnet-4-6', thinking_budget: 6000, permission_mode: 'acceptEdits', max_turns: 5 },
|
||||||
TASK_IMPLEMENTATION: { model: 'claude-sonnet-4-6', thinking_budget: 6000, permission_mode: 'bypassPermissions', max_turns: 50 },
|
TASK_IMPLEMENTATION: { model: 'claude-sonnet-4-6', thinking_budget: 6000, permission_mode: 'bypassPermissions', max_turns: 50 },
|
||||||
SPRINT_IMPLEMENTATION: { model: 'claude-sonnet-4-6', thinking_budget: 6000, permission_mode: 'bypassPermissions', max_turns: null },
|
SPRINT_IMPLEMENTATION: { model: 'claude-sonnet-4-6', thinking_budget: 6000, permission_mode: 'bypassPermissions', max_turns: null },
|
||||||
} as const
|
} as const
|
||||||
|
|
@ -86,12 +86,81 @@ describe('resolveJobConfig — cascade', () => {
|
||||||
expect(cfg.permission_mode).toBe('acceptEdits')
|
expect(cfg.permission_mode).toBe('acceptEdits')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('max_turns en allowed_tools blijven kind-default ook met product- en job-overrides (geen V1-cascade)', () => {
|
it('max_turns blijft kind-default ook met product- en job-overrides (geen V1-cascade)', () => {
|
||||||
const cfg = resolveJobConfig(
|
const cfg = resolveJobConfig(
|
||||||
{ kind: 'IDEA_GRILL', requested_model: 'claude-haiku-4-5-20251001' },
|
{ kind: 'IDEA_GRILL', requested_model: 'claude-haiku-4-5-20251001' },
|
||||||
{ preferred_model: 'claude-sonnet-4-6' },
|
{ preferred_model: 'claude-sonnet-4-6' },
|
||||||
)
|
)
|
||||||
expect(cfg.max_turns).toBe(15)
|
expect(cfg.max_turns).toBe(15)
|
||||||
expect(cfg.allowed_tools).toEqual(['Read', 'Grep', 'Glob', 'WebSearch', 'AskUserQuestion'])
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('KIND_DEFAULTS.allowed_tools', () => {
|
||||||
|
it('TASK_IMPLEMENTATION bevat geen claim-tools', () => {
|
||||||
|
const cfg = getKindDefault('TASK_IMPLEMENTATION')
|
||||||
|
expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__wait_for_job')
|
||||||
|
expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__check_queue_empty')
|
||||||
|
expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__get_idea_context')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('TASK_IMPLEMENTATION bevat de essentiële task-tools', () => {
|
||||||
|
const cfg = getKindDefault('TASK_IMPLEMENTATION')
|
||||||
|
expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_task_status')
|
||||||
|
expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_job_status')
|
||||||
|
expect(cfg.allowed_tools).toContain('mcp__scrum4me__verify_task_against_plan')
|
||||||
|
expect(cfg.allowed_tools).toContain('Bash')
|
||||||
|
expect(cfg.allowed_tools).toContain('Edit')
|
||||||
|
expect(cfg.allowed_tools).toContain('Write')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('SPRINT_IMPLEMENTATION bevat sprint-specifieke tools maar GEEN job_heartbeat (runner doet die)', () => {
|
||||||
|
const cfg = getKindDefault('SPRINT_IMPLEMENTATION')
|
||||||
|
expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_task_execution')
|
||||||
|
expect(cfg.allowed_tools).toContain('mcp__scrum4me__verify_sprint_task')
|
||||||
|
expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__job_heartbeat')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('IDEA_GRILL bevat update_idea_grill_md en geen wait_for_job', () => {
|
||||||
|
const cfg = getKindDefault('IDEA_GRILL')
|
||||||
|
expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_idea_grill_md')
|
||||||
|
expect(cfg.allowed_tools).toContain('mcp__scrum4me__log_idea_decision')
|
||||||
|
expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_job_status')
|
||||||
|
expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__wait_for_job')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('IDEA_MAKE_PLAN bevat update_idea_plan_md en geen wait_for_job', () => {
|
||||||
|
const cfg = getKindDefault('IDEA_MAKE_PLAN')
|
||||||
|
expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_idea_plan_md')
|
||||||
|
expect(cfg.allowed_tools).toContain('mcp__scrum4me__log_idea_decision')
|
||||||
|
expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__wait_for_job')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('alle kinds hebben non-null allowed_tools', () => {
|
||||||
|
for (const kind of ['IDEA_GRILL', 'IDEA_MAKE_PLAN', 'PLAN_CHAT', 'TASK_IMPLEMENTATION', 'SPRINT_IMPLEMENTATION']) {
|
||||||
|
const cfg = getKindDefault(kind)
|
||||||
|
expect(cfg.allowed_tools).not.toBeNull()
|
||||||
|
expect(Array.isArray(cfg.allowed_tools)).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mapBudgetToEffort', () => {
|
||||||
|
it.each([
|
||||||
|
[0, null],
|
||||||
|
[-1, null],
|
||||||
|
[1, 'medium'],
|
||||||
|
[3000, 'medium'],
|
||||||
|
[6000, 'medium'],
|
||||||
|
[6001, 'high'],
|
||||||
|
[9000, 'high'],
|
||||||
|
[12000, 'high'],
|
||||||
|
[12001, 'xhigh'],
|
||||||
|
[18000, 'xhigh'],
|
||||||
|
[24000, 'xhigh'],
|
||||||
|
[24001, 'max'],
|
||||||
|
[50000, 'max'],
|
||||||
|
[100000, 'max'],
|
||||||
|
])('budget %i → %s', (budget, expected) => {
|
||||||
|
expect(mapBudgetToEffort(budget)).toBe(expected)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
64
__tests__/kind-prompts.test.ts
Normal file
64
__tests__/kind-prompts.test.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import type { ClaudeJobKind } from '@prisma/client'
|
||||||
|
import { getKindPromptText, getIdeaPromptText } from '../src/lib/kind-prompts.js'
|
||||||
|
|
||||||
|
const KINDS: ClaudeJobKind[] = [
|
||||||
|
'IDEA_GRILL',
|
||||||
|
'IDEA_MAKE_PLAN',
|
||||||
|
'TASK_IMPLEMENTATION',
|
||||||
|
'SPRINT_IMPLEMENTATION',
|
||||||
|
'PLAN_CHAT',
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('getKindPromptText', () => {
|
||||||
|
it.each(KINDS)('returnt non-empty content voor %s', (kind) => {
|
||||||
|
const text = getKindPromptText(kind)
|
||||||
|
expect(text.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('TASK_IMPLEMENTATION-prompt verbiedt wait_for_job', () => {
|
||||||
|
const text = getKindPromptText('TASK_IMPLEMENTATION')
|
||||||
|
expect(text).toMatch(/GEEN.*wait_for_job/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('SPRINT_IMPLEMENTATION-prompt verbiedt job_heartbeat', () => {
|
||||||
|
const text = getKindPromptText('SPRINT_IMPLEMENTATION')
|
||||||
|
expect(text).toMatch(/GEEN.*job_heartbeat/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(KINDS)(
|
||||||
|
'%s-prompt noemt $PAYLOAD_PATH als variabele (alle kinds — runner doet substitution)',
|
||||||
|
(kind) => {
|
||||||
|
const text = getKindPromptText(kind)
|
||||||
|
expect(text).toContain('$PAYLOAD_PATH')
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it.each(['IDEA_GRILL', 'IDEA_MAKE_PLAN'] as const)(
|
||||||
|
'%s-prompt verwijst niet meer naar wait_for_job (refactor: runner claimt)',
|
||||||
|
(kind) => {
|
||||||
|
const text = getKindPromptText(kind)
|
||||||
|
expect(text).not.toContain('wait_for_job')
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it.each(['IDEA_GRILL', 'IDEA_MAKE_PLAN'] as const)(
|
||||||
|
'%s-prompt bevat geen onvervangen {idea_*} placeholders',
|
||||||
|
(kind) => {
|
||||||
|
const text = getKindPromptText(kind)
|
||||||
|
expect(text).not.toMatch(/\{idea_code\}|\{idea_title\}/)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getIdeaPromptText (back-compat)', () => {
|
||||||
|
it('returnt content voor IDEA_GRILL', () => {
|
||||||
|
expect(getIdeaPromptText('IDEA_GRILL').length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
it('returnt content voor IDEA_MAKE_PLAN', () => {
|
||||||
|
expect(getIdeaPromptText('IDEA_MAKE_PLAN').length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
it('returnt empty string voor non-idea kind', () => {
|
||||||
|
expect(getIdeaPromptText('TASK_IMPLEMENTATION')).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
140
__tests__/update-idea-plan-reviewed.test.ts
Normal file
140
__tests__/update-idea-plan-reviewed.test.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('../src/prisma.js', () => ({
|
||||||
|
prisma: {
|
||||||
|
idea: { update: vi.fn() },
|
||||||
|
ideaLog: { create: vi.fn() },
|
||||||
|
$transaction: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../src/auth.js', () => ({
|
||||||
|
requireWriteAccess: vi.fn(),
|
||||||
|
PermissionDeniedError: class PermissionDeniedError extends Error {
|
||||||
|
constructor(message = 'Demo accounts cannot perform write operations') {
|
||||||
|
super(message)
|
||||||
|
this.name = 'PermissionDeniedError'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../src/access.js', () => ({
|
||||||
|
userOwnsIdea: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { prisma } from '../src/prisma.js'
|
||||||
|
import { requireWriteAccess } from '../src/auth.js'
|
||||||
|
import { userOwnsIdea } from '../src/access.js'
|
||||||
|
import { handleUpdateIdeaPlanReviewed } from '../src/tools/update-idea-plan-reviewed.js'
|
||||||
|
|
||||||
|
const mockPrisma = prisma as unknown as {
|
||||||
|
idea: { update: ReturnType<typeof vi.fn> }
|
||||||
|
ideaLog: { create: ReturnType<typeof vi.fn> }
|
||||||
|
$transaction: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
const mockRequireWriteAccess = requireWriteAccess as ReturnType<typeof vi.fn>
|
||||||
|
const mockUserOwnsIdea = userOwnsIdea as ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
const IDEA_ID = 'idea-1'
|
||||||
|
const USER_ID = 'user-1'
|
||||||
|
const REVIEW_LOG = {
|
||||||
|
rounds: [{ score: 88 }],
|
||||||
|
convergence: { stable_at_round: 2 },
|
||||||
|
approval: { status: 'approved' },
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockRequireWriteAccess.mockResolvedValue({
|
||||||
|
userId: USER_ID,
|
||||||
|
tokenId: 'tok-1',
|
||||||
|
username: 'alice',
|
||||||
|
isDemo: false,
|
||||||
|
})
|
||||||
|
mockUserOwnsIdea.mockResolvedValue(true)
|
||||||
|
// $transaction returns the array of its two operations' results; the handler
|
||||||
|
// only reads result[0] (the idea.update result).
|
||||||
|
mockPrisma.$transaction.mockImplementation(async () => [
|
||||||
|
{ id: IDEA_ID, status: 'PLACEHOLDER', code: 'IDEA-1' },
|
||||||
|
{},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
function parseResult(result: Awaited<ReturnType<typeof handleUpdateIdeaPlanReviewed>>) {
|
||||||
|
const text = result.content?.[0]?.type === 'text' ? result.content[0].text : ''
|
||||||
|
try {
|
||||||
|
return JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The handler builds `data.status` inside the idea.update call passed to
|
||||||
|
// $transaction. We capture it by inspecting the prisma.idea.update mock args.
|
||||||
|
function statusPassedToUpdate(): string | undefined {
|
||||||
|
const call = mockPrisma.idea.update.mock.calls[0]
|
||||||
|
return call?.[0]?.data?.status
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('handleUpdateIdeaPlanReviewed — status transition', () => {
|
||||||
|
it('approval_status="approved" → PLAN_REVIEWED', async () => {
|
||||||
|
await handleUpdateIdeaPlanReviewed({
|
||||||
|
idea_id: IDEA_ID,
|
||||||
|
review_log: REVIEW_LOG,
|
||||||
|
approval_status: 'approved',
|
||||||
|
})
|
||||||
|
expect(statusPassedToUpdate()).toBe('PLAN_REVIEWED')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('approval_status="rejected" → PLAN_REVIEW_FAILED', async () => {
|
||||||
|
await handleUpdateIdeaPlanReviewed({
|
||||||
|
idea_id: IDEA_ID,
|
||||||
|
review_log: REVIEW_LOG,
|
||||||
|
approval_status: 'rejected',
|
||||||
|
})
|
||||||
|
expect(statusPassedToUpdate()).toBe('PLAN_REVIEW_FAILED')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('approval_status="pending" → PLAN_REVIEW_FAILED (needs manual approval, never silently approved)', async () => {
|
||||||
|
await handleUpdateIdeaPlanReviewed({
|
||||||
|
idea_id: IDEA_ID,
|
||||||
|
review_log: REVIEW_LOG,
|
||||||
|
approval_status: 'pending',
|
||||||
|
})
|
||||||
|
expect(statusPassedToUpdate()).toBe('PLAN_REVIEW_FAILED')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omitted approval_status → PLAN_REVIEW_FAILED (safe default, not PLAN_REVIEWED)', async () => {
|
||||||
|
await handleUpdateIdeaPlanReviewed({
|
||||||
|
idea_id: IDEA_ID,
|
||||||
|
review_log: REVIEW_LOG,
|
||||||
|
})
|
||||||
|
expect(statusPassedToUpdate()).toBe('PLAN_REVIEW_FAILED')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns "Idea not found" when the user does not own the idea', async () => {
|
||||||
|
mockUserOwnsIdea.mockResolvedValue(false)
|
||||||
|
const result = await handleUpdateIdeaPlanReviewed({
|
||||||
|
idea_id: IDEA_ID,
|
||||||
|
review_log: REVIEW_LOG,
|
||||||
|
approval_status: 'approved',
|
||||||
|
})
|
||||||
|
expect(parseResult(result)).toContain('Idea not found')
|
||||||
|
expect(mockPrisma.idea.update).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists review_log + reviewed_at and logs a PLAN_REVIEW_RESULT entry', async () => {
|
||||||
|
await handleUpdateIdeaPlanReviewed({
|
||||||
|
idea_id: IDEA_ID,
|
||||||
|
review_log: REVIEW_LOG,
|
||||||
|
approval_status: 'approved',
|
||||||
|
})
|
||||||
|
const updateArg = mockPrisma.idea.update.mock.calls[0]?.[0]
|
||||||
|
expect(updateArg?.data?.plan_review_log).toEqual(REVIEW_LOG)
|
||||||
|
expect(updateArg?.data?.reviewed_at).toBeInstanceOf(Date)
|
||||||
|
|
||||||
|
const logArg = mockPrisma.ideaLog.create.mock.calls[0]?.[0]
|
||||||
|
expect(logArg?.data?.type).toBe('PLAN_REVIEW_RESULT')
|
||||||
|
expect(logArg?.data?.idea_id).toBe(IDEA_ID)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -4,7 +4,7 @@ vi.mock('../src/prisma.js', () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
product: { findUnique: vi.fn() },
|
product: { findUnique: vi.fn() },
|
||||||
task: { findUnique: vi.fn() },
|
task: { findUnique: vi.fn() },
|
||||||
claudeJob: { findFirst: vi.fn(), findUnique: vi.fn() },
|
claudeJob: { findFirst: vi.fn(), findMany: vi.fn(), findUnique: vi.fn() },
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
@ -22,6 +22,7 @@ const mockPrisma = prisma as unknown as {
|
||||||
task: { findUnique: ReturnType<typeof vi.fn> }
|
task: { findUnique: ReturnType<typeof vi.fn> }
|
||||||
claudeJob: {
|
claudeJob: {
|
||||||
findFirst: ReturnType<typeof vi.fn>
|
findFirst: ReturnType<typeof vi.fn>
|
||||||
|
findMany: ReturnType<typeof vi.fn>
|
||||||
findUnique: ReturnType<typeof vi.fn>
|
findUnique: ReturnType<typeof vi.fn>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -41,9 +42,10 @@ beforeEach(() => {
|
||||||
mockPrisma.product.findUnique.mockResolvedValue({ auto_pr: true })
|
mockPrisma.product.findUnique.mockResolvedValue({ auto_pr: true })
|
||||||
mockPrisma.task.findUnique.mockResolvedValue({
|
mockPrisma.task.findUnique.mockResolvedValue({
|
||||||
title: 'Add feature',
|
title: 'Add feature',
|
||||||
|
repo_url: null,
|
||||||
story: { id: 'story-1', code: 'SCRUM-42', title: 'Story title' },
|
story: { id: 'story-1', code: 'SCRUM-42', title: 'Story title' },
|
||||||
})
|
})
|
||||||
mockPrisma.claudeJob.findFirst.mockResolvedValue(null) // no sibling PR by default
|
mockPrisma.claudeJob.findMany.mockResolvedValue([]) // no sibling PRs by default
|
||||||
// Default: legacy job zonder sprint_run (STORY-mode pad).
|
// Default: legacy job zonder sprint_run (STORY-mode pad).
|
||||||
mockPrisma.claudeJob.findUnique.mockResolvedValue({ sprint_run_id: null, sprint_run: null })
|
mockPrisma.claudeJob.findUnique.mockResolvedValue({ sprint_run_id: null, sprint_run: null })
|
||||||
mockCreatePr.mockResolvedValue({ url: 'https://github.com/org/repo/pull/99' })
|
mockCreatePr.mockResolvedValue({ url: 'https://github.com/org/repo/pull/99' })
|
||||||
|
|
@ -62,12 +64,27 @@ describe('maybeCreateAutoPr', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reuses sibling pr_url when another job in same story already opened a PR', async () => {
|
it('reuses sibling pr_url when another job in same story already opened a PR', async () => {
|
||||||
mockPrisma.claudeJob.findFirst.mockResolvedValue({ pr_url: 'https://github.com/org/repo/pull/77' })
|
mockPrisma.claudeJob.findMany.mockResolvedValue([
|
||||||
|
{ pr_url: 'https://github.com/org/repo/pull/77', task: { repo_url: null } },
|
||||||
|
])
|
||||||
const url = await maybeCreateAutoPr(BASE_OPTS)
|
const url = await maybeCreateAutoPr(BASE_OPTS)
|
||||||
expect(url).toBe('https://github.com/org/repo/pull/77')
|
expect(url).toBe('https://github.com/org/repo/pull/77')
|
||||||
expect(mockCreatePr).not.toHaveBeenCalled()
|
expect(mockCreatePr).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does NOT reuse a sibling PR from a different repo (cross-repo story)', async () => {
|
||||||
|
// Sibling targeted another repo via task.repo_url — its PR must not leak in.
|
||||||
|
mockPrisma.claudeJob.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
pr_url: 'https://github.com/org/other-repo/pull/12',
|
||||||
|
task: { repo_url: 'https://github.com/org/other-repo' },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const url = await maybeCreateAutoPr(BASE_OPTS)
|
||||||
|
expect(url).toBe('https://github.com/org/repo/pull/99') // fresh PR, not the sibling's
|
||||||
|
expect(mockCreatePr).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
it('returns null when auto_pr=false', async () => {
|
it('returns null when auto_pr=false', async () => {
|
||||||
mockPrisma.product.findUnique.mockResolvedValue({ auto_pr: false })
|
mockPrisma.product.findUnique.mockResolvedValue({ auto_pr: false })
|
||||||
const url = await maybeCreateAutoPr(BASE_OPTS)
|
const url = await maybeCreateAutoPr(BASE_OPTS)
|
||||||
|
|
@ -78,6 +95,7 @@ describe('maybeCreateAutoPr', () => {
|
||||||
it('uses story title without code prefix when story has no code', async () => {
|
it('uses story title without code prefix when story has no code', async () => {
|
||||||
mockPrisma.task.findUnique.mockResolvedValue({
|
mockPrisma.task.findUnique.mockResolvedValue({
|
||||||
title: 'Add feature',
|
title: 'Add feature',
|
||||||
|
repo_url: null,
|
||||||
story: { id: 'story-1', code: null, title: 'Story title' },
|
story: { id: 'story-1', code: null, title: 'Story title' },
|
||||||
})
|
})
|
||||||
await maybeCreateAutoPr(BASE_OPTS)
|
await maybeCreateAutoPr(BASE_OPTS)
|
||||||
|
|
@ -113,7 +131,9 @@ describe('maybeCreateAutoPr', () => {
|
||||||
sprint_run_id: 'run-1',
|
sprint_run_id: 'run-1',
|
||||||
sprint_run: { id: 'run-1', pr_strategy: 'SPRINT', sprint: { sprint_goal: 'Goal' } },
|
sprint_run: { id: 'run-1', pr_strategy: 'SPRINT', sprint: { sprint_goal: 'Goal' } },
|
||||||
})
|
})
|
||||||
mockPrisma.claudeJob.findFirst.mockResolvedValue({ pr_url: 'https://github.com/org/repo/pull/55' })
|
mockPrisma.claudeJob.findMany.mockResolvedValue([
|
||||||
|
{ pr_url: 'https://github.com/org/repo/pull/55', task: { repo_url: null } },
|
||||||
|
])
|
||||||
|
|
||||||
const url = await maybeCreateAutoPr(BASE_OPTS)
|
const url = await maybeCreateAutoPr(BASE_OPTS)
|
||||||
|
|
||||||
|
|
@ -121,6 +141,29 @@ describe('maybeCreateAutoPr', () => {
|
||||||
expect(mockCreatePr).not.toHaveBeenCalled()
|
expect(mockCreatePr).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('SPRINT-mode: cross-repo — sibling-PR van ander repo wordt niet hergebruikt', async () => {
|
||||||
|
mockPrisma.claudeJob.findUnique.mockResolvedValue({
|
||||||
|
sprint_run_id: 'run-1',
|
||||||
|
sprint_run: { id: 'run-1', pr_strategy: 'SPRINT', sprint: { sprint_goal: 'Goal' } },
|
||||||
|
})
|
||||||
|
// Deze job target een ander repo via task.repo_url.
|
||||||
|
mockPrisma.task.findUnique.mockResolvedValue({
|
||||||
|
title: 'MCP-taak',
|
||||||
|
repo_url: 'https://github.com/org/scrum4me-mcp',
|
||||||
|
story: { id: 'story-1', code: 'SCRUM-9', title: 'Story title' },
|
||||||
|
})
|
||||||
|
// Sibling met pr_url hoort bij het product-repo (repo_url null) → andere bucket.
|
||||||
|
mockPrisma.claudeJob.findMany.mockResolvedValue([
|
||||||
|
{ pr_url: 'https://github.com/org/repo/pull/201', task: { repo_url: null } },
|
||||||
|
])
|
||||||
|
|
||||||
|
const url = await maybeCreateAutoPr(BASE_OPTS)
|
||||||
|
|
||||||
|
// Geen hergebruik van de product-repo PR → eigen draft-PR voor het mcp-repo.
|
||||||
|
expect(url).toBe('https://github.com/org/repo/pull/99')
|
||||||
|
expect(mockCreatePr).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
it('returns null and does not throw when gh fails', async () => {
|
it('returns null and does not throw when gh fails', async () => {
|
||||||
mockCreatePr.mockResolvedValue({ error: 'gh CLI not found' })
|
mockCreatePr.mockResolvedValue({ error: 'gh CLI not found' })
|
||||||
const url = await maybeCreateAutoPr(BASE_OPTS)
|
const url = await maybeCreateAutoPr(BASE_OPTS)
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,26 @@ vi.mock('../src/git/push.js', () => ({
|
||||||
pushBranchForJob: vi.fn(),
|
pushBranchForJob: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('../src/prisma.js', () => ({
|
||||||
|
prisma: {
|
||||||
|
claudeJob: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
import { pushBranchForJob } from '../src/git/push.js'
|
import { pushBranchForJob } from '../src/git/push.js'
|
||||||
|
import { prisma } from '../src/prisma.js'
|
||||||
import { prepareDoneUpdate } from '../src/tools/update-job-status.js'
|
import { prepareDoneUpdate } from '../src/tools/update-job-status.js'
|
||||||
|
|
||||||
const mockPush = pushBranchForJob as ReturnType<typeof vi.fn>
|
const mockPush = pushBranchForJob as ReturnType<typeof vi.fn>
|
||||||
|
const mockFindUnique = (prisma as unknown as {
|
||||||
|
claudeJob: { findUnique: ReturnType<typeof vi.fn> }
|
||||||
|
}).claudeJob.findUnique
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
mockFindUnique.mockResolvedValue(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('prepareDoneUpdate', () => {
|
describe('prepareDoneUpdate', () => {
|
||||||
|
|
@ -39,8 +52,25 @@ describe('prepareDoneUpdate', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('derives branchName from jobId when branch is undefined', async () => {
|
it('reads branchName from DB (claudeJob.branch) when branch arg is undefined', async () => {
|
||||||
process.env.SCRUM4ME_AGENT_WORKTREE_DIR = '/wt'
|
process.env.SCRUM4ME_AGENT_WORKTREE_DIR = '/wt'
|
||||||
|
mockFindUnique.mockResolvedValue({ branch: 'feat/sprint-fvy30lvv' })
|
||||||
|
mockPush.mockResolvedValue({ pushed: true, remoteRef: 'refs/heads/feat/sprint-fvy30lvv' })
|
||||||
|
|
||||||
|
await prepareDoneUpdate('job-abc12345', undefined)
|
||||||
|
|
||||||
|
expect(mockFindUnique).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'job-abc12345' },
|
||||||
|
select: { branch: true },
|
||||||
|
})
|
||||||
|
expect(mockPush).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ branchName: 'feat/sprint-fvy30lvv' }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to feat/job-<8> when neither branch arg nor DB.branch is set', async () => {
|
||||||
|
process.env.SCRUM4ME_AGENT_WORKTREE_DIR = '/wt'
|
||||||
|
mockFindUnique.mockResolvedValue({ branch: null })
|
||||||
mockPush.mockResolvedValue({ pushed: true, remoteRef: 'refs/heads/feat/job-abc12345' })
|
mockPush.mockResolvedValue({ pushed: true, remoteRef: 'refs/heads/feat/job-abc12345' })
|
||||||
|
|
||||||
await prepareDoneUpdate('job-abc12345', undefined)
|
await prepareDoneUpdate('job-abc12345', undefined)
|
||||||
|
|
|
||||||
74
__tests__/update-job-status-timestamps.test.ts
Normal file
74
__tests__/update-job-status-timestamps.test.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
// Unit-tests voor resolveJobTimestamps — de status-gedreven timestamp-helper
|
||||||
|
// van update_job_status. Pure functie, geen mocks (zoals update-job-status-gate).
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { resolveJobTimestamps } from '../src/tools/update-job-status.js'
|
||||||
|
|
||||||
|
const NOW = new Date('2026-05-14T12:00:00.000Z')
|
||||||
|
const EARLIER = new Date('2026-05-14T11:00:00.000Z')
|
||||||
|
|
||||||
|
describe('resolveJobTimestamps', () => {
|
||||||
|
describe('running', () => {
|
||||||
|
it('sets started_at when not yet set, no finished_at', () => {
|
||||||
|
const r = resolveJobTimestamps('running', { claimed_at: EARLIER, started_at: null }, NOW)
|
||||||
|
expect(r.started_at).toBe(NOW)
|
||||||
|
expect(r.finished_at).toBeUndefined()
|
||||||
|
expect(r.claimed_at).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is set-once: does not re-stamp started_at when already set', () => {
|
||||||
|
const r = resolveJobTimestamps('running', { claimed_at: EARLIER, started_at: EARLIER }, NOW)
|
||||||
|
expect(r.started_at).toBeUndefined()
|
||||||
|
expect(r.finished_at).toBeUndefined()
|
||||||
|
expect(r.claimed_at).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('terminal transitions (done/failed/skipped)', () => {
|
||||||
|
it.each(['done', 'failed', 'skipped'] as const)(
|
||||||
|
'backfills started_at and sets finished_at for %s when started_at is null',
|
||||||
|
(status) => {
|
||||||
|
const r = resolveJobTimestamps(status, { claimed_at: EARLIER, started_at: null }, NOW)
|
||||||
|
expect(r.started_at).toBe(NOW)
|
||||||
|
expect(r.finished_at).toBe(NOW)
|
||||||
|
expect(r.claimed_at).toBeUndefined()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('only sets finished_at when started_at is already set', () => {
|
||||||
|
const r = resolveJobTimestamps('done', { claimed_at: EARLIER, started_at: EARLIER }, NOW)
|
||||||
|
expect(r.started_at).toBeUndefined()
|
||||||
|
expect(r.finished_at).toBe(NOW)
|
||||||
|
expect(r.claimed_at).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('claimed_at backfill', () => {
|
||||||
|
it.each(['running', 'done', 'failed', 'skipped'] as const)(
|
||||||
|
'backfills claimed_at for %s when it is null',
|
||||||
|
(status) => {
|
||||||
|
const r = resolveJobTimestamps(status, { claimed_at: null, started_at: null }, NOW)
|
||||||
|
expect(r.claimed_at).toBe(NOW)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('never returns claimed_at when it is already set', () => {
|
||||||
|
const r = resolveJobTimestamps('done', { claimed_at: EARLIER, started_at: EARLIER }, NOW)
|
||||||
|
expect(r.claimed_at).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns only finished_at when all timestamps are already set and status is terminal', () => {
|
||||||
|
const r = resolveJobTimestamps('failed', { claimed_at: EARLIER, started_at: EARLIER }, NOW)
|
||||||
|
expect(r).toEqual({ finished_at: NOW })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults now to a fresh Date when omitted', () => {
|
||||||
|
const before = Date.now()
|
||||||
|
const r = resolveJobTimestamps('running', { claimed_at: EARLIER, started_at: null })
|
||||||
|
const after = Date.now()
|
||||||
|
expect(r.started_at).toBeInstanceOf(Date)
|
||||||
|
expect(r.started_at!.getTime()).toBeGreaterThanOrEqual(before)
|
||||||
|
expect(r.started_at!.getTime()).toBeLessThanOrEqual(after)
|
||||||
|
})
|
||||||
|
})
|
||||||
174
__tests__/update-sprint.test.ts
Normal file
174
__tests__/update-sprint.test.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('../src/prisma.js', () => ({
|
||||||
|
prisma: {
|
||||||
|
sprint: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../src/auth.js', () => ({
|
||||||
|
requireWriteAccess: vi.fn(),
|
||||||
|
PermissionDeniedError: class PermissionDeniedError extends Error {
|
||||||
|
constructor(message = 'Demo accounts cannot perform write operations') {
|
||||||
|
super(message)
|
||||||
|
this.name = 'PermissionDeniedError'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../src/access.js', () => ({
|
||||||
|
userCanAccessProduct: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { prisma } from '../src/prisma.js'
|
||||||
|
import { requireWriteAccess } from '../src/auth.js'
|
||||||
|
import { userCanAccessProduct } from '../src/access.js'
|
||||||
|
import { handleUpdateSprint } from '../src/tools/update-sprint.js'
|
||||||
|
|
||||||
|
const mockPrisma = prisma as unknown as {
|
||||||
|
sprint: {
|
||||||
|
findUnique: ReturnType<typeof vi.fn>
|
||||||
|
update: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const mockRequireWriteAccess = requireWriteAccess as ReturnType<typeof vi.fn>
|
||||||
|
const mockUserCanAccessProduct = userCanAccessProduct as ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
const SPRINT_ID = 'spr-1'
|
||||||
|
const PRODUCT_ID = 'prod-1'
|
||||||
|
const USER_ID = 'user-1'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockRequireWriteAccess.mockResolvedValue({ userId: USER_ID, tokenId: 'tok-1', username: 'alice', isDemo: false })
|
||||||
|
mockUserCanAccessProduct.mockResolvedValue(true)
|
||||||
|
mockPrisma.sprint.findUnique.mockResolvedValue({ id: SPRINT_ID, product_id: PRODUCT_ID })
|
||||||
|
mockPrisma.sprint.update.mockResolvedValue({
|
||||||
|
id: SPRINT_ID,
|
||||||
|
code: 'S-2026-05-11-1',
|
||||||
|
sprint_goal: 'g',
|
||||||
|
status: 'OPEN',
|
||||||
|
start_date: new Date('2026-05-11'),
|
||||||
|
end_date: null,
|
||||||
|
completed_at: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function getText(result: Awaited<ReturnType<typeof handleUpdateSprint>>) {
|
||||||
|
return result.content?.[0]?.type === 'text' ? result.content[0].text : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('handleUpdateSprint', () => {
|
||||||
|
it('returns error when no fields provided', async () => {
|
||||||
|
const result = await handleUpdateSprint({ sprint_id: SPRINT_ID })
|
||||||
|
|
||||||
|
expect(mockPrisma.sprint.update).not.toHaveBeenCalled()
|
||||||
|
expect(getText(result)).toMatch(/Minstens één veld vereist/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates status only', async () => {
|
||||||
|
await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'OPEN' })
|
||||||
|
|
||||||
|
expect(mockPrisma.sprint.update).toHaveBeenCalledTimes(1)
|
||||||
|
const args = mockPrisma.sprint.update.mock.calls[0][0]
|
||||||
|
expect(args.where).toEqual({ id: SPRINT_ID })
|
||||||
|
expect(args.data).toEqual({ status: 'OPEN' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('auto-sets end_date AND completed_at when status → CLOSED without explicit end_date', async () => {
|
||||||
|
const before = Date.now()
|
||||||
|
await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'CLOSED' })
|
||||||
|
const after = Date.now()
|
||||||
|
|
||||||
|
const args = mockPrisma.sprint.update.mock.calls[0][0]
|
||||||
|
expect(args.data.status).toBe('CLOSED')
|
||||||
|
expect(args.data.end_date).toBeInstanceOf(Date)
|
||||||
|
expect(args.data.end_date.getTime()).toBeGreaterThanOrEqual(before)
|
||||||
|
expect(args.data.end_date.getTime()).toBeLessThanOrEqual(after)
|
||||||
|
expect(args.data.completed_at).toBeInstanceOf(Date)
|
||||||
|
expect(args.data.completed_at.getTime()).toBeGreaterThanOrEqual(before)
|
||||||
|
expect(args.data.completed_at.getTime()).toBeLessThanOrEqual(after)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('auto-sets end_date when status → FAILED, but does NOT set completed_at', async () => {
|
||||||
|
await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'FAILED' })
|
||||||
|
|
||||||
|
const args = mockPrisma.sprint.update.mock.calls[0][0]
|
||||||
|
expect(args.data.end_date).toBeInstanceOf(Date)
|
||||||
|
expect(args.data.completed_at).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('auto-sets end_date when status → ARCHIVED, but does NOT set completed_at', async () => {
|
||||||
|
await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'ARCHIVED' })
|
||||||
|
|
||||||
|
const args = mockPrisma.sprint.update.mock.calls[0][0]
|
||||||
|
expect(args.data.end_date).toBeInstanceOf(Date)
|
||||||
|
expect(args.data.completed_at).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still sets completed_at when status → CLOSED even with explicit end_date', async () => {
|
||||||
|
await handleUpdateSprint({
|
||||||
|
sprint_id: SPRINT_ID,
|
||||||
|
status: 'CLOSED',
|
||||||
|
end_date: '2025-12-31',
|
||||||
|
})
|
||||||
|
|
||||||
|
const args = mockPrisma.sprint.update.mock.calls[0][0]
|
||||||
|
expect(args.data.end_date.toISOString().slice(0, 10)).toBe('2025-12-31')
|
||||||
|
expect(args.data.completed_at).toBeInstanceOf(Date)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does NOT auto-set end_date or completed_at when status → OPEN', async () => {
|
||||||
|
await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'OPEN' })
|
||||||
|
|
||||||
|
const args = mockPrisma.sprint.update.mock.calls[0][0]
|
||||||
|
expect(args.data.end_date).toBeUndefined()
|
||||||
|
expect(args.data.completed_at).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates multiple fields at once', async () => {
|
||||||
|
await handleUpdateSprint({
|
||||||
|
sprint_id: SPRINT_ID,
|
||||||
|
sprint_goal: 'New goal',
|
||||||
|
start_date: '2026-05-15',
|
||||||
|
})
|
||||||
|
|
||||||
|
const args = mockPrisma.sprint.update.mock.calls[0][0]
|
||||||
|
expect(args.data.sprint_goal).toBe('New goal')
|
||||||
|
expect(args.data.start_date.toISOString().slice(0, 10)).toBe('2026-05-15')
|
||||||
|
expect(args.data.status).toBeUndefined()
|
||||||
|
expect(args.data.end_date).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns error when sprint not found', async () => {
|
||||||
|
mockPrisma.sprint.findUnique.mockResolvedValue(null)
|
||||||
|
|
||||||
|
const result = await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'CLOSED' })
|
||||||
|
|
||||||
|
expect(mockPrisma.sprint.update).not.toHaveBeenCalled()
|
||||||
|
expect(getText(result)).toMatch(/not found/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns error when user cannot access sprint product', async () => {
|
||||||
|
mockUserCanAccessProduct.mockResolvedValue(false)
|
||||||
|
|
||||||
|
const result = await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'CLOSED' })
|
||||||
|
|
||||||
|
expect(mockPrisma.sprint.update).not.toHaveBeenCalled()
|
||||||
|
expect(getText(result)).toMatch(/not accessible/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows any status transition (no state-machine)', async () => {
|
||||||
|
await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'OPEN' })
|
||||||
|
expect(mockPrisma.sprint.update).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'CLOSED' })
|
||||||
|
expect(mockPrisma.sprint.update).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
|
await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'OPEN' })
|
||||||
|
expect(mockPrisma.sprint.update).toHaveBeenCalledTimes(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -163,3 +163,53 @@ describe('classifyDiffAgainstPlan — delete-only commits', () => {
|
||||||
expect(r.result).toBe('EMPTY')
|
expect(r.result).toBe('EMPTY')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Pseudo-paths in plans (code-snippets, attribute-syntax, ellipses) moeten
|
||||||
|
// niet als plan-paden meetellen — anders krijg je PARTIAL terwijl het werk
|
||||||
|
// volledig gedaan is. Regression-guard voor T-815-incident (sprint
|
||||||
|
// cmoyiu4yd000zf917acq9twtr, 2026-05-09).
|
||||||
|
describe('classifyDiffAgainstPlan — plan met pseudo-paths', () => {
|
||||||
|
it('negeert `data-debug-label="..."` als pseudo-pad en classificeert ALIGNED', () => {
|
||||||
|
const plan = [
|
||||||
|
'Verwijder alle voorkomens van `data-debug-label="..."` uit:',
|
||||||
|
'',
|
||||||
|
'- `app/components/shared/status-bar.tsx`',
|
||||||
|
'- `app/components/shared/header.tsx`',
|
||||||
|
].join('\n')
|
||||||
|
const diff = makeDiff([
|
||||||
|
'app/components/shared/status-bar.tsx',
|
||||||
|
'app/components/shared/header.tsx',
|
||||||
|
])
|
||||||
|
const r = classifyDiffAgainstPlan({ diff, plan })
|
||||||
|
expect(r.result).toBe('ALIGNED')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('negeert ellipsis-tokens (drie of meer dots) als pad', () => {
|
||||||
|
const plan = 'Refactor `foo(...)` naar `bar()`. Files: `src/a.ts`.'
|
||||||
|
const diff = makeDiff(['src/a.ts'])
|
||||||
|
const r = classifyDiffAgainstPlan({ diff, plan })
|
||||||
|
expect(r.result).toBe('ALIGNED')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('negeert tokens met operators/quotes als pad', () => {
|
||||||
|
const plan = 'Wijzig `props={x: 1}` en `useState<string>()` in `src/c.tsx`.'
|
||||||
|
const diff = makeDiff(['src/c.tsx'])
|
||||||
|
const r = classifyDiffAgainstPlan({ diff, plan })
|
||||||
|
expect(r.result).toBe('ALIGNED')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepteert package.json en andere extension-only paths', () => {
|
||||||
|
const plan = 'Update `package.json` en `tsconfig.json`.'
|
||||||
|
const diff = makeDiff(['package.json', 'tsconfig.json'])
|
||||||
|
const r = classifyDiffAgainstPlan({ diff, plan })
|
||||||
|
expect(r.result).toBe('ALIGNED')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blijft PARTIAL retourneren wanneer een echt plan-pad ontbreekt', () => {
|
||||||
|
const plan = 'Wijzig `src/foo.ts` en `src/bar.ts`. Verwijder `data-x="..."`.'
|
||||||
|
const diff = makeDiff(['src/foo.ts'])
|
||||||
|
const r = classifyDiffAgainstPlan({ diff, plan })
|
||||||
|
expect(r.result).toBe('PARTIAL')
|
||||||
|
expect(r.reasoning).toMatch(/bar\.ts/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import * as fs from 'node:fs/promises'
|
||||||
vi.mock('../src/prisma.js', () => ({
|
vi.mock('../src/prisma.js', () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
$executeRaw: vi.fn(),
|
$executeRaw: vi.fn(),
|
||||||
claudeJob: { findFirst: vi.fn(), findUnique: vi.fn() },
|
claudeJob: { findFirst: vi.fn(), findUnique: vi.fn(), update: vi.fn() },
|
||||||
product: { findUnique: vi.fn() },
|
product: { findUnique: vi.fn() },
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
@ -21,7 +21,7 @@ import { resolveRepoRoot, rollbackClaim, attachWorktreeToJob } from '../src/tool
|
||||||
|
|
||||||
const mockPrisma = prisma as unknown as {
|
const mockPrisma = prisma as unknown as {
|
||||||
$executeRaw: ReturnType<typeof vi.fn>
|
$executeRaw: ReturnType<typeof vi.fn>
|
||||||
claudeJob: { findFirst: ReturnType<typeof vi.fn>; findUnique: ReturnType<typeof vi.fn> }
|
claudeJob: { findFirst: ReturnType<typeof vi.fn>; findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
|
||||||
product: { findUnique: ReturnType<typeof vi.fn> }
|
product: { findUnique: ReturnType<typeof vi.fn> }
|
||||||
}
|
}
|
||||||
const mockCreateWorktree = createWorktreeForJob as ReturnType<typeof vi.fn>
|
const mockCreateWorktree = createWorktreeForJob as ReturnType<typeof vi.fn>
|
||||||
|
|
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "scrum4me-mcp",
|
"name": "scrum4me-mcp",
|
||||||
"version": "0.7.0",
|
"version": "0.8.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "scrum4me-mcp",
|
"name": "scrum4me-mcp",
|
||||||
"version": "0.7.0",
|
"version": "0.8.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "scrum4me-mcp",
|
"name": "scrum4me-mcp",
|
||||||
"version": "0.7.0",
|
"version": "0.8.0",
|
||||||
"description": "MCP server for Scrum4Me — exposes dev-flow tools and prompts via the Model Context Protocol",
|
"description": "MCP server for Scrum4Me — exposes dev-flow tools and prompts via the Model Context Protocol",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ enum TaskStatus {
|
||||||
REVIEW
|
REVIEW
|
||||||
DONE
|
DONE
|
||||||
FAILED
|
FAILED
|
||||||
|
EXCLUDED
|
||||||
}
|
}
|
||||||
|
|
||||||
enum LogType {
|
enum LogType {
|
||||||
|
|
@ -70,8 +71,9 @@ enum TestStatus {
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SprintStatus {
|
enum SprintStatus {
|
||||||
ACTIVE
|
OPEN
|
||||||
COMPLETED
|
CLOSED
|
||||||
|
ARCHIVED
|
||||||
FAILED
|
FAILED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,6 +100,9 @@ enum IdeaStatus {
|
||||||
PLANNING
|
PLANNING
|
||||||
PLAN_FAILED
|
PLAN_FAILED
|
||||||
PLAN_READY
|
PLAN_READY
|
||||||
|
REVIEWING_PLAN
|
||||||
|
PLAN_REVIEW_FAILED
|
||||||
|
PLAN_REVIEWED
|
||||||
PLANNED
|
PLANNED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -105,6 +110,7 @@ enum ClaudeJobKind {
|
||||||
TASK_IMPLEMENTATION
|
TASK_IMPLEMENTATION
|
||||||
IDEA_GRILL
|
IDEA_GRILL
|
||||||
IDEA_MAKE_PLAN
|
IDEA_MAKE_PLAN
|
||||||
|
IDEA_REVIEW_PLAN
|
||||||
PLAN_CHAT
|
PLAN_CHAT
|
||||||
SPRINT_IMPLEMENTATION
|
SPRINT_IMPLEMENTATION
|
||||||
}
|
}
|
||||||
|
|
@ -122,6 +128,7 @@ enum IdeaLogType {
|
||||||
NOTE
|
NOTE
|
||||||
GRILL_RESULT
|
GRILL_RESULT
|
||||||
PLAN_RESULT
|
PLAN_RESULT
|
||||||
|
PLAN_REVIEW_RESULT
|
||||||
STATUS_CHANGE
|
STATUS_CHANGE
|
||||||
JOB_EVENT
|
JOB_EVENT
|
||||||
}
|
}
|
||||||
|
|
@ -145,6 +152,7 @@ model User {
|
||||||
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)
|
||||||
idea_code_counter Int @default(0)
|
idea_code_counter Int @default(0)
|
||||||
min_quota_pct Int @default(20)
|
min_quota_pct Int @default(20)
|
||||||
|
settings Json @default("{}")
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
updated_at DateTime @updatedAt
|
updated_at DateTime @updatedAt
|
||||||
roles UserRole[]
|
roles UserRole[]
|
||||||
|
|
@ -159,6 +167,7 @@ model User {
|
||||||
claude_jobs ClaudeJob[]
|
claude_jobs ClaudeJob[]
|
||||||
claude_workers ClaudeWorker[]
|
claude_workers ClaudeWorker[]
|
||||||
started_sprint_runs SprintRun[] @relation("SprintRunStartedBy")
|
started_sprint_runs SprintRun[] @relation("SprintRunStartedBy")
|
||||||
|
push_subscriptions PushSubscription[]
|
||||||
|
|
||||||
@@index([active_product_id])
|
@@index([active_product_id])
|
||||||
@@map("users")
|
@@map("users")
|
||||||
|
|
@ -297,8 +306,9 @@ model Sprint {
|
||||||
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)
|
||||||
sprint_goal String
|
sprint_goal String
|
||||||
status SprintStatus @default(ACTIVE)
|
status SprintStatus @default(OPEN)
|
||||||
start_date DateTime? @db.Date
|
start_date DateTime? @db.Date
|
||||||
end_date DateTime? @db.Date
|
end_date DateTime? @db.Date
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
|
|
@ -307,6 +317,7 @@ model Sprint {
|
||||||
tasks Task[]
|
tasks Task[]
|
||||||
sprint_runs SprintRun[]
|
sprint_runs SprintRun[]
|
||||||
|
|
||||||
|
@@unique([product_id, code])
|
||||||
@@index([product_id, status])
|
@@index([product_id, status])
|
||||||
@@map("sprints")
|
@@map("sprints")
|
||||||
}
|
}
|
||||||
|
|
@ -505,22 +516,24 @@ model ProductMember {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Idea {
|
model Idea {
|
||||||
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?
|
||||||
code String @db.VarChar(30)
|
code String @db.VarChar(30)
|
||||||
title String
|
title String
|
||||||
description String? @db.VarChar(4000)
|
description String? @db.VarChar(4000)
|
||||||
grill_md String? @db.Text
|
grill_md String? @db.Text
|
||||||
plan_md String? @db.Text
|
plan_md String? @db.Text
|
||||||
pbi Pbi? @relation(fields: [pbi_id], references: [id], onDelete: SetNull)
|
plan_review_log Json? // ReviewLog from orchestrator (all rounds, convergence metrics, approval status)
|
||||||
pbi_id String? @unique
|
reviewed_at DateTime? // When last reviewed
|
||||||
status IdeaStatus @default(DRAFT)
|
pbi Pbi? @relation(fields: [pbi_id], references: [id], onDelete: SetNull)
|
||||||
archived Boolean @default(false)
|
pbi_id String? @unique
|
||||||
created_at DateTime @default(now())
|
status IdeaStatus @default(DRAFT)
|
||||||
updated_at DateTime @updatedAt
|
archived Boolean @default(false)
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
questions ClaudeQuestion[]
|
questions ClaudeQuestion[]
|
||||||
jobs ClaudeJob[]
|
jobs ClaudeJob[]
|
||||||
|
|
@ -625,3 +638,18 @@ model ClaudeQuestion {
|
||||||
@@index([status, expires_at])
|
@@index([status, expires_at])
|
||||||
@@map("claude_questions")
|
@@map("claude_questions")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model PushSubscription {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||||
|
user_id String
|
||||||
|
endpoint String @unique
|
||||||
|
p256dh String
|
||||||
|
auth String
|
||||||
|
user_agent String?
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
last_used_at DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([user_id])
|
||||||
|
@@map("push_subscriptions")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,19 @@ async function branchExists(repoRoot: string, name: string): Promise<boolean> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function remoteBranchExists(repoRoot: string, name: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await exec(
|
||||||
|
'git',
|
||||||
|
['show-ref', '--verify', '--quiet', `refs/remotes/origin/${name}`],
|
||||||
|
{ cwd: repoRoot },
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function findWorktreeForBranch(
|
async function findWorktreeForBranch(
|
||||||
repoRoot: string,
|
repoRoot: string,
|
||||||
branchName: string,
|
branchName: string,
|
||||||
|
|
@ -75,7 +88,27 @@ export async function createWorktreeForJob(opts: {
|
||||||
if (occupant) {
|
if (occupant) {
|
||||||
await exec('git', ['worktree', 'remove', '--force', occupant], { cwd: repoRoot })
|
await exec('git', ['worktree', 'remove', '--force', occupant], { cwd: repoRoot })
|
||||||
}
|
}
|
||||||
await exec('git', ['worktree', 'add', worktreePath, branchName], { cwd: repoRoot })
|
// reuseBranch is decided sprint-wide, but git branches are per-repo. For a
|
||||||
|
// cross-repo sprint the first job targeting THIS repo gets reuseBranch=true
|
||||||
|
// even though the branch was never created here; a container recreate also
|
||||||
|
// wipes the local clone. Fall back gracefully instead of failing with
|
||||||
|
// "invalid reference":
|
||||||
|
// - local branch exists → reuse it
|
||||||
|
// - exists on origin only → recreate the local branch tracking origin
|
||||||
|
// - nowhere → create it fresh from baseRef
|
||||||
|
if (await branchExists(repoRoot, branchName)) {
|
||||||
|
await exec('git', ['worktree', 'add', worktreePath, branchName], { cwd: repoRoot })
|
||||||
|
} else if (await remoteBranchExists(repoRoot, branchName)) {
|
||||||
|
await exec(
|
||||||
|
'git',
|
||||||
|
['worktree', 'add', '-b', branchName, worktreePath, `origin/${branchName}`],
|
||||||
|
{ cwd: repoRoot },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
await exec('git', ['worktree', 'add', '-b', branchName, worktreePath, baseRef], {
|
||||||
|
cwd: repoRoot,
|
||||||
|
})
|
||||||
|
}
|
||||||
return { worktreePath, branchName }
|
return { worktreePath, branchName }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ import { registerLogCommitTool } from './tools/log-commit.js'
|
||||||
import { registerCreatePbiTool } from './tools/create-pbi.js'
|
import { registerCreatePbiTool } from './tools/create-pbi.js'
|
||||||
import { registerCreateStoryTool } from './tools/create-story.js'
|
import { registerCreateStoryTool } from './tools/create-story.js'
|
||||||
import { registerCreateTaskTool } from './tools/create-task.js'
|
import { registerCreateTaskTool } from './tools/create-task.js'
|
||||||
|
import { registerCreateSprintTool } from './tools/create-sprint.js'
|
||||||
|
import { registerUpdateSprintTool } from './tools/update-sprint.js'
|
||||||
import { registerAskUserQuestionTool } from './tools/ask-user-question.js'
|
import { registerAskUserQuestionTool } from './tools/ask-user-question.js'
|
||||||
import { registerGetQuestionAnswerTool } from './tools/get-question-answer.js'
|
import { registerGetQuestionAnswerTool } from './tools/get-question-answer.js'
|
||||||
import { registerListOpenQuestionsTool } from './tools/list-open-questions.js'
|
import { registerListOpenQuestionsTool } from './tools/list-open-questions.js'
|
||||||
|
|
@ -26,6 +28,7 @@ import { registerMarkPbiPrMergedTool } from './tools/mark-pbi-pr-merged.js'
|
||||||
import { registerGetIdeaContextTool } from './tools/get-idea-context.js'
|
import { registerGetIdeaContextTool } from './tools/get-idea-context.js'
|
||||||
import { registerUpdateIdeaGrillMdTool } from './tools/update-idea-grill-md.js'
|
import { registerUpdateIdeaGrillMdTool } from './tools/update-idea-grill-md.js'
|
||||||
import { registerUpdateIdeaPlanMdTool } from './tools/update-idea-plan-md.js'
|
import { registerUpdateIdeaPlanMdTool } from './tools/update-idea-plan-md.js'
|
||||||
|
import { registerUpdateIdeaPlanReviewedTool } from './tools/update-idea-plan-reviewed.js'
|
||||||
import { registerLogIdeaDecisionTool } from './tools/log-idea-decision.js'
|
import { registerLogIdeaDecisionTool } from './tools/log-idea-decision.js'
|
||||||
import { registerGetWorkerSettingsTool } from './tools/get-worker-settings.js'
|
import { registerGetWorkerSettingsTool } from './tools/get-worker-settings.js'
|
||||||
import { registerWorkerHeartbeatTool } from './tools/worker-heartbeat.js'
|
import { registerWorkerHeartbeatTool } from './tools/worker-heartbeat.js'
|
||||||
|
|
@ -77,6 +80,9 @@ async function main() {
|
||||||
registerCreatePbiTool(server)
|
registerCreatePbiTool(server)
|
||||||
registerCreateStoryTool(server)
|
registerCreateStoryTool(server)
|
||||||
registerCreateTaskTool(server)
|
registerCreateTaskTool(server)
|
||||||
|
// PBI-12: sprint lifecycle tools
|
||||||
|
registerCreateSprintTool(server)
|
||||||
|
registerUpdateSprintTool(server)
|
||||||
registerAskUserQuestionTool(server)
|
registerAskUserQuestionTool(server)
|
||||||
registerGetQuestionAnswerTool(server)
|
registerGetQuestionAnswerTool(server)
|
||||||
registerListOpenQuestionsTool(server)
|
registerListOpenQuestionsTool(server)
|
||||||
|
|
@ -92,6 +98,7 @@ async function main() {
|
||||||
registerGetIdeaContextTool(server)
|
registerGetIdeaContextTool(server)
|
||||||
registerUpdateIdeaGrillMdTool(server)
|
registerUpdateIdeaGrillMdTool(server)
|
||||||
registerUpdateIdeaPlanMdTool(server)
|
registerUpdateIdeaPlanMdTool(server)
|
||||||
|
registerUpdateIdeaPlanReviewedTool(server)
|
||||||
registerLogIdeaDecisionTool(server)
|
registerLogIdeaDecisionTool(server)
|
||||||
// M13: worker quota-gate tools
|
// M13: worker quota-gate tools
|
||||||
registerGetWorkerSettingsTool(server)
|
registerGetWorkerSettingsTool(server)
|
||||||
|
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
// 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 ''
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +1,21 @@
|
||||||
// PBI-67: model + mode-selectie per ClaudeJob-kind.
|
// PBI-67: model + mode-selectie per ClaudeJob-kind.
|
||||||
//
|
//
|
||||||
|
// Sync met Scrum4Me/lib/job-config.ts — als je hier een veld aanpast,
|
||||||
|
// doe hetzelfde aan de webapp-kant. Bewust duplicate (geen gedeeld
|
||||||
|
// package) om de MCP-server eigenstandig te houden.
|
||||||
|
//
|
||||||
// Override-cascade (eerste match wint):
|
// Override-cascade (eerste match wint):
|
||||||
// 1. task.requires_opus === true → forceer Opus
|
// 1. task.requires_opus === true → forceer Opus
|
||||||
// 2. job.requested_* (snapshot bij enqueue)
|
// 2. job.requested_* (snapshot bij enqueue)
|
||||||
// 3. product.preferred_*
|
// 3. product.preferred_*
|
||||||
// 4. KIND_DEFAULTS hieronder
|
// 4. KIND_DEFAULTS hieronder
|
||||||
|
//
|
||||||
|
// CLI-flag-mapping (Claude CLI 2.1.x):
|
||||||
|
// - thinking_budget (number) → mapBudgetToEffort() → --effort {low,medium,high,xhigh,max}
|
||||||
|
// (de CLI heeft geen --thinking-budget flag — alleen --effort)
|
||||||
|
// - max_turns blijft audit-only: de CLI heeft géén --max-turns flag.
|
||||||
|
// De waarde wordt gesnapshot voor cost-attribution maar niet doorgegeven.
|
||||||
|
// - allowed_tools → --allowedTools (komma-gescheiden lijst)
|
||||||
|
|
||||||
export type ClaudeModel =
|
export type ClaudeModel =
|
||||||
| 'claude-opus-4-7'
|
| 'claude-opus-4-7'
|
||||||
|
|
@ -38,41 +49,100 @@ export type TaskInput = {
|
||||||
requires_opus?: boolean | null
|
requires_opus?: boolean | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tool-allowlists per kind. Bewust géén `wait_for_job`, `check_queue_empty`
|
||||||
|
// of `get_idea_context` — de runner (scrum4me-docker/bin/run-one-job.ts)
|
||||||
|
// claimt voor Claude. Vangrail tegen recursieve claims binnen één invocation.
|
||||||
|
const TASK_TOOLS = [
|
||||||
|
'Read', 'Edit', 'Write', 'Bash', 'Grep', 'Glob',
|
||||||
|
'mcp__scrum4me__get_claude_context',
|
||||||
|
'mcp__scrum4me__update_task_status',
|
||||||
|
'mcp__scrum4me__update_task_plan',
|
||||||
|
'mcp__scrum4me__log_implementation',
|
||||||
|
'mcp__scrum4me__log_test_result',
|
||||||
|
'mcp__scrum4me__log_commit',
|
||||||
|
'mcp__scrum4me__verify_task_against_plan',
|
||||||
|
'mcp__scrum4me__update_job_status',
|
||||||
|
'mcp__scrum4me__ask_user_question',
|
||||||
|
'mcp__scrum4me__get_question_answer',
|
||||||
|
'mcp__scrum4me__list_open_questions',
|
||||||
|
'mcp__scrum4me__cancel_question',
|
||||||
|
'mcp__scrum4me__worker_heartbeat',
|
||||||
|
]
|
||||||
|
|
||||||
const KIND_DEFAULTS: Record<string, JobConfig> = {
|
const KIND_DEFAULTS: Record<string, JobConfig> = {
|
||||||
|
// Idea-kinds en PLAN_CHAT draaien in `acceptEdits` (niet `plan`):
|
||||||
|
// `plan`-mode wacht op human-approval na elke planning-fase, wat in een
|
||||||
|
// autonome runner-context betekent dat Claude geen `update_job_status`
|
||||||
|
// aanroept en de job na lease-expiry FAILED'd. De `allowed_tools`-lijst
|
||||||
|
// doet de echte sandboxing (geen Bash, geen Edit, alleen Read/Grep/etc).
|
||||||
IDEA_GRILL: {
|
IDEA_GRILL: {
|
||||||
model: 'claude-sonnet-4-6',
|
model: 'claude-sonnet-4-6',
|
||||||
thinking_budget: 12000,
|
thinking_budget: 12000,
|
||||||
permission_mode: 'plan',
|
permission_mode: 'acceptEdits',
|
||||||
max_turns: 15,
|
max_turns: 15,
|
||||||
allowed_tools: ['Read', 'Grep', 'Glob', 'WebSearch', 'AskUserQuestion'],
|
allowed_tools: [
|
||||||
|
'Read', 'Grep', 'Glob', 'WebSearch', 'AskUserQuestion',
|
||||||
|
'mcp__scrum4me__update_idea_grill_md',
|
||||||
|
'mcp__scrum4me__log_idea_decision',
|
||||||
|
'mcp__scrum4me__update_job_status',
|
||||||
|
'mcp__scrum4me__ask_user_question',
|
||||||
|
'mcp__scrum4me__get_question_answer',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
IDEA_MAKE_PLAN: {
|
IDEA_MAKE_PLAN: {
|
||||||
model: 'claude-opus-4-7',
|
model: 'claude-opus-4-7',
|
||||||
thinking_budget: 24000,
|
thinking_budget: 24000,
|
||||||
permission_mode: 'plan',
|
permission_mode: 'acceptEdits',
|
||||||
max_turns: 20,
|
max_turns: 20,
|
||||||
allowed_tools: ['Read', 'Grep', 'Glob', 'WebSearch', 'AskUserQuestion', 'Write'],
|
allowed_tools: [
|
||||||
|
'Read', 'Grep', 'Glob', 'WebSearch', 'AskUserQuestion', 'Write',
|
||||||
|
'mcp__scrum4me__update_idea_plan_md',
|
||||||
|
'mcp__scrum4me__log_idea_decision',
|
||||||
|
'mcp__scrum4me__update_job_status',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
IDEA_REVIEW_PLAN: {
|
||||||
|
model: 'claude-opus-4-7',
|
||||||
|
thinking_budget: 6000,
|
||||||
|
permission_mode: 'acceptEdits',
|
||||||
|
max_turns: 1,
|
||||||
|
allowed_tools: [
|
||||||
|
'Read', 'Write', 'Grep', 'Glob',
|
||||||
|
'mcp__scrum4me__update_idea_plan_reviewed',
|
||||||
|
'mcp__scrum4me__log_idea_decision',
|
||||||
|
'mcp__scrum4me__update_job_status',
|
||||||
|
'mcp__scrum4me__ask_user_question',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
PLAN_CHAT: {
|
PLAN_CHAT: {
|
||||||
model: 'claude-sonnet-4-6',
|
model: 'claude-sonnet-4-6',
|
||||||
thinking_budget: 6000,
|
thinking_budget: 6000,
|
||||||
permission_mode: 'plan',
|
permission_mode: 'acceptEdits',
|
||||||
max_turns: 5,
|
max_turns: 5,
|
||||||
allowed_tools: ['Read', 'Grep', 'AskUserQuestion'],
|
allowed_tools: [
|
||||||
|
'Read', 'Grep', 'AskUserQuestion',
|
||||||
|
'mcp__scrum4me__update_job_status',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
TASK_IMPLEMENTATION: {
|
TASK_IMPLEMENTATION: {
|
||||||
model: 'claude-sonnet-4-6',
|
model: 'claude-sonnet-4-6',
|
||||||
thinking_budget: 6000,
|
thinking_budget: 6000,
|
||||||
permission_mode: 'bypassPermissions',
|
permission_mode: 'bypassPermissions',
|
||||||
max_turns: 50,
|
max_turns: 50,
|
||||||
allowed_tools: null,
|
allowed_tools: TASK_TOOLS,
|
||||||
},
|
},
|
||||||
SPRINT_IMPLEMENTATION: {
|
SPRINT_IMPLEMENTATION: {
|
||||||
model: 'claude-sonnet-4-6',
|
model: 'claude-sonnet-4-6',
|
||||||
thinking_budget: 6000,
|
thinking_budget: 6000,
|
||||||
permission_mode: 'bypassPermissions',
|
permission_mode: 'bypassPermissions',
|
||||||
max_turns: null,
|
max_turns: null,
|
||||||
allowed_tools: null,
|
// Geen `mcp__scrum4me__job_heartbeat` — de runner verlengt de lease
|
||||||
|
// automatisch via setInterval (zie scrum4me-docker/bin/run-one-job.ts).
|
||||||
|
allowed_tools: [
|
||||||
|
...TASK_TOOLS,
|
||||||
|
'mcp__scrum4me__update_task_execution',
|
||||||
|
'mcp__scrum4me__verify_sprint_task',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,3 +188,20 @@ export function resolveJobConfig(
|
||||||
allowed_tools: base.allowed_tools,
|
allowed_tools: base.allowed_tools,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map numeriek thinking_budget naar de Claude CLI 2.1.x --effort flag.
|
||||||
|
// Returns null als de flag niet meegegeven moet worden (budget = 0).
|
||||||
|
//
|
||||||
|
// Mapping (sync met Scrum4Me/lib/job-config.ts):
|
||||||
|
// 0 → null (geen --effort flag)
|
||||||
|
// 1..6000 → "medium"
|
||||||
|
// 6001..12000 → "high"
|
||||||
|
// 12001..24000→ "xhigh"
|
||||||
|
// >24000 → "max"
|
||||||
|
export function mapBudgetToEffort(budget: number): string | null {
|
||||||
|
if (budget <= 0) return null
|
||||||
|
if (budget <= 6000) return 'medium'
|
||||||
|
if (budget <= 12000) return 'high'
|
||||||
|
if (budget <= 24000) return 'xhigh'
|
||||||
|
return 'max'
|
||||||
|
}
|
||||||
|
|
|
||||||
49
src/lib/kind-prompts.ts
Normal file
49
src/lib/kind-prompts.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
// Loader voor embedded prompts per ClaudeJob-kind.
|
||||||
|
//
|
||||||
|
// De .md-bestanden in src/prompts/<kind>/ worden bewust meegebakken zodat
|
||||||
|
// elke runner ze kan inlezen zonder externe plugin-dependency. De runner
|
||||||
|
// (scrum4me-docker/bin/run-one-job.ts) leest de juiste prompt via
|
||||||
|
// getKindPromptText() en geeft die door als `claude -p`-prompt.
|
||||||
|
//
|
||||||
|
// Variabele-vervanging gebeurt door de runner zelf (bv. $PAYLOAD_PATH).
|
||||||
|
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
import type { ClaudeJobKind } from '@prisma/client'
|
||||||
|
|
||||||
|
const cache: Partial<Record<ClaudeJobKind, string>> = {}
|
||||||
|
|
||||||
|
function loadPrompt(rel: string): string {
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url))
|
||||||
|
// src/lib/kind-prompts.ts → src/lib → src → src/prompts/<rel>
|
||||||
|
const path = join(here, '..', 'prompts', rel)
|
||||||
|
return readFileSync(path, 'utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND_TO_PROMPT_PATH: Partial<Record<ClaudeJobKind, string>> = {
|
||||||
|
IDEA_GRILL: 'idea/grill.md',
|
||||||
|
IDEA_MAKE_PLAN: 'idea/make-plan.md',
|
||||||
|
IDEA_REVIEW_PLAN: 'idea/review-plan.md',
|
||||||
|
TASK_IMPLEMENTATION: 'task/implementation.md',
|
||||||
|
SPRINT_IMPLEMENTATION: 'sprint/implementation.md',
|
||||||
|
PLAN_CHAT: 'plan-chat/chat.md',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getKindPromptText(kind: ClaudeJobKind): string {
|
||||||
|
if (cache[kind]) return cache[kind]!
|
||||||
|
const rel = KIND_TO_PROMPT_PATH[kind]
|
||||||
|
if (!rel) return ''
|
||||||
|
const text = loadPrompt(rel)
|
||||||
|
cache[kind] = text
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back-compat re-export. wait-for-job.ts roept getIdeaPromptText aan voor
|
||||||
|
// de drie idea-kinds; behouden zodat we de bestaande call-site niet hoeven
|
||||||
|
// te wijzigen tot een aparte cleanup-pass.
|
||||||
|
export function getIdeaPromptText(kind: ClaudeJobKind): string {
|
||||||
|
if (kind !== 'IDEA_GRILL' && kind !== 'IDEA_MAKE_PLAN' && kind !== 'IDEA_REVIEW_PLAN') return ''
|
||||||
|
return getKindPromptText(kind)
|
||||||
|
}
|
||||||
|
|
@ -140,15 +140,15 @@ export async function propagateStatusUpwards(
|
||||||
|
|
||||||
let nextStatus: SprintStatus
|
let nextStatus: SprintStatus
|
||||||
if (anyPbiFailed) nextStatus = 'FAILED'
|
if (anyPbiFailed) nextStatus = 'FAILED'
|
||||||
else if (allPbisDone) nextStatus = 'COMPLETED'
|
else if (allPbisDone) nextStatus = 'CLOSED'
|
||||||
else nextStatus = 'ACTIVE'
|
else nextStatus = 'OPEN'
|
||||||
|
|
||||||
if (nextStatus !== sprint.status) {
|
if (nextStatus !== sprint.status) {
|
||||||
await tx.sprint.update({
|
await tx.sprint.update({
|
||||||
where: { id: sprint.id },
|
where: { id: sprint.id },
|
||||||
data: {
|
data: {
|
||||||
status: nextStatus,
|
status: nextStatus,
|
||||||
...(nextStatus === 'COMPLETED' ? { completed_at: new Date() } : {}),
|
...(nextStatus === 'CLOSED' ? { completed_at: new Date() } : {}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
sprintChanged = true
|
sprintChanged = true
|
||||||
|
|
@ -162,7 +162,7 @@ export async function propagateStatusUpwards(
|
||||||
// 3. Story → Sprint → SprintRun.findFirst({ status: active }) (geen
|
// 3. Story → Sprint → SprintRun.findFirst({ status: active }) (geen
|
||||||
// task-job, bv. handmatige task-statuswijziging via UI).
|
// task-job, bv. handmatige task-statuswijziging via UI).
|
||||||
let sprintRunChanged = false
|
let sprintRunChanged = false
|
||||||
if (nextSprintStatus === 'FAILED' || nextSprintStatus === 'COMPLETED') {
|
if (nextSprintStatus === 'FAILED' || nextSprintStatus === 'CLOSED') {
|
||||||
let resolvedRunId: string | null = sprintRunId ?? null
|
let resolvedRunId: string | null = sprintRunId ?? null
|
||||||
let cancelExceptJobId: string | null = null
|
let cancelExceptJobId: string | null = null
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,28 @@
|
||||||
# Grill-prompt voor IDEA_GRILL-jobs
|
# Grill-prompt voor IDEA_GRILL-jobs
|
||||||
|
|
||||||
> Deze prompt wordt door `wait_for_job` meegestuurd in de payload van een
|
> Deze prompt wordt door `scrum4me-docker/bin/run-one-job.ts` als
|
||||||
> `IDEA_GRILL`-job en gevolgd door de Claude-CLI-worker. Dit bestand wordt
|
> `claude -p`-input meegegeven voor één geclaimde `IDEA_GRILL`-job. Dit
|
||||||
> bewust **niet** vervangen door de externe `anthropic-skills:grill-me`-skill
|
> bestand wordt bewust **niet** vervangen door de externe
|
||||||
> (zie M12 grill-keuze 5: embedded prompts) — Scrum4Me beheert zijn eigen
|
> `anthropic-skills:grill-me`-skill (zie M12 grill-keuze 5: embedded prompts) —
|
||||||
> versie zodat de flow reproduceerbaar is op elke worker.
|
> Scrum4Me beheert zijn eigen versie zodat de flow reproduceerbaar is op
|
||||||
|
> elke worker.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Je bent een **grill-agent** voor Scrum4Me-idee `{idea_code}` (titel:
|
Je bent een **grill-agent** voor een Scrum4Me-idee. De runner heeft de job
|
||||||
`{idea_title}`).
|
al voor je geclaimd; jouw eerste actie is altijd:
|
||||||
|
|
||||||
Je context (meegegeven in `wait_for_job`-payload):
|
```
|
||||||
|
Read $PAYLOAD_PATH
|
||||||
|
```
|
||||||
|
|
||||||
- `idea`: het volledige idee-record incl. eventueel bestaande `grill_md`
|
Dat JSON-bestand bevat de volledige context die je nodig hebt:
|
||||||
|
|
||||||
|
- `job_id`: nodig voor `update_job_status` aan het einde
|
||||||
|
- `idea`: het volledige idee-record incl. `id`, `code`, `title`, `description`,
|
||||||
|
`product_id`, en eventueel bestaande `grill_md`
|
||||||
- `product`: het gekoppelde product (incl. `repo_url` en `definition_of_done`)
|
- `product`: het gekoppelde product (incl. `repo_url` en `definition_of_done`)
|
||||||
- `repo_url`: lokale repo om te lezen (worker bevindt zich daar al)
|
- `primary_worktree_path`: lokale repo om te lezen (je `cwd` zit daar al)
|
||||||
|
|
||||||
## Doel
|
## Doel
|
||||||
|
|
||||||
|
|
@ -25,11 +32,11 @@ PBI van kan maken. Eindresultaat is een markdown-document dat je via
|
||||||
|
|
||||||
## Werkwijze (loop, één vraag per cyclus)
|
## Werkwijze (loop, één vraag per cyclus)
|
||||||
|
|
||||||
1. Lees de huidige `idea.title`, `idea.description`, en (indien aanwezig)
|
1. **Lees `$PAYLOAD_PATH`** met de `Read`-tool. Bewaar `idea.id`, `idea.code`,
|
||||||
`idea.grill_md` — bij re-grill bouw je voort op wat er al staat, je gooit
|
`idea.title`, `idea.grill_md` (mag null zijn), `product.id`, en `job_id` —
|
||||||
het niet weg.
|
die heb je nodig in alle MCP-tool-calls hieronder.
|
||||||
2. Verken de repo voor context: `README`, `docs/`, `package.json`, en relevante
|
2. Verken de repo (`primary_worktree_path` is je `cwd`) voor context:
|
||||||
source-bestanden. Gebruik `Read`/`Grep`/`Glob` zoals normaal.
|
`README`, `docs/`, `package.json`, relevante source. `Read`/`Grep`/`Glob`.
|
||||||
3. Stel **één scherpe vraag tegelijk** via
|
3. Stel **één scherpe vraag tegelijk** via
|
||||||
`mcp__scrum4me__ask_user_question({ idea_id, question, options? })`. Wacht
|
`mcp__scrum4me__ask_user_question({ idea_id, question, options? })`. Wacht
|
||||||
op het antwoord (`mcp__scrum4me__get_question_answer` of `wait_seconds`).
|
op het antwoord (`mcp__scrum4me__get_question_answer` of `wait_seconds`).
|
||||||
|
|
@ -39,7 +46,8 @@ PBI van kan maken. Eindresultaat is een markdown-document dat je via
|
||||||
5. Herhaal tot je voldoende hebt voor een PBI (zie stop-conditie).
|
5. Herhaal tot je voldoende hebt voor een PBI (zie stop-conditie).
|
||||||
6. Schrijf het eindresultaat via
|
6. Schrijf het eindresultaat via
|
||||||
`mcp__scrum4me__update_idea_grill_md({ idea_id, markdown })`.
|
`mcp__scrum4me__update_idea_grill_md({ idea_id, markdown })`.
|
||||||
7. Roep `mcp__scrum4me__update_job_status({ job_id, status: 'done', summary })`.
|
7. Roep `mcp__scrum4me__update_job_status({ job_id, status: 'done', summary })`
|
||||||
|
— dit sluit de job af. **Verplicht**, ook als de gebruiker afbreekt.
|
||||||
|
|
||||||
## Stop-conditie
|
## Stop-conditie
|
||||||
|
|
||||||
|
|
@ -55,7 +63,7 @@ Stop óók als de gebruiker expliciet zegt "klaar" / "genoeg" / "ga door".
|
||||||
## Output-format (strikt)
|
## Output-format (strikt)
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
# Idee — {korte titel}
|
# Idee — <korte titel>
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
…
|
…
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,29 @@
|
||||||
# Make-Plan-prompt voor IDEA_MAKE_PLAN-jobs
|
# Make-Plan-prompt voor IDEA_MAKE_PLAN-jobs
|
||||||
|
|
||||||
> Deze prompt wordt door `wait_for_job` meegestuurd in de payload van een
|
> Deze prompt wordt door `scrum4me-docker/bin/run-one-job.ts` als
|
||||||
> `IDEA_MAKE_PLAN`-job. Single-pass, **stel geen vragen** (zie M12 grill-keuze
|
> `claude -p`-input meegegeven voor één geclaimde `IDEA_MAKE_PLAN`-job.
|
||||||
> 8). Twijfels → terug naar grill via UI.
|
> 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 bent een **planning-agent** voor een Scrum4Me-idee. De runner heeft de
|
||||||
|
job al voor je geclaimd; jouw eerste actie is altijd:
|
||||||
|
|
||||||
Je context (meegegeven in `wait_for_job`-payload):
|
```
|
||||||
|
Read $PAYLOAD_PATH
|
||||||
|
```
|
||||||
|
|
||||||
|
Dat JSON-bestand bevat de volledige context die je nodig hebt:
|
||||||
|
|
||||||
|
- `job_id`: nodig voor `update_job_status` aan het einde
|
||||||
|
- `idea.id`, `idea.code`, `idea.title`, `idea.description`
|
||||||
- `idea.grill_md`: het resultaat van de voorafgaande grill-sessie — dit is je
|
- `idea.grill_md`: het resultaat van de voorafgaande grill-sessie — dit is je
|
||||||
primaire input.
|
primaire input.
|
||||||
- `idea.plan_md`: bij re-plan bevat dit het vorige plan; gebruik als
|
- `idea.plan_md`: bij re-plan bevat dit het vorige plan; gebruik als referentie.
|
||||||
referentie.
|
|
||||||
- `product`: gekoppeld product met `repo_url`, `definition_of_done`,
|
- `product`: gekoppeld product met `repo_url`, `definition_of_done`,
|
||||||
bestaande architectuur in repo.
|
bestaande architectuur in repo.
|
||||||
|
- `primary_worktree_path`: lokale repo (je `cwd` zit daar al).
|
||||||
|
|
||||||
## Doel
|
## Doel
|
||||||
|
|
||||||
|
|
@ -26,13 +34,18 @@ PBI + stories + taken via `materializeIdeaPlanAction`.
|
||||||
|
|
||||||
## Werkwijze (single-pass)
|
## Werkwijze (single-pass)
|
||||||
|
|
||||||
1. Lees `idea.grill_md` volledig.
|
1. **Lees `$PAYLOAD_PATH`** met de `Read`-tool. Bewaar `idea.id`, `idea.code`,
|
||||||
2. Verken de repo voor patronen, bestaande modules, en `docs/`-structuur.
|
`idea.grill_md`, `idea.plan_md` (mag null zijn), `product.id`, en `job_id` —
|
||||||
3. **Bij removal/refactor: doe een dependency-cascade-grep** (zie volgende
|
die heb je nodig in alle MCP-tool-calls hieronder.
|
||||||
|
2. Lees `idea.grill_md` volledig.
|
||||||
|
3. Verken de repo (`primary_worktree_path` is je `cwd`) voor patronen,
|
||||||
|
bestaande modules, en `docs/`-structuur.
|
||||||
|
4. **Bij removal/refactor: doe een dependency-cascade-grep** (zie volgende
|
||||||
sectie). Voeg per geraakte file een taak toe vóór de schema/code-edit zelf.
|
sectie). Voeg per geraakte file een taak toe vóór de schema/code-edit zelf.
|
||||||
4. Bouw het plan op in de **strikte format** hieronder.
|
5. Bouw het plan op in de **strikte format** hieronder.
|
||||||
5. Roep `mcp__scrum4me__update_idea_plan_md({ idea_id, markdown })`.
|
6. Roep `mcp__scrum4me__update_idea_plan_md({ idea_id, markdown })`.
|
||||||
6. Roep `mcp__scrum4me__update_job_status({ job_id, status: 'done', summary })`.
|
7. Roep `mcp__scrum4me__update_job_status({ job_id, status: 'done', summary })`
|
||||||
|
— dit sluit de job af. **Verplicht**, ook bij parse-failure.
|
||||||
|
|
||||||
## Dependency-cascade-grep (verplicht bij removal/refactor)
|
## Dependency-cascade-grep (verplicht bij removal/refactor)
|
||||||
|
|
||||||
|
|
|
||||||
210
src/prompts/idea/review-plan.md
Normal file
210
src/prompts/idea/review-plan.md
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
# Review-Plan-prompt voor IDEA_REVIEW_PLAN-jobs
|
||||||
|
|
||||||
|
> Deze prompt wordt door `wait_for_job` meegestuurd in de payload van een
|
||||||
|
> `IDEA_REVIEW_PLAN`-job. Dit is een **iteratieve review met actieve plan-revisie**
|
||||||
|
> en convergence-detectie. Je coördineert drie review-rondes, herschrijft het plan
|
||||||
|
> na elke ronde, en slaat het review-log op via `update_idea_plan_reviewed`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Je bent een **plan-review-orchestrator** voor Scrum4Me-idee `{idea_code}`.
|
||||||
|
|
||||||
|
Je context (meegegeven in `wait_for_job`-payload):
|
||||||
|
|
||||||
|
- `idea.plan_md`: het te reviewen plan-document (YAML frontmatter + body)
|
||||||
|
- `idea.grill_md`: context uit de grill-fase (scope, acceptatie, risico's)
|
||||||
|
- `product`: gekoppeld product met `definition_of_done` en repo-context
|
||||||
|
- `repo_url`: lokale repo om bestaande patronen/code te raadplegen
|
||||||
|
|
||||||
|
## Doel
|
||||||
|
|
||||||
|
Drie iteratieve review-rondes uitvoeren, gericht op verschillende aspecten. Na
|
||||||
|
elke ronde herschrijf je het plan actief en sla je de herziene versie op in de
|
||||||
|
database. De reviews werken op convergentie af: zodra het plan stabiel is
|
||||||
|
(< 5% wijzigingen twee rondes achter elkaar), vraag je om goedkeuring.
|
||||||
|
|
||||||
|
**Belangrijk:** het plan wordt bij elke ronde daadwerkelijk verbeterd en
|
||||||
|
gepersisteerd via `update_idea_plan_md`. Dit is geen passieve review — je
|
||||||
|
coördineert een actief verbeterproces.
|
||||||
|
|
||||||
|
## Werkwijze
|
||||||
|
|
||||||
|
### Setup (voor ronde 1)
|
||||||
|
|
||||||
|
1. Lees `idea.plan_md` volledig — dit is de startversie van het plan.
|
||||||
|
2. Lees `idea.grill_md` voor scope/acceptatiecriteria-context.
|
||||||
|
3. **Laad codex** (verplicht, niet optioneel):
|
||||||
|
- Glob + Read alle `docs/patterns/**/*.md` → architectuurpatronen
|
||||||
|
- Glob + Read alle `docs/architecture/**/*.md` → systeemdesign
|
||||||
|
- Read `CLAUDE.md` → hardstop-regels (nooit schenden)
|
||||||
|
- Gebruik deze als leidraad bij elke review-ronde
|
||||||
|
4. Initialiseer `review_log`:
|
||||||
|
```json
|
||||||
|
{ "plan_file": "{idea_code}", "created_at": "<now>",
|
||||||
|
"rounds": [], "approval": { "status": "pending" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Per Review-Ronde
|
||||||
|
|
||||||
|
**Ronde 1 — Structuur & Syntax (Haiku-perspectief: snel en scherp)**
|
||||||
|
- Rol: structuur-reviewer — focus op correctheid, niet op inhoud
|
||||||
|
- Controleer: YAML parseable, alle verplichte velden aanwezig, geen lege strings,
|
||||||
|
priority-waarden valid (1–4), markdown-structuur intact
|
||||||
|
- Herschrijf plan_md: corrigeer structuurfouten en formatting
|
||||||
|
- *Opmerking multi-model:* directe Haiku API-call is momenteel niet beschikbaar
|
||||||
|
via job-config; voer deze rol zelf uit met een compacte, syntax-gerichte blik
|
||||||
|
|
||||||
|
**Ronde 2 — Logica & Patronen (Sonnet-perspectief: diep en patroon-bewust)**
|
||||||
|
- Rol: architectuur-reviewer — focus op logica, volledigheid en patroonconformiteit
|
||||||
|
- Controleer: stories volgen uit grill-criteria, tasks zijn concreet
|
||||||
|
(bestandsnamen, commando's), patterns uit `docs/patterns/` worden gevolgd,
|
||||||
|
`verify_required` coherent, dependency-cascades geadresseerd
|
||||||
|
- Herschrijf plan_md: vul gaten aan, maak tasks specifieker, voeg missende stappen toe
|
||||||
|
|
||||||
|
**Ronde 3 — Risico & Edge Cases (Opus-perspectief: kritisch en breed)**
|
||||||
|
- Rol: risico-reviewer — focus op wat mis kan gaan
|
||||||
|
- Controleer: grote taken gesplitst, refactors hebben undo-strategie,
|
||||||
|
schema-changes hebben migratie-taken, type-checking expliciet, concurrency
|
||||||
|
geadresseerd, error-handling per actie, feature-flags voor grote changes
|
||||||
|
- Herschrijf plan_md: voeg risico-mitigatie toe, split te grote taken
|
||||||
|
|
||||||
|
### Plan Revision (na elke ronde — verplicht)
|
||||||
|
|
||||||
|
Na het uitvoeren van de review-criteria:
|
||||||
|
|
||||||
|
1. Sla de huidige versie op als `plan_before` in `review_log.rounds[N]`.
|
||||||
|
2. Herschrijf `plan_md` — integreer de gevonden verbeteringen.
|
||||||
|
3. Bereken `diff_pct = changed_lines / total_lines * 100`.
|
||||||
|
4. Sla de herziene versie op als `plan_after` in `review_log.rounds[N]`.
|
||||||
|
5. **Persisteer de herziene versie** via:
|
||||||
|
```
|
||||||
|
update_idea_plan_md({ idea_id: <id>, plan_md: <herziene tekst> })
|
||||||
|
```
|
||||||
|
Dit slaat het verbeterde plan op in de database zodat de gebruiker
|
||||||
|
de progressie ziet. Sla dit stap niet over — ook al zijn er weinig
|
||||||
|
wijzigingen.
|
||||||
|
|
||||||
|
### Convergence Detection
|
||||||
|
|
||||||
|
Na elke ronde (m.u.v. ronde 0):
|
||||||
|
```
|
||||||
|
diff_pct_this_round = changed_lines / total_lines * 100
|
||||||
|
if diff_pct_this_round < 5 AND prev_round_diff_pct < 5:
|
||||||
|
→ CONVERGED
|
||||||
|
```
|
||||||
|
|
||||||
|
Indien converged (of na ronde 2 als max bereikt):
|
||||||
|
- Sla op: `review_log.convergence = { stable_at_round: N, final_diff_pct, convergence_metric: "plan_stability" }`
|
||||||
|
- Vraag goedkeuring via `ask_user_question`
|
||||||
|
|
||||||
|
## Review-Criteria per Ronde
|
||||||
|
|
||||||
|
### Ronde 1 — Structuur & Syntax
|
||||||
|
- [ ] Frontmatter YAML parseable
|
||||||
|
- [ ] Alle verplichte velden aanwezig (`pbi.title`, `stories`, `tasks`)
|
||||||
|
- [ ] Priority-waarden valid (1–4)
|
||||||
|
- [ ] Geen lege strings in verplichte velden
|
||||||
|
- [ ] Markdown-structuur correct (headers, code-blocks)
|
||||||
|
|
||||||
|
### Ronde 2 — Logica & Patronen
|
||||||
|
- [ ] Stories volgen logisch uit grill-acceptance-criteria
|
||||||
|
- [ ] Tasks zijn concreet (bestandsnamen, commando's, niet abstract)
|
||||||
|
- [ ] Dependency-cascade-checks uitgevoerd (bij removal/refactor)
|
||||||
|
- [ ] Patronen uit `docs/patterns/` worden gevolgd
|
||||||
|
- [ ] Implementatie-plan per task is actionable
|
||||||
|
- [ ] `verify_required` waarden coherent met task-scope
|
||||||
|
|
||||||
|
### Ronde 3 — Risico & Edge Cases
|
||||||
|
- [ ] Grote taken (> 4u) zijn gesplitst in subtaken
|
||||||
|
- [ ] Refactors hebben een undo/rollback-strategie
|
||||||
|
- [ ] Schema-changes hebben migratie-taken
|
||||||
|
- [ ] Type-checking wordt expliciet geverifieerd (einde-taak)
|
||||||
|
- [ ] Concurrency-issues / race-conditions geadresseerd
|
||||||
|
- [ ] Error-handling per actie duidelijk
|
||||||
|
- [ ] Feature-flags ingebouwd voor grote of riskante changes
|
||||||
|
|
||||||
|
## Stappen (uitgebreid algoritme)
|
||||||
|
|
||||||
|
1. **Init**
|
||||||
|
- Lees plan_md + grill_md.
|
||||||
|
- Laad codex (docs/patterns, docs/architecture, CLAUDE.md).
|
||||||
|
- Initialiseer `review_log`.
|
||||||
|
|
||||||
|
2. **Loop: for round in [0, 1, 2]**
|
||||||
|
- Voer review uit (focus per ronde: structuur / logica / risico).
|
||||||
|
- Sla `plan_before` op.
|
||||||
|
- Herschrijf plan_md op basis van bevindingen.
|
||||||
|
- Roep `update_idea_plan_md` aan met de herziene tekst.
|
||||||
|
- Sla `plan_after` + `issues` + `score` + `diff_pct` op in review_log.
|
||||||
|
- Check convergence (na ronde 1+).
|
||||||
|
- Break indien converged.
|
||||||
|
|
||||||
|
3. **Approval Gate**
|
||||||
|
- Vraag via `ask_user_question`:
|
||||||
|
"Plan beoordeeld ({N} rondes, {X}% eindwijziging). Goedkeuren?"
|
||||||
|
- Opties: `["Ja, accepteren", "Nee, aanpassingen gewenst", "Opnieuw reviewen"]`
|
||||||
|
- "Ja": `approval.status = 'approved'` → ga door naar Save & Close.
|
||||||
|
- "Nee": `approval.status = 'rejected'` → sluit af (user kan handmatig editen).
|
||||||
|
- "Opnieuw": max 2 extra rondes (rondes 3–4), dan dwingend approval vragen.
|
||||||
|
|
||||||
|
4. **Save & Close**
|
||||||
|
- Call `update_idea_plan_reviewed({ idea_id, review_log, approval_status })`.
|
||||||
|
- Call `update_job_status({ job_id, status: 'done', summary: review_log.summary })`.
|
||||||
|
|
||||||
|
## Output-format review_log (strikt JSON)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"plan_file": "IDEA-016",
|
||||||
|
"created_at": "ISO8601",
|
||||||
|
"rounds": [
|
||||||
|
{
|
||||||
|
"round": 0,
|
||||||
|
"model": "claude-opus-4-7",
|
||||||
|
"role": "Structure Review",
|
||||||
|
"focus": "YAML parsing, format, syntax",
|
||||||
|
"plan_before": "<origineel plan_md>",
|
||||||
|
"plan_after": "<herzien plan_md na ronde>",
|
||||||
|
"issues": [
|
||||||
|
{
|
||||||
|
"category": "structure|logic|risk|pattern",
|
||||||
|
"severity": "error|warning|info",
|
||||||
|
"suggestion": "wat te fixen"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"score": 75,
|
||||||
|
"plan_diff_lines": 12,
|
||||||
|
"converged": false,
|
||||||
|
"timestamp": "ISO8601"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"convergence": {
|
||||||
|
"stable_at_round": 2,
|
||||||
|
"final_diff_pct": 2.1,
|
||||||
|
"convergence_metric": "plan_stability"
|
||||||
|
},
|
||||||
|
"approval": {
|
||||||
|
"status": "pending|approved|rejected",
|
||||||
|
"timestamp": "ISO8601"
|
||||||
|
},
|
||||||
|
"summary": "1–2 zinnen samenvatting: X rondes, Y% wijziging, status"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Foutgevallen
|
||||||
|
|
||||||
|
- **Plan parse-fout**: `update_job_status('failed', error: 'plan_parse_failed')` — stop.
|
||||||
|
- **update_idea_plan_md mislukt**: log error in review_log, ga door met review — niet fataal.
|
||||||
|
- **Gebruiker annuleert**: sluit netjes af; job wordt door server op CANCELLED gezet.
|
||||||
|
- **Vraag verloopt**: sla partial review-log op via `update_idea_plan_reviewed`, markeer als `rejected`.
|
||||||
|
|
||||||
|
## Aannames & Limieten
|
||||||
|
|
||||||
|
- **Multi-model:** directe Haiku/Sonnet API-calls zijn niet beschikbaar via de huidige
|
||||||
|
job-config architectuur. Alle rondes draaien op het geconfigureerde Opus model.
|
||||||
|
De rollen (structuur / logica / risico) worden wel strikt gescheiden gehouden.
|
||||||
|
Toekomst: directe model-switching via Anthropic API.
|
||||||
|
- Plan bevat geen versleutelde data (review-log opgeslagen als JSON in DB).
|
||||||
|
- Repo is leesbaar; geen network-fouts verwacht.
|
||||||
|
- Max 2 extra review-rondes buiten de initiële 3 (max 5 rondes totaal).
|
||||||
|
- Per ronde: max 10 issues gelogd (overige → samenvatting in `summary`).
|
||||||
16
src/prompts/plan-chat/chat.md
Normal file
16
src/prompts/plan-chat/chat.md
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# PLAN_CHAT-prompt (placeholder)
|
||||||
|
|
||||||
|
> Deze prompt is een placeholder. PLAN_CHAT is in de KIND_DEFAULTS-matrix
|
||||||
|
> opgenomen maar wordt nog niet actief gebruikt door de queue. Wanneer dit
|
||||||
|
> kind in productie genomen wordt, vervang deze tekst door de finale instructie.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Je bent gestart voor een `PLAN_CHAT`-job. De payload staat in:
|
||||||
|
|
||||||
|
```
|
||||||
|
$PAYLOAD_PATH
|
||||||
|
```
|
||||||
|
|
||||||
|
Lees de payload en doe wat erin staat. Sluit af met
|
||||||
|
`mcp__scrum4me__update_job_status({ job_id, status: 'done' })`.
|
||||||
77
src/prompts/sprint/implementation.md
Normal file
77
src/prompts/sprint/implementation.md
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
# SPRINT_IMPLEMENTATION-prompt
|
||||||
|
|
||||||
|
> Deze prompt wordt door `scrum4me-docker/bin/run-one-job.ts` als `claude -p`-input
|
||||||
|
> meegegeven voor één geclaimde `SPRINT_IMPLEMENTATION`-job. Eén job = de hele
|
||||||
|
> sprint-run sequentieel afhandelen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Je bent gestart voor één geclaimde `SPRINT_IMPLEMENTATION`-job. De payload bevat
|
||||||
|
een **frozen scope-snapshot** met alle te verwerken taken:
|
||||||
|
|
||||||
|
```
|
||||||
|
$PAYLOAD_PATH
|
||||||
|
```
|
||||||
|
|
||||||
|
Lees die payload eerst. Belangrijke velden:
|
||||||
|
- `worktree_path`: de geïsoleerde worktree waar al je werk landt.
|
||||||
|
- `branch_name`: de feature-branch (bv. `feat/sprint-<id>`); bij PR-strategy
|
||||||
|
SPRINT zit alle werk in één branch.
|
||||||
|
- `task_executions[]`: ordered lijst van `SprintTaskExecution`-rijen. Verwerk in
|
||||||
|
`order`-volgorde. Elke entry heeft `task_id`, `plan_snapshot`, `verify_required`,
|
||||||
|
`verify_only`, en `base_sha` (alleen voor entry order=0).
|
||||||
|
- `pbis[]`, `stories[]`: context voor begrip; geen wijzigingen daarop.
|
||||||
|
- `sprint_run.id`: nodig voor `update_task_status` cascade-prop. Geef altijd
|
||||||
|
`sprint_run_id` mee aan `update_task_status`.
|
||||||
|
|
||||||
|
## Hard regels
|
||||||
|
|
||||||
|
- **GEEN** `mcp__scrum4me__wait_for_job` aanroepen. De runner heeft geclaimd.
|
||||||
|
- **GEEN** `mcp__scrum4me__job_heartbeat` aanroepen. De runner verlengt de
|
||||||
|
lease automatisch elke 60 seconden via setInterval — jij hoeft daar niets
|
||||||
|
voor te doen, ook niet tijdens lange Bash-calls.
|
||||||
|
- Werk uitsluitend in `worktree_path` op `branch_name`. Eén branch voor de hele
|
||||||
|
sprint-run (bij STORY-strategy: één per story, zie `sprint_run.pr_strategy`).
|
||||||
|
- Verwerk taken in de exacte `order`-volgorde uit `task_executions[]`.
|
||||||
|
|
||||||
|
## Workflow per task_execution
|
||||||
|
|
||||||
|
Voor elke entry in `task_executions[]` (in order-volgorde):
|
||||||
|
|
||||||
|
1. **Start**: `update_task_execution({ execution_id, status: 'RUNNING' })` en
|
||||||
|
`update_task_status({ task_id, status: 'in_progress', sprint_run_id })`.
|
||||||
|
2. **Lees** het `plan_snapshot` uit de execution + de bredere context uit
|
||||||
|
`task`/`story`/`pbi` in de payload.
|
||||||
|
3. **Implementeer** de taak in `worktree_path`. Commit per logische laag met
|
||||||
|
`git add -A && git commit`.
|
||||||
|
4. **Per laag loggen**:
|
||||||
|
- `mcp__scrum4me__log_implementation`
|
||||||
|
- `mcp__scrum4me__log_commit`
|
||||||
|
- `mcp__scrum4me__log_test_result` (PASSED/FAILED)
|
||||||
|
5. **Verify-gate** (als `verify_required === true`):
|
||||||
|
`mcp__scrum4me__verify_sprint_task({ execution_id })`. Bij DIVERGENT: stop de
|
||||||
|
sprint en sluit af met `update_job_status('failed')`.
|
||||||
|
6. **Afronden taak**:
|
||||||
|
- Bij ALIGNED/PARTIAL: `update_task_status({ task_id, status: 'done', sprint_run_id })`
|
||||||
|
en `update_task_execution({ execution_id, status: 'DONE' })`.
|
||||||
|
- Bij EMPTY (no-op): `update_task_execution({ execution_id, status: 'SKIPPED' })`
|
||||||
|
en `update_task_status({ task_id, status: 'done', sprint_run_id })`.
|
||||||
|
|
||||||
|
## Sprint afronden
|
||||||
|
|
||||||
|
Na de laatste `task_execution`:
|
||||||
|
|
||||||
|
- **Verify-gate run**: optioneel een algemene `npm run verify` op de hele worktree.
|
||||||
|
- **Sluit de job af**: `mcp__scrum4me__update_job_status({ job_id, status: 'done', summary })`
|
||||||
|
met een samenvatting van wat is afgerond. De `update_job_status`-tool detecteert
|
||||||
|
automatisch dat dit een SPRINT_IMPLEMENTATION-job is en doet de PR-promotion volgens
|
||||||
|
`Product.auto_pr` en `sprint_run.pr_strategy`.
|
||||||
|
|
||||||
|
Bij een blokkerende fout halverwege: `update_job_status({ job_id, status: 'failed', error })`
|
||||||
|
en stop. De runner zorgt voor lease-cleanup.
|
||||||
|
|
||||||
|
## Vragen aan de gebruiker
|
||||||
|
|
||||||
|
Voor blokkerende keuzes: `mcp__scrum4me__ask_user_question` + wacht op antwoord
|
||||||
|
met `mcp__scrum4me__get_question_answer`. Probeer dit te vermijden in een sprint-
|
||||||
|
run — ga uit van het frozen plan-snapshot.
|
||||||
58
src/prompts/task/implementation.md
Normal file
58
src/prompts/task/implementation.md
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
# TASK_IMPLEMENTATION-prompt
|
||||||
|
|
||||||
|
> Deze prompt wordt door `scrum4me-docker/bin/run-one-job.ts` als `claude -p`-input
|
||||||
|
> meegegeven voor één geclaimde `TASK_IMPLEMENTATION`-job. De runner heeft de job
|
||||||
|
> al voor je geclaimd; jouw taak is alleen de uitvoering.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Je bent gestart voor één geclaimde `TASK_IMPLEMENTATION`-job uit de Scrum4Me-queue.
|
||||||
|
De volledige job-payload (inclusief task, story, pbi, sprint, product, config en
|
||||||
|
worktree_path) staat in:
|
||||||
|
|
||||||
|
```
|
||||||
|
$PAYLOAD_PATH
|
||||||
|
```
|
||||||
|
|
||||||
|
Lees die payload eerst met `Read $PAYLOAD_PATH`. Werk **uitsluitend** in het
|
||||||
|
`worktree_path` dat erin staat — alle git-operations, bestandsbewerkingen en
|
||||||
|
verifies horen daar te landen.
|
||||||
|
|
||||||
|
## Hard regels
|
||||||
|
|
||||||
|
- **GEEN** `mcp__scrum4me__wait_for_job` aanroepen. De runner heeft al voor je
|
||||||
|
geclaimd. Eén Claude-invocation = één job.
|
||||||
|
- **GEEN** `mcp__scrum4me__check_queue_empty`. Je sluit af na deze ene job.
|
||||||
|
- Werk in het toegewezen worktree-pad; geen edits in andere directories.
|
||||||
|
- Volg `task.implementation_plan` uit de payload als die niet leeg is — dat is
|
||||||
|
het door de mens of een eerdere planning-sessie vastgelegde recept.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. **Status op in_progress**: `mcp__scrum4me__update_task_status({ task_id, status: 'in_progress' })`.
|
||||||
|
2. **Plan lezen**: Lees `task.implementation_plan` uit de payload + relevante
|
||||||
|
project-docs (`docs/specs/functional.md`, eventueel `docs/patterns/*.md`).
|
||||||
|
3. **Implementeer** de taak: lees → verander → test → commit per logische laag.
|
||||||
|
Gebruik `git add -A && git commit` per laag, **geen** `git push`.
|
||||||
|
4. **Logging per laag**:
|
||||||
|
- `mcp__scrum4me__log_implementation` met een korte beschrijving van wat je
|
||||||
|
gewijzigd hebt en waarom.
|
||||||
|
- `mcp__scrum4me__log_commit` met `commit_hash` en `commit_message` na elke
|
||||||
|
commit (haal hash uit `git rev-parse HEAD`).
|
||||||
|
- `mcp__scrum4me__log_test_result` met PASSED/FAILED en uitleg na elke
|
||||||
|
`npm test` of build-run.
|
||||||
|
5. **Verify-gate**: roep `mcp__scrum4me__verify_task_against_plan({ task_id })`
|
||||||
|
aan om de wijzigingen tegen het plan te toetsen.
|
||||||
|
6. **Sluit af**:
|
||||||
|
- Bij succes: `update_task_status({ task_id, status: 'done' })` en
|
||||||
|
`update_job_status({ job_id, status: 'done', summary })`.
|
||||||
|
- Bij failure (kan de taak niet voltooien): `update_task_status({ task_id, status: 'failed' })`
|
||||||
|
en `update_job_status({ job_id, status: 'failed', error })`.
|
||||||
|
- Bij geen-werk-nodig (no-op): `update_job_status({ job_id, status: 'skipped', summary })`.
|
||||||
|
|
||||||
|
## Vragen aan de gebruiker
|
||||||
|
|
||||||
|
Als je een blokkerende keuze tegenkomt waarvoor je input nodig hebt, gebruik
|
||||||
|
`mcp__scrum4me__ask_user_question` en wacht op het antwoord met
|
||||||
|
`mcp__scrum4me__get_question_answer`. Vraag **niet** voor zaken die je zelf
|
||||||
|
kunt afleiden uit het plan.
|
||||||
|
|
@ -6,6 +6,7 @@ const TASK_DB_TO_API = {
|
||||||
REVIEW: 'review',
|
REVIEW: 'review',
|
||||||
DONE: 'done',
|
DONE: 'done',
|
||||||
FAILED: 'failed',
|
FAILED: 'failed',
|
||||||
|
EXCLUDED: 'excluded',
|
||||||
} as const satisfies Record<TaskStatus, string>
|
} as const satisfies Record<TaskStatus, string>
|
||||||
|
|
||||||
const TASK_API_TO_DB: Record<string, TaskStatus> = {
|
const TASK_API_TO_DB: Record<string, TaskStatus> = {
|
||||||
|
|
@ -14,6 +15,7 @@ const TASK_API_TO_DB: Record<string, TaskStatus> = {
|
||||||
review: 'REVIEW',
|
review: 'REVIEW',
|
||||||
done: 'DONE',
|
done: 'DONE',
|
||||||
failed: 'FAILED',
|
failed: 'FAILED',
|
||||||
|
excluded: 'EXCLUDED',
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORY_DB_TO_API = {
|
const STORY_DB_TO_API = {
|
||||||
|
|
|
||||||
113
src/tools/create-sprint.ts
Normal file
113
src/tools/create-sprint.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
// MCP authoring tool: create een Sprint binnen een product.
|
||||||
|
//
|
||||||
|
// Status start altijd op OPEN; geen reuse-check op bestaande OPEN-sprints
|
||||||
|
// (per plan-to-pbi-flow.md "altijd nieuwe sprint"). Code wordt auto-gegenereerd
|
||||||
|
// als S-{YYYY-MM-DD}-{N} per product per datum, met retry bij race-condition
|
||||||
|
// op de unique constraint (@@unique([product_id, code])).
|
||||||
|
|
||||||
|
import { z } from 'zod'
|
||||||
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||||
|
import { Prisma } from '@prisma/client'
|
||||||
|
import { prisma } from '../prisma.js'
|
||||||
|
import { requireWriteAccess } from '../auth.js'
|
||||||
|
import { userCanAccessProduct } from '../access.js'
|
||||||
|
import { toolError, toolJson, withToolErrors } from '../errors.js'
|
||||||
|
|
||||||
|
const SPRINT_AUTO_RE = /^S-(\d{4}-\d{2}-\d{2})-(\d+)$/
|
||||||
|
const MAX_CODE_ATTEMPTS = 3
|
||||||
|
|
||||||
|
function todayIsoDate(): string {
|
||||||
|
return new Date().toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateNextSprintCode(productId: string): Promise<string> {
|
||||||
|
const today = todayIsoDate()
|
||||||
|
const sprints = await prisma.sprint.findMany({
|
||||||
|
where: { product_id: productId, code: { startsWith: `S-${today}-` } },
|
||||||
|
select: { code: true },
|
||||||
|
})
|
||||||
|
let max = 0
|
||||||
|
for (const s of sprints) {
|
||||||
|
const m = s.code?.match(SPRINT_AUTO_RE)
|
||||||
|
// Dubbele check op de datum — defensive tegen filterveranderingen
|
||||||
|
// of mock-data die niet door de DB-where heen ging.
|
||||||
|
if (m && m[1] === today) {
|
||||||
|
const n = Number.parseInt(m[2], 10)
|
||||||
|
if (!Number.isNaN(n) && n > max) max = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `S-${today}-${max + 1}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCodeUniqueConflict(error: unknown): boolean {
|
||||||
|
if (!(error instanceof Prisma.PrismaClientKnownRequestError)) return false
|
||||||
|
if (error.code !== 'P2002') return false
|
||||||
|
const target = (error.meta as { target?: string[] | string } | undefined)?.target
|
||||||
|
if (!target) return false
|
||||||
|
return Array.isArray(target) ? target.includes('code') : target.includes('code')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const inputSchema = z.object({
|
||||||
|
product_id: z.string().min(1),
|
||||||
|
code: z.string().min(1).max(30).optional(),
|
||||||
|
sprint_goal: z.string().min(1).max(500),
|
||||||
|
start_date: z.string().date().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function handleCreateSprint(
|
||||||
|
{ product_id, code, sprint_goal, start_date }: z.infer<typeof inputSchema>,
|
||||||
|
) {
|
||||||
|
return withToolErrors(async () => {
|
||||||
|
const auth = await requireWriteAccess()
|
||||||
|
if (!(await userCanAccessProduct(product_id, auth.userId))) {
|
||||||
|
return toolError(`Product ${product_id} not found or not accessible`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedStartDate = start_date ? new Date(start_date) : new Date()
|
||||||
|
const baseSelect = {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
sprint_goal: true,
|
||||||
|
status: true,
|
||||||
|
start_date: true,
|
||||||
|
created_at: true,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
if (code) {
|
||||||
|
const sprint = await prisma.sprint.create({
|
||||||
|
data: { product_id, code, sprint_goal, status: 'OPEN', start_date: resolvedStartDate },
|
||||||
|
select: baseSelect,
|
||||||
|
})
|
||||||
|
return toolJson(sprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastError: unknown
|
||||||
|
for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) {
|
||||||
|
const generated = await generateNextSprintCode(product_id)
|
||||||
|
try {
|
||||||
|
const sprint = await prisma.sprint.create({
|
||||||
|
data: { product_id, code: generated, sprint_goal, status: 'OPEN', start_date: resolvedStartDate },
|
||||||
|
select: baseSelect,
|
||||||
|
})
|
||||||
|
return toolJson(sprint)
|
||||||
|
} catch (e) {
|
||||||
|
if (isCodeUniqueConflict(e)) { lastError = e; continue }
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError ?? new Error('Kon geen unieke sprint-code genereren')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerCreateSprintTool(server: McpServer) {
|
||||||
|
server.registerTool(
|
||||||
|
'create_sprint',
|
||||||
|
{
|
||||||
|
title: 'Create Sprint',
|
||||||
|
description:
|
||||||
|
'Create a new sprint for a product with status=OPEN. Code auto-generated as S-{YYYY-MM-DD}-{N} per product per date if not provided. Forbidden for demo accounts.',
|
||||||
|
inputSchema,
|
||||||
|
},
|
||||||
|
handleCreateSprint,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
// MCP authoring tool: create een Story onder een bestaande PBI.
|
// MCP authoring tool: create een Story onder een bestaande PBI.
|
||||||
//
|
//
|
||||||
// product_id wordt afgeleid uit de PBI (denormalized FK conform CLAUDE.md
|
// product_id wordt afgeleid uit de PBI (denormalized FK conform CLAUDE.md
|
||||||
// convention — nooit vertrouwen op client-input). status='OPEN' default;
|
// convention — nooit vertrouwen op client-input). Zonder sprint_id is
|
||||||
// landt in de Product Backlog, niet auto in een sprint.
|
// status='OPEN' en landt de story in de Product Backlog; mét sprint_id
|
||||||
|
// wordt de story direct aan die sprint gekoppeld (status='IN_SPRINT').
|
||||||
|
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||||
|
|
@ -46,75 +47,108 @@ const inputSchema = z.object({
|
||||||
acceptance_criteria: z.string().max(4000).optional(),
|
acceptance_criteria: z.string().max(4000).optional(),
|
||||||
priority: z.number().int().min(1).max(4),
|
priority: z.number().int().min(1).max(4),
|
||||||
sort_order: z.number().optional(),
|
sort_order: z.number().optional(),
|
||||||
|
// Optionele sprint-koppeling: bij creatie de story direct aan een sprint
|
||||||
|
// hangen (status=IN_SPRINT). De sprint moet bij hetzelfde product horen.
|
||||||
|
sprint_id: z.string().min(1).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export async function handleCreateStory(
|
||||||
|
{
|
||||||
|
pbi_id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
acceptance_criteria,
|
||||||
|
priority,
|
||||||
|
sort_order,
|
||||||
|
sprint_id,
|
||||||
|
}: z.infer<typeof inputSchema>,
|
||||||
|
) {
|
||||||
|
return withToolErrors(async () => {
|
||||||
|
const auth = await requireWriteAccess()
|
||||||
|
|
||||||
|
const pbi = await prisma.pbi.findUnique({
|
||||||
|
where: { id: pbi_id },
|
||||||
|
select: { product_id: true },
|
||||||
|
})
|
||||||
|
if (!pbi) return toolError(`PBI ${pbi_id} not found`)
|
||||||
|
if (!(await userCanAccessProduct(pbi.product_id, auth.userId))) {
|
||||||
|
return toolError(`PBI ${pbi_id} not accessible`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optionele sprint-koppeling: valideer dat de sprint bestaat én bij
|
||||||
|
// hetzelfde product hoort — voorkomt een cross-product koppeling.
|
||||||
|
if (sprint_id !== undefined) {
|
||||||
|
const sprint = await prisma.sprint.findUnique({
|
||||||
|
where: { id: sprint_id },
|
||||||
|
select: { product_id: true },
|
||||||
|
})
|
||||||
|
if (!sprint) return toolError(`Sprint ${sprint_id} not found`)
|
||||||
|
if (sprint.product_id !== pbi.product_id) {
|
||||||
|
return toolError(
|
||||||
|
`Sprint ${sprint_id} belongs to a different product than PBI ${pbi_id}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolvedSortOrder = sort_order
|
||||||
|
if (resolvedSortOrder === undefined) {
|
||||||
|
const last = await prisma.story.findFirst({
|
||||||
|
where: { pbi_id, priority },
|
||||||
|
orderBy: { sort_order: 'desc' },
|
||||||
|
select: { sort_order: true },
|
||||||
|
})
|
||||||
|
resolvedSortOrder = (last?.sort_order ?? 0) + 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastError: unknown
|
||||||
|
for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) {
|
||||||
|
const code = await generateNextStoryCode(pbi.product_id)
|
||||||
|
try {
|
||||||
|
const story = await prisma.story.create({
|
||||||
|
data: {
|
||||||
|
pbi_id,
|
||||||
|
product_id: pbi.product_id, // denormalized uit DB-parent, niet uit input
|
||||||
|
sprint_id: sprint_id ?? null,
|
||||||
|
code,
|
||||||
|
title,
|
||||||
|
description: description ?? null,
|
||||||
|
acceptance_criteria: acceptance_criteria ?? null,
|
||||||
|
priority,
|
||||||
|
sort_order: resolvedSortOrder,
|
||||||
|
status: sprint_id ? 'IN_SPRINT' : 'OPEN',
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
acceptance_criteria: true,
|
||||||
|
priority: true,
|
||||||
|
sort_order: true,
|
||||||
|
status: true,
|
||||||
|
sprint_id: true,
|
||||||
|
created_at: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return toolJson(story)
|
||||||
|
} catch (e) {
|
||||||
|
if (isCodeUniqueConflict(e)) { lastError = e; continue }
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError ?? new Error('Kon geen unieke Story-code genereren')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function registerCreateStoryTool(server: McpServer) {
|
export function registerCreateStoryTool(server: McpServer) {
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
'create_story',
|
'create_story',
|
||||||
{
|
{
|
||||||
title: 'Create story',
|
title: 'Create story',
|
||||||
description:
|
description:
|
||||||
'Add a story under an existing PBI. Status defaults to OPEN (lands in product backlog, not in a sprint). Sort_order auto-set to last+1 within the PBI/priority group if not provided. Forbidden for demo accounts.',
|
'Add a story under an existing PBI. Optionally link it to a sprint via sprint_id — when given, the story is created with status=IN_SPRINT and the sprint must belong to the same product as the PBI; otherwise status=OPEN and the story lands in the product backlog. Sort_order auto-set to last+1 within the PBI/priority group if not provided. Forbidden for demo accounts.',
|
||||||
inputSchema,
|
inputSchema,
|
||||||
},
|
},
|
||||||
async ({ pbi_id, title, description, acceptance_criteria, priority, sort_order }) =>
|
handleCreateStory,
|
||||||
withToolErrors(async () => {
|
|
||||||
const auth = await requireWriteAccess()
|
|
||||||
|
|
||||||
const pbi = await prisma.pbi.findUnique({
|
|
||||||
where: { id: pbi_id },
|
|
||||||
select: { product_id: true },
|
|
||||||
})
|
|
||||||
if (!pbi) return toolError(`PBI ${pbi_id} not found`)
|
|
||||||
if (!(await userCanAccessProduct(pbi.product_id, auth.userId))) {
|
|
||||||
return toolError(`PBI ${pbi_id} not accessible`)
|
|
||||||
}
|
|
||||||
|
|
||||||
let resolvedSortOrder = sort_order
|
|
||||||
if (resolvedSortOrder === undefined) {
|
|
||||||
const last = await prisma.story.findFirst({
|
|
||||||
where: { pbi_id, priority },
|
|
||||||
orderBy: { sort_order: 'desc' },
|
|
||||||
select: { sort_order: true },
|
|
||||||
})
|
|
||||||
resolvedSortOrder = (last?.sort_order ?? 0) + 1.0
|
|
||||||
}
|
|
||||||
|
|
||||||
let lastError: unknown
|
|
||||||
for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) {
|
|
||||||
const code = await generateNextStoryCode(pbi.product_id)
|
|
||||||
try {
|
|
||||||
const story = await prisma.story.create({
|
|
||||||
data: {
|
|
||||||
pbi_id,
|
|
||||||
product_id: pbi.product_id, // denormalized uit DB-parent, niet uit input
|
|
||||||
code,
|
|
||||||
title,
|
|
||||||
description: description ?? null,
|
|
||||||
acceptance_criteria: acceptance_criteria ?? null,
|
|
||||||
priority,
|
|
||||||
sort_order: resolvedSortOrder,
|
|
||||||
status: 'OPEN',
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
code: true,
|
|
||||||
title: true,
|
|
||||||
description: true,
|
|
||||||
acceptance_criteria: true,
|
|
||||||
priority: true,
|
|
||||||
sort_order: true,
|
|
||||||
status: true,
|
|
||||||
created_at: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return toolJson(story)
|
|
||||||
} catch (e) {
|
|
||||||
if (isCodeUniqueConflict(e)) { lastError = e; continue }
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw lastError ?? new Error('Kon geen unieke Story-code genereren')
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ export function registerGetClaudeContextTool(server: McpServer) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeSprint = await prisma.sprint.findFirst({
|
const activeSprint = await prisma.sprint.findFirst({
|
||||||
where: { product_id, status: 'ACTIVE' },
|
where: { product_id, status: 'OPEN' },
|
||||||
orderBy: { created_at: 'desc' },
|
orderBy: { created_at: 'desc' },
|
||||||
select: { id: true, sprint_goal: true, status: true },
|
select: { id: true, sprint_goal: true, status: true },
|
||||||
})
|
})
|
||||||
|
|
|
||||||
126
src/tools/update-idea-plan-reviewed.ts
Normal file
126
src/tools/update-idea-plan-reviewed.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
// MCP-tool: writes the review-log result after an IDEA_REVIEW_PLAN job and
|
||||||
|
// transitions idea.status. Only an explicit approval_status='approved' moves
|
||||||
|
// the idea to PLAN_REVIEWED; anything else (rejected, pending, or omitted)
|
||||||
|
// goes to PLAN_REVIEW_FAILED — a human must then decide. The tool never
|
||||||
|
// silently approves.
|
||||||
|
//
|
||||||
|
// Called by the worker as the final step of a review-plan session.
|
||||||
|
|
||||||
|
import { z } from 'zod'
|
||||||
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||||
|
|
||||||
|
import { prisma } from '../prisma.js'
|
||||||
|
import { requireWriteAccess } from '../auth.js'
|
||||||
|
import { userOwnsIdea } from '../access.js'
|
||||||
|
import { toolError, toolJson, withToolErrors } from '../errors.js'
|
||||||
|
|
||||||
|
export const inputSchema = z.object({
|
||||||
|
idea_id: z.string().min(1),
|
||||||
|
review_log: z.object({}).passthrough(), // Full ReviewLog from orchestrator (JSON object)
|
||||||
|
approval_status: z
|
||||||
|
.enum(['pending', 'approved', 'rejected'] as const)
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function handleUpdateIdeaPlanReviewed(
|
||||||
|
{ idea_id, review_log, approval_status }: z.infer<typeof inputSchema>,
|
||||||
|
) {
|
||||||
|
return withToolErrors(async () => {
|
||||||
|
const auth = await requireWriteAccess()
|
||||||
|
if (!(await userOwnsIdea(idea_id, auth.userId))) {
|
||||||
|
return toolError('Idea not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alleen een expliciete 'approved' brengt het idee naar PLAN_REVIEWED.
|
||||||
|
// 'rejected', 'pending' én een weggelaten approval_status betekenen
|
||||||
|
// allemaal "niet auto-goedgekeurd — mens moet beslissen" en gaan naar
|
||||||
|
// PLAN_REVIEW_FAILED. Nooit stilzwijgend goedkeuren (de vorige
|
||||||
|
// `: 'PLAN_REVIEWED'`-default deed dat wel bij pending/undefined).
|
||||||
|
const nextStatus =
|
||||||
|
approval_status === 'approved' ? 'PLAN_REVIEWED' : 'PLAN_REVIEW_FAILED'
|
||||||
|
|
||||||
|
// Log summary metrics from review_log
|
||||||
|
const logSummary = buildReviewLogSummary(review_log)
|
||||||
|
|
||||||
|
const result = await prisma.$transaction([
|
||||||
|
prisma.idea.update({
|
||||||
|
where: { id: idea_id },
|
||||||
|
data: {
|
||||||
|
plan_review_log: review_log as any,
|
||||||
|
reviewed_at: new Date(),
|
||||||
|
status: nextStatus,
|
||||||
|
},
|
||||||
|
select: { id: true, status: true, code: true },
|
||||||
|
}),
|
||||||
|
prisma.ideaLog.create({
|
||||||
|
data: {
|
||||||
|
idea_id,
|
||||||
|
type: 'PLAN_REVIEW_RESULT',
|
||||||
|
content: logSummary.summary,
|
||||||
|
metadata: {
|
||||||
|
approval_status,
|
||||||
|
convergence_status: logSummary.convergence_status,
|
||||||
|
final_score: logSummary.final_score,
|
||||||
|
rounds_completed: logSummary.rounds_completed,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
return toolJson({
|
||||||
|
ok: true,
|
||||||
|
idea: result[0],
|
||||||
|
review_log_summary: logSummary,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerUpdateIdeaPlanReviewedTool(server: McpServer) {
|
||||||
|
server.registerTool(
|
||||||
|
'update_idea_plan_reviewed',
|
||||||
|
{
|
||||||
|
title: 'Mark plan as reviewed',
|
||||||
|
description:
|
||||||
|
'Save review-log after a plan review cycle and transition idea.status. ' +
|
||||||
|
'Only approval_status="approved" → PLAN_REVIEWED; "rejected", "pending", ' +
|
||||||
|
'or an omitted approval_status → PLAN_REVIEW_FAILED (needs manual ' +
|
||||||
|
'approval — never silently approved). Forbidden for demo accounts.',
|
||||||
|
inputSchema,
|
||||||
|
},
|
||||||
|
handleUpdateIdeaPlanReviewed,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReviewLogSummary(
|
||||||
|
reviewLog: Record<string, any>,
|
||||||
|
): {
|
||||||
|
summary: string
|
||||||
|
convergence_status: string
|
||||||
|
final_score: number
|
||||||
|
rounds_completed: number
|
||||||
|
} {
|
||||||
|
const rounds = Array.isArray(reviewLog.rounds) ? reviewLog.rounds : []
|
||||||
|
const convergence = reviewLog.convergence || {}
|
||||||
|
const finalScore =
|
||||||
|
rounds.length > 0 ? rounds[rounds.length - 1].score ?? 0 : 0
|
||||||
|
|
||||||
|
const convergenceStatus =
|
||||||
|
convergence.stable_at_round !== undefined
|
||||||
|
? `stable at round ${convergence.stable_at_round}`
|
||||||
|
: convergence.final_diff_pct !== undefined
|
||||||
|
? `${convergence.final_diff_pct}% diff`
|
||||||
|
: 'pending'
|
||||||
|
|
||||||
|
const summary =
|
||||||
|
`Plan reviewed in ${rounds.length} rounds. ` +
|
||||||
|
`Convergence: ${convergenceStatus}. ` +
|
||||||
|
`Final score: ${finalScore}/100. ` +
|
||||||
|
`Status: ${reviewLog.approval?.status || 'pending'}.`
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary,
|
||||||
|
convergence_status: convergenceStatus,
|
||||||
|
final_score: finalScore,
|
||||||
|
rounds_completed: rounds.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -71,31 +71,57 @@ export async function cleanupWorktreeForTerminalStatus(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Branch-per-story: only remove the worktree if no sibling job in the same
|
// Branch-shared check: bepaal welke siblings dezelfde branch reuse'n.
|
||||||
// story is still active. If siblings are queued/claimed/running they will
|
// - SPRINT pr_strategy → alle TASK_IMPLEMENTATION jobs in dezelfde
|
||||||
// re-use this branch — destroying the worktree now wastes the next claim.
|
// sprint_run delen feat/sprint-<id>.
|
||||||
|
// - STORY pr_strategy / legacy → alle TASK_IMPLEMENTATION jobs in
|
||||||
|
// dezelfde story delen feat/story-<id>.
|
||||||
|
// Bij active siblings: defer cleanup (en in elk geval keepBranch=true)
|
||||||
|
// zodat de volgende claim de branch kan reuse'n.
|
||||||
const job = await prisma.claudeJob.findUnique({
|
const job = await prisma.claudeJob.findUnique({
|
||||||
where: { id: jobId },
|
where: { id: jobId },
|
||||||
select: { task: { select: { story_id: true } } },
|
select: {
|
||||||
|
task: { select: { story_id: true } },
|
||||||
|
sprint_run_id: true,
|
||||||
|
sprint_run: { select: { pr_strategy: true } },
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if (job?.task) {
|
|
||||||
const activeSiblings = await prisma.claudeJob.count({
|
let activeSiblings = 0
|
||||||
|
let scope = ''
|
||||||
|
if (job?.sprint_run && job.sprint_run.pr_strategy === 'SPRINT') {
|
||||||
|
activeSiblings = await prisma.claudeJob.count({
|
||||||
|
where: {
|
||||||
|
sprint_run_id: job.sprint_run_id,
|
||||||
|
status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] },
|
||||||
|
id: { not: jobId },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
scope = `sprint_run ${job.sprint_run_id}`
|
||||||
|
} else if (job?.task) {
|
||||||
|
activeSiblings = await prisma.claudeJob.count({
|
||||||
where: {
|
where: {
|
||||||
task: { story_id: job.task.story_id },
|
task: { story_id: job.task.story_id },
|
||||||
status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] },
|
status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] },
|
||||||
id: { not: jobId },
|
id: { not: jobId },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (activeSiblings > 0) {
|
scope = `story ${job.task.story_id}`
|
||||||
console.log(
|
|
||||||
`[update_job_status] cleanup deferred for job=${jobId}: ${activeSiblings} sibling(s) still active in story ${job.task.story_id}`,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep branch when job is done and a branch was reported (agent pushed)
|
if (activeSiblings > 0) {
|
||||||
const keepBranch = status === 'done' && branch !== undefined
|
console.log(
|
||||||
|
`[update_job_status] cleanup deferred for job=${jobId}: ${activeSiblings} sibling(s) still active in ${scope}`,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep branch when:
|
||||||
|
// - job is done en agent rapporteerde push (branch !== undefined), of
|
||||||
|
// - SPRINT pr_strategy job is skipped — andere stories delen branch.
|
||||||
|
const keepBranch =
|
||||||
|
(status === 'done' && branch !== undefined) ||
|
||||||
|
(status === 'skipped' && job?.sprint_run?.pr_strategy === 'SPRINT')
|
||||||
try {
|
try {
|
||||||
await removeWorktreeForJob({ repoRoot, jobId, keepBranch })
|
await removeWorktreeForJob({ repoRoot, jobId, keepBranch })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -119,9 +145,25 @@ export async function prepareDoneUpdate(
|
||||||
jobId: string,
|
jobId: string,
|
||||||
branch: string | undefined,
|
branch: string | undefined,
|
||||||
): Promise<DoneUpdatePlan> {
|
): Promise<DoneUpdatePlan> {
|
||||||
|
// Resolve branch in deze volgorde:
|
||||||
|
// 1. Expliciete `branch`-arg van Claude (meestal niet meegegeven).
|
||||||
|
// 2. ClaudeJob.branch uit de DB — gezet door attachWorktreeToJob met de
|
||||||
|
// juiste pr_strategy: feat/sprint-<id> voor SPRINT, feat/story-<id>
|
||||||
|
// voor STORY met sibling-reuse.
|
||||||
|
// 3. Legacy fallback feat/job-<8> — alleen voor jobs zonder DB-branch
|
||||||
|
// (zou niet moeten voorkomen na PBI-50).
|
||||||
|
let resolvedBranch = branch
|
||||||
|
if (!resolvedBranch) {
|
||||||
|
const dbJob = await prisma.claudeJob.findUnique({
|
||||||
|
where: { id: jobId },
|
||||||
|
select: { branch: true },
|
||||||
|
})
|
||||||
|
resolvedBranch = dbJob?.branch ?? undefined
|
||||||
|
}
|
||||||
|
const branchName = resolvedBranch ?? `feat/job-${jobId.slice(-8)}`
|
||||||
|
|
||||||
const worktreeDir = getWorktreeRoot()
|
const worktreeDir = getWorktreeRoot()
|
||||||
const worktreePath = path.join(worktreeDir, jobId)
|
const worktreePath = path.join(worktreeDir, jobId)
|
||||||
const branchName = branch ?? `feat/job-${jobId.slice(-8)}`
|
|
||||||
|
|
||||||
const pushResult = await pushBranchForJob({ worktreePath, branchName })
|
const pushResult = await pushBranchForJob({ worktreePath, branchName })
|
||||||
|
|
||||||
|
|
@ -348,6 +390,32 @@ export function resolveNextAction(
|
||||||
return queueCount > 0 ? 'wait_for_job_again' : 'queue_empty'
|
return queueCount > 0 ? 'wait_for_job_again' : 'queue_empty'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type JobTimestampUpdate = {
|
||||||
|
claimed_at?: Date
|
||||||
|
started_at?: Date
|
||||||
|
finished_at?: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bepaalt welke lifecycle-timestamps update_job_status schrijft bij een
|
||||||
|
// status-overgang. Set-once (backfill alleen als nu null) houdt de invariant
|
||||||
|
// claimed_at ≤ started_at ≤ finished_at: een job die CLAIMED → done gaat
|
||||||
|
// zonder `running`-rapport krijgt alsnog een started_at, en claimed_at
|
||||||
|
// (normaal door wait_for_job bij claim gezet) wordt nooit overschreven.
|
||||||
|
export function resolveJobTimestamps(
|
||||||
|
status: 'running' | 'done' | 'failed' | 'skipped',
|
||||||
|
current: { claimed_at: Date | null; started_at: Date | null },
|
||||||
|
now: Date = new Date(),
|
||||||
|
): JobTimestampUpdate {
|
||||||
|
const isTerminal = status === 'done' || status === 'failed' || status === 'skipped'
|
||||||
|
const update: JobTimestampUpdate = {}
|
||||||
|
if (current.claimed_at == null) update.claimed_at = now
|
||||||
|
if (current.started_at == null && (status === 'running' || isTerminal)) {
|
||||||
|
update.started_at = now
|
||||||
|
}
|
||||||
|
if (isTerminal) update.finished_at = now
|
||||||
|
return update
|
||||||
|
}
|
||||||
|
|
||||||
export async function maybeCreateAutoPr(opts: {
|
export async function maybeCreateAutoPr(opts: {
|
||||||
jobId: string
|
jobId: string
|
||||||
productId: string
|
productId: string
|
||||||
|
|
@ -378,24 +446,35 @@ export async function maybeCreateAutoPr(opts: {
|
||||||
where: { id: taskId },
|
where: { id: taskId },
|
||||||
select: {
|
select: {
|
||||||
title: true,
|
title: true,
|
||||||
|
repo_url: true,
|
||||||
story: { select: { id: true, code: true, title: true } },
|
story: { select: { id: true, code: true, title: true } },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (!task) return null
|
if (!task) return null
|
||||||
|
|
||||||
// PBI-46 SPRINT-mode: hergebruik 1 draft-PR voor de hele SprintRun.
|
// Cross-repo sprints: een sprint kan taken hebben die via task.repo_url een
|
||||||
|
// ander repo targeten. PRs en branches zijn per-repo, dus een sibling-PR mag
|
||||||
|
// alleen hergebruikt worden als die sibling hetzelfde repo targette. null/leeg
|
||||||
|
// repo_url = het product-repo; twee taken zitten in dezelfde repo-bucket als
|
||||||
|
// hun (repo_url ?? null) gelijk is.
|
||||||
|
const thisRepoKey = task.repo_url ?? null
|
||||||
|
|
||||||
|
// PBI-46 SPRINT-mode: hergebruik 1 draft-PR voor de hele SprintRun (per repo).
|
||||||
// Mens zet 'm ready-for-review zodra de SprintRun DONE is.
|
// Mens zet 'm ready-for-review zodra de SprintRun DONE is.
|
||||||
if (job?.sprint_run && job.sprint_run.pr_strategy === 'SPRINT') {
|
if (job?.sprint_run && job.sprint_run.pr_strategy === 'SPRINT') {
|
||||||
const sprintSibling = await prisma.claudeJob.findFirst({
|
const sprintSiblings = await prisma.claudeJob.findMany({
|
||||||
where: {
|
where: {
|
||||||
sprint_run_id: job.sprint_run_id,
|
sprint_run_id: job.sprint_run_id,
|
||||||
pr_url: { not: null },
|
pr_url: { not: null },
|
||||||
id: { not: jobId },
|
id: { not: jobId },
|
||||||
},
|
},
|
||||||
select: { pr_url: true },
|
select: { pr_url: true, task: { select: { repo_url: true } } },
|
||||||
orderBy: { created_at: 'asc' },
|
orderBy: { created_at: 'asc' },
|
||||||
})
|
})
|
||||||
if (sprintSibling?.pr_url) return sprintSibling.pr_url
|
const sameRepoSibling = sprintSiblings.find(
|
||||||
|
(s) => (s.task?.repo_url ?? null) === thisRepoKey,
|
||||||
|
)
|
||||||
|
if (sameRepoSibling?.pr_url) return sameRepoSibling.pr_url
|
||||||
|
|
||||||
// Eerste DONE in deze SprintRun → maak draft-PR aan, geen auto-merge.
|
// Eerste DONE in deze SprintRun → maak draft-PR aan, geen auto-merge.
|
||||||
const goal = job.sprint_run.sprint.sprint_goal
|
const goal = job.sprint_run.sprint.sprint_goal
|
||||||
|
|
@ -417,17 +496,21 @@ export async function maybeCreateAutoPr(opts: {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// STORY-mode (default of legacy): branch-per-story, sibling-tasks delen PR.
|
// STORY-mode (default of legacy): branch-per-story, sibling-tasks delen PR
|
||||||
const sibling = await prisma.claudeJob.findFirst({
|
// — maar alleen siblings die hetzelfde repo targeten (zie thisRepoKey).
|
||||||
|
const storySiblings = await prisma.claudeJob.findMany({
|
||||||
where: {
|
where: {
|
||||||
task: { story_id: task.story.id },
|
task: { story_id: task.story.id },
|
||||||
pr_url: { not: null },
|
pr_url: { not: null },
|
||||||
id: { not: jobId },
|
id: { not: jobId },
|
||||||
},
|
},
|
||||||
select: { pr_url: true },
|
select: { pr_url: true, task: { select: { repo_url: true } } },
|
||||||
orderBy: { created_at: 'asc' },
|
orderBy: { created_at: 'asc' },
|
||||||
})
|
})
|
||||||
if (sibling?.pr_url) return sibling.pr_url
|
const sameRepoStorySibling = storySiblings.find(
|
||||||
|
(s) => (s.task?.repo_url ?? null) === thisRepoKey,
|
||||||
|
)
|
||||||
|
if (sameRepoStorySibling?.pr_url) return sameRepoStorySibling.pr_url
|
||||||
|
|
||||||
const storyTitle = task.story.code ? `${task.story.code}: ${task.story.title}` : task.story.title
|
const storyTitle = task.story.code ? `${task.story.code}: ${task.story.title}` : task.story.title
|
||||||
const body = summary
|
const body = summary
|
||||||
|
|
@ -512,6 +595,8 @@ export function registerUpdateJobStatusTool(server: McpServer) {
|
||||||
'Report progress on a claimed ClaudeJob. Allowed transitions from CLAIMED/RUNNING: ' +
|
'Report progress on a claimed ClaudeJob. Allowed transitions from CLAIMED/RUNNING: ' +
|
||||||
'running (start), done (finished), failed (error), skipped (no-op exit). ' +
|
'running (start), done (finished), failed (error), skipped (no-op exit). ' +
|
||||||
'The Bearer token must match the token that claimed the job. ' +
|
'The Bearer token must match the token that claimed the job. ' +
|
||||||
|
'Stamps started_at on running and finished_at on done/failed/skipped, and backfills ' +
|
||||||
|
'claimed_at/started_at when missing so claimed_at ≤ started_at ≤ finished_at always holds. ' +
|
||||||
'Before marking done: call verify_task_against_plan first — done is rejected when ' +
|
'Before marking done: call verify_task_against_plan first — done is rejected when ' +
|
||||||
'verify_result is null, EMPTY (unless task.verify_only is true), or when the verify level ' +
|
'verify_result is null, EMPTY (unless task.verify_only is true), or when the verify level ' +
|
||||||
'doesn’t meet task.verify_required: ALIGNED-only is strict; ALIGNED_OR_PARTIAL accepts ' +
|
'doesn’t meet task.verify_required: ALIGNED-only is strict; ALIGNED_OR_PARTIAL accepts ' +
|
||||||
|
|
@ -551,6 +636,8 @@ export function registerUpdateJobStatusTool(server: McpServer) {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
status: true,
|
status: true,
|
||||||
|
claimed_at: true,
|
||||||
|
started_at: true,
|
||||||
claimed_by_token_id: true,
|
claimed_by_token_id: true,
|
||||||
user_id: true,
|
user_id: true,
|
||||||
product_id: true,
|
product_id: true,
|
||||||
|
|
@ -694,10 +781,11 @@ export function registerUpdateJobStatusTool(server: McpServer) {
|
||||||
where: { id: job_id },
|
where: { id: job_id },
|
||||||
data: {
|
data: {
|
||||||
status: dbStatus,
|
status: dbStatus,
|
||||||
...(actualStatus === 'running' ? { started_at: now } : {}),
|
...resolveJobTimestamps(
|
||||||
...(actualStatus === 'done' || actualStatus === 'failed' || actualStatus === 'skipped'
|
actualStatus,
|
||||||
? { finished_at: now }
|
{ claimed_at: job.claimed_at, started_at: job.started_at },
|
||||||
: {}),
|
now,
|
||||||
|
),
|
||||||
...(branchToWrite !== undefined ? { branch: branchToWrite } : {}),
|
...(branchToWrite !== undefined ? { branch: branchToWrite } : {}),
|
||||||
...(pushedAt !== undefined ? { pushed_at: pushedAt } : {}),
|
...(pushedAt !== undefined ? { pushed_at: pushedAt } : {}),
|
||||||
...(summary !== undefined ? { summary } : {}),
|
...(summary !== undefined ? { summary } : {}),
|
||||||
|
|
|
||||||
102
src/tools/update-sprint.ts
Normal file
102
src/tools/update-sprint.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
// MCP tool: update een Sprint.
|
||||||
|
//
|
||||||
|
// Generieke update — wijzigt elke combinatie van status, sprint_goal,
|
||||||
|
// start_date en end_date. Géén state-machine validatie (zie
|
||||||
|
// docs/plans/sprint-mcp-tools.md): last-write-wins, het resubmit/heropen-pad
|
||||||
|
// zit elders. Bij status → CLOSED/FAILED/ARCHIVED zonder expliciete end_date
|
||||||
|
// wordt end_date automatisch op vandaag gezet. Bij status → CLOSED wordt
|
||||||
|
// daarnaast `completed_at` op now() gezet (parity met
|
||||||
|
// src/lib/tasks-status-update.ts dat hetzelfde doet bij auto-close via
|
||||||
|
// task-status-cascade; zo houden reporting en UI één bron van waarheid voor
|
||||||
|
// completion-tijd).
|
||||||
|
|
||||||
|
import { z } from 'zod'
|
||||||
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||||
|
import type { SprintStatus } from '@prisma/client'
|
||||||
|
import { prisma } from '../prisma.js'
|
||||||
|
import { requireWriteAccess } from '../auth.js'
|
||||||
|
import { userCanAccessProduct } from '../access.js'
|
||||||
|
import { toolError, toolJson, withToolErrors } from '../errors.js'
|
||||||
|
|
||||||
|
const TERMINAL_STATUSES = new Set<SprintStatus>(['CLOSED', 'FAILED', 'ARCHIVED'])
|
||||||
|
|
||||||
|
export const inputSchema = z.object({
|
||||||
|
sprint_id: z.string().min(1),
|
||||||
|
status: z.enum(['OPEN', 'CLOSED', 'ARCHIVED', 'FAILED']).optional(),
|
||||||
|
sprint_goal: z.string().min(1).max(500).optional(),
|
||||||
|
end_date: z.string().date().optional(),
|
||||||
|
start_date: z.string().date().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function handleUpdateSprint(
|
||||||
|
{ sprint_id, status, sprint_goal, end_date, start_date }: z.infer<typeof inputSchema>,
|
||||||
|
) {
|
||||||
|
return withToolErrors(async () => {
|
||||||
|
if (
|
||||||
|
status === undefined &&
|
||||||
|
sprint_goal === undefined &&
|
||||||
|
end_date === undefined &&
|
||||||
|
start_date === undefined
|
||||||
|
) {
|
||||||
|
return toolError('Minstens één veld vereist om te wijzigen')
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = await requireWriteAccess()
|
||||||
|
|
||||||
|
const sprint = await prisma.sprint.findUnique({
|
||||||
|
where: { id: sprint_id },
|
||||||
|
select: { id: true, product_id: true },
|
||||||
|
})
|
||||||
|
if (!sprint) {
|
||||||
|
return toolError(`Sprint ${sprint_id} not found`)
|
||||||
|
}
|
||||||
|
if (!(await userCanAccessProduct(sprint.product_id, auth.userId))) {
|
||||||
|
return toolError(`Sprint ${sprint_id} not accessible`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: {
|
||||||
|
status?: SprintStatus
|
||||||
|
sprint_goal?: string
|
||||||
|
start_date?: Date
|
||||||
|
end_date?: Date
|
||||||
|
completed_at?: Date
|
||||||
|
} = {}
|
||||||
|
if (status !== undefined) data.status = status
|
||||||
|
if (sprint_goal !== undefined) data.sprint_goal = sprint_goal
|
||||||
|
if (start_date !== undefined) data.start_date = new Date(start_date)
|
||||||
|
if (end_date !== undefined) {
|
||||||
|
data.end_date = new Date(end_date)
|
||||||
|
} else if (status !== undefined && TERMINAL_STATUSES.has(status)) {
|
||||||
|
data.end_date = new Date()
|
||||||
|
}
|
||||||
|
if (status === 'CLOSED') data.completed_at = new Date()
|
||||||
|
|
||||||
|
const updated = await prisma.sprint.update({
|
||||||
|
where: { id: sprint_id },
|
||||||
|
data,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
sprint_goal: true,
|
||||||
|
status: true,
|
||||||
|
start_date: true,
|
||||||
|
end_date: true,
|
||||||
|
completed_at: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return toolJson(updated)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerUpdateSprintTool(server: McpServer) {
|
||||||
|
server.registerTool(
|
||||||
|
'update_sprint',
|
||||||
|
{
|
||||||
|
title: 'Update Sprint',
|
||||||
|
description:
|
||||||
|
'Update a sprint: status, sprint_goal, start_date and/or end_date. At least one field required. No state-machine validation — last-write-wins. When status goes to CLOSED/FAILED/ARCHIVED and end_date is not provided, end_date is set to today. When status goes to CLOSED, completed_at is set to now (parity with auto-close via task-cascade). Forbidden for demo accounts.',
|
||||||
|
inputSchema,
|
||||||
|
},
|
||||||
|
handleUpdateSprint,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -202,12 +202,18 @@ export async function attachWorktreeToJob(
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`[attachWorktreeToJob] failed to resolve base_sha for ${jobId}:`, err)
|
console.warn(`[attachWorktreeToJob] failed to resolve base_sha for ${jobId}:`, err)
|
||||||
}
|
}
|
||||||
if (baseSha) {
|
// Persist branch + base_sha. update_job_status (prepareDoneUpdate)
|
||||||
await prisma.claudeJob.update({
|
// leest claudeJob.branch om naar de juiste ref te pushen — zonder deze
|
||||||
where: { id: jobId },
|
// update valt 'ie terug op het legacy `feat/job-<8>` patroon en faalt
|
||||||
data: { base_sha: baseSha },
|
// de push met "src refspec ... does not match any" voor sprint/story
|
||||||
})
|
// strategy branches.
|
||||||
}
|
await prisma.claudeJob.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: {
|
||||||
|
branch: actualBranch,
|
||||||
|
...(baseSha ? { base_sha: baseSha } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
return { worktree_path: worktreePath, branch_name: actualBranch, reused_branch: reused }
|
return { worktree_path: worktreePath, branch_name: actualBranch, reused_branch: reused }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -446,7 +452,7 @@ export async function tryClaimJob(
|
||||||
return rows.length > 0 ? rows[0].id : null
|
return rows.length > 0 ? rows[0].id : null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getFullJobContext(jobId: string) {
|
export async function getFullJobContext(jobId: string) {
|
||||||
const job = await prisma.claudeJob.findUnique({
|
const job = await prisma.claudeJob.findUnique({
|
||||||
where: { id: jobId },
|
where: { id: jobId },
|
||||||
include: {
|
include: {
|
||||||
|
|
@ -502,10 +508,10 @@ async function getFullJobContext(jobId: string) {
|
||||||
|
|
||||||
// M12: branch on kind. Idea-jobs hebben geen task/story/pbi/sprint; ze
|
// M12: branch on kind. Idea-jobs hebben geen task/story/pbi/sprint; ze
|
||||||
// hebben in plaats daarvan idea + embedded prompt_text.
|
// hebben in plaats daarvan idea + embedded prompt_text.
|
||||||
if (job.kind === 'IDEA_GRILL' || job.kind === 'IDEA_MAKE_PLAN') {
|
if (job.kind === 'IDEA_GRILL' || job.kind === 'IDEA_MAKE_PLAN' || job.kind === 'IDEA_REVIEW_PLAN') {
|
||||||
if (!job.idea) return null
|
if (!job.idea) return null
|
||||||
const { idea } = job
|
const { idea } = job
|
||||||
const { getIdeaPromptText } = await import('../lib/idea-prompts.js')
|
const { getIdeaPromptText } = await import('../lib/kind-prompts.js')
|
||||||
|
|
||||||
// Setup persistent product-worktrees for this idea-job (PBI-9).
|
// Setup persistent product-worktrees for this idea-job (PBI-9).
|
||||||
// Primary product is gated by repo_url via resolveRepoRoot returning null.
|
// Primary product is gated by repo_url via resolveRepoRoot returning null.
|
||||||
|
|
@ -563,7 +569,11 @@ async function getFullJobContext(jobId: string) {
|
||||||
pbi: idea.pbi,
|
pbi: idea.pbi,
|
||||||
repo_url: job.product.repo_url,
|
repo_url: job.product.repo_url,
|
||||||
prompt_text: getIdeaPromptText(job.kind),
|
prompt_text: getIdeaPromptText(job.kind),
|
||||||
branch_suggestion: `feat/idea-${idea.code.toLowerCase()}-${job.kind === 'IDEA_GRILL' ? 'grill' : 'plan'}`,
|
branch_suggestion: `feat/idea-${idea.code.toLowerCase()}-${(() => {
|
||||||
|
if (job.kind === 'IDEA_GRILL') return 'grill'
|
||||||
|
if (job.kind === 'IDEA_REVIEW_PLAN') return 'review'
|
||||||
|
return 'plan'
|
||||||
|
})()}`,
|
||||||
product_worktrees: worktrees.map((w) => ({
|
product_worktrees: worktrees.map((w) => ({
|
||||||
product_id: w.productId,
|
product_id: w.productId,
|
||||||
worktree_path: w.worktreePath,
|
worktree_path: w.worktreePath,
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ function extractPlanPaths(plan: string): string[] {
|
||||||
let m: RegExpExecArray | null
|
let m: RegExpExecArray | null
|
||||||
while ((m = backtickRe.exec(plan)) !== null) {
|
while ((m = backtickRe.exec(plan)) !== null) {
|
||||||
const p = m[1].trim()
|
const p = m[1].trim()
|
||||||
if ((p.includes('/') || p.includes('.')) && !p.includes(' ') && p.length > 3) paths.add(p)
|
if (looksLikePath(p)) paths.add(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
const bulletRe = /^[-*]\s+\*{0,2}([^\s*][^\s]*)\.([a-zA-Z]{1,6})\*{0,2}\s*[:\n]/gm
|
const bulletRe = /^[-*]\s+\*{0,2}([^\s*][^\s]*)\.([a-zA-Z]{1,6})\*{0,2}\s*[:\n]/gm
|
||||||
|
|
@ -38,6 +38,20 @@ function extractPlanPaths(plan: string): string[] {
|
||||||
return [...paths]
|
return [...paths]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Heuristic: does this backtick-quoted token look like a file path?
|
||||||
|
// Excludes code-snippets like `data-debug-label="..."`, `foo()`, `<div>` —
|
||||||
|
// anything containing operator/quote/bracket chars or an ellipsis is rejected.
|
||||||
|
// Accepts paths with a slash (multi-segment) or a recognisable file-extension
|
||||||
|
// suffix (1–6 alphanumeric chars after a final dot, e.g. `.tsx`, `.json`).
|
||||||
|
function looksLikePath(p: string): boolean {
|
||||||
|
if (p.length <= 3) return false
|
||||||
|
if (p.includes(' ')) return false
|
||||||
|
if (/[="'<>()[\]{};,]/.test(p)) return false
|
||||||
|
if (/\.{2,}/.test(p)) return false
|
||||||
|
if (!p.includes('/') && !/\.[a-zA-Z][a-zA-Z0-9]{0,5}$/.test(p)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// Path match: exact or suffix match so "classify.ts" matches "src/verify/classify.ts".
|
// Path match: exact or suffix match so "classify.ts" matches "src/verify/classify.ts".
|
||||||
function pathMatches(planPath: string, diffPaths: string[]): boolean {
|
function pathMatches(planPath: string, diffPaths: string[]): boolean {
|
||||||
const norm = planPath.replace(/\\/g, '/')
|
const norm = planPath.replace(/\\/g, '/')
|
||||||
|
|
|
||||||
2
vendor/scrum4me
vendored
2
vendor/scrum4me
vendored
|
|
@ -1 +1 @@
|
||||||
Subproject commit 77617e89ac830bc4a86fa7d41f16a5122a1d9689
|
Subproject commit 7bb252c528d810584bcb46a56cff3d26ebf392ff
|
||||||
Loading…
Add table
Add a link
Reference in a new issue