Compare commits

...
Sign in to create a new pull request.

3 commits

Author SHA1 Message Date
Scrum4Me Agent
26cbacc27d docs(ST-2): agent merge-policy sectie in AGENTS.md, CLAUDE.md en runbook
Documenteert dat de agent na gh pr create altijd set_pbi_pr aanroept
en daarna stopt — nooit gh pr merge. Tabel in AGENTS.md en de
agent-batch flow in branch-and-commit.md bijgewerkt met de nieuwe stap.
Gate-foutmelding (open_pr_url) wordt aan gebruiker getoond, niet geretry'd.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 17:21:29 +02:00
Scrum4Me Agent
2093a7788e feat(ST-2): batch-enqueue gate blokkeert nieuwe PBI bij open PR
Voegt een PBI-gate toe aan enqueueClaudeJobsBatchAction: als er een PBI
met een open PR (pr_url gezet, pr_merged_at null) bestaat, worden taken
van een andere PBI geweigerd met een foutmelding die de PR-URL bevat.
Taken van dezelfde PBI als de open PR mogen wel worden ge-enqueued.

Tests: geen open PR, zelfde PBI, andere PBI geblokkeerd, pr_merged_at
gezet onblokkeert de gate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 17:18:08 +02:00
Scrum4Me Agent
834a71e47f ST-migvir40: Schema: Pbi.pr_url + pr_merged_at + Prisma-migratie
Voeg twee optionele velden toe aan model Pbi voor het opslaan van de
PR-link en merge-status. Migratie 20260503145506_add_pbi_pr_link past
de database aan; Prisma-client is hergenereerd.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 16:57:14 +02:00
8 changed files with 115 additions and 9 deletions

View file

@ -18,6 +18,28 @@ For Claude Code specifically, CLAUDE.md is loaded automatically. Start there.
|---|---|---| |---|---|---|
| Start run | `git checkout -b feat/<batch-slug>` | `gh pr create` | | Start run | `git checkout -b feat/<batch-slug>` | `gh pr create` |
| Na elke taak | `git add -A && git commit -m "<type>(ST-XXX): <title>"` | `git push` | | Na elke taak | `git add -A && git commit -m "<type>(ST-XXX): <title>"` | `git push` |
| Queue leeg | `git push -u origin <branch>` + `gh pr create` | — | | Queue leeg | `git push -u origin <branch>` + `gh pr create` | `gh pr merge` |
| Na PR-create | `mcp__scrum4me__set_pbi_pr(pbi_id, pr_url)`**STOP** | idem |
Full details: [docs/runbooks/branch-and-commit.md § Agent-batch flow](./docs/runbooks/branch-and-commit.md) Full details: [docs/runbooks/branch-and-commit.md § Agent-batch flow](./docs/runbooks/branch-and-commit.md)
---
## PR-merge & PBI-volgorde
**De agent mergt nooit zelf een PR.** Na `gh pr create`:
1. Roep direct `mcp__scrum4me__set_pbi_pr(pbi_id, pr_url)` aan zodat de gate weet dat deze PBI in review zit.
2. Stop — wacht op handmatige merge door de PB-owner.
3. Als de gebruiker daarna een nieuwe batch voor een andere PBI start en `enqueueClaudeJobsBatchAction` de error `open_pr_url` teruggeeft: toon de foutmelding aan de gebruiker en stop. Niet opnieuw proberen; de gebruiker moet eerst de open PR mergen.
**Verboden:**
- `gh pr merge` (ook niet met `--auto`, `--squash`, of `--rebase`)
- Een PR sluiten en herforken om de gate te omzeilen
- Opnieuw enqueuen na een gate-weigering zonder gebruikersbevestiging
**PR-body** bevat minimaal:
- Lijst van geïmplementeerde taak-titels (één per regel)
- Link naar de PBI (`pbi_id` of de Scrum4Me-URL)
---

View file

@ -36,7 +36,7 @@ Desktop-first Scrum-app voor solo developers en kleine teams. Hiërarchie: produ
5. Verifieer: `npm run lint && npm test && npm run build` 5. Verifieer: `npm run lint && npm test && npm run build`
6. Commit per laag: `git add -A && git commit`**geen** `git push` — zie [docs/runbooks/branch-and-commit.md](./docs/runbooks/branch-and-commit.md) 6. Commit per laag: `git add -A && git commit`**geen** `git push` — zie [docs/runbooks/branch-and-commit.md](./docs/runbooks/branch-and-commit.md)
7. Herhaal stap 26 per story; branch blijft dezelfde 7. Herhaal stap 26 per story; branch blijft dezelfde
8. Queue leeg → `git push -u origin <branch>` + `gh pr create` 8. Queue leeg → `git push -u origin <branch>` + `gh pr create` (body: taak-titels + PBI-link) → roep `mcp__scrum4me__set_pbi_pr(pbi_id, pr_url)` aan → **STOP** (nooit `gh pr merge`)
**Track B — manueel:** **Track B — manueel:**
1. Lees taak in `docs/backlog/index.md` 1. Lees taak in `docs/backlog/index.md`

View file

@ -9,6 +9,7 @@ const {
mockGetSession, mockGetSession,
mockFindFirstProduct, mockFindFirstProduct,
mockFindFirstSprint, mockFindFirstSprint,
mockFindFirstPbi,
mockFindManyTask, mockFindManyTask,
mockTransaction, mockTransaction,
mockExecuteRaw, mockExecuteRaw,
@ -16,6 +17,7 @@ const {
mockGetSession: vi.fn(), mockGetSession: vi.fn(),
mockFindFirstProduct: vi.fn(), mockFindFirstProduct: vi.fn(),
mockFindFirstSprint: vi.fn(), mockFindFirstSprint: vi.fn(),
mockFindFirstPbi: vi.fn(),
mockFindManyTask: vi.fn(), mockFindManyTask: vi.fn(),
mockTransaction: vi.fn(), mockTransaction: vi.fn(),
mockExecuteRaw: vi.fn().mockResolvedValue(undefined), mockExecuteRaw: vi.fn().mockResolvedValue(undefined),
@ -28,6 +30,7 @@ vi.mock('@/lib/prisma', () => ({
task: { findMany: mockFindManyTask }, task: { findMany: mockFindManyTask },
product: { findFirst: mockFindFirstProduct }, product: { findFirst: mockFindFirstProduct },
sprint: { findFirst: mockFindFirstSprint }, sprint: { findFirst: mockFindFirstSprint },
pbi: { findFirst: mockFindFirstPbi },
claudeJob: { create: vi.fn() }, claudeJob: { create: vi.fn() },
$executeRaw: mockExecuteRaw, $executeRaw: mockExecuteRaw,
$transaction: mockTransaction, $transaction: mockTransaction,
@ -66,8 +69,9 @@ const makePbi2Task = (id: string, status = 'TO_DO', pbiStatus = 'READY') => ({
}, },
}) })
const makeBatchTask = (id: string, hasActiveJob = false) => ({ const makeBatchTask = (id: string, hasActiveJob = false, pbiId = 'pbi-1') => ({
id, id,
story: { pbi_id: pbiId },
claude_jobs: hasActiveJob ? [{ id: 'job-active' }] : [], claude_jobs: hasActiveJob ? [{ id: 'job-active' }] : [],
}) })
@ -85,6 +89,7 @@ beforeEach(() => {
mockGetSession.mockResolvedValue(SESSION_USER) mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
mockFindFirstSprint.mockResolvedValue({ id: SPRINT_ID }) mockFindFirstSprint.mockResolvedValue({ id: SPRINT_ID })
mockFindFirstPbi.mockResolvedValue(null) // geen open PR by default
}) })
// ============================================================= // =============================================================
@ -229,4 +234,55 @@ describe('enqueueClaudeJobsBatchAction — 2 PBI scenario', () => {
expect(result).toMatchObject({ error: expect.stringContaining('demo') }) expect(result).toMatchObject({ error: expect.stringContaining('demo') })
expect(mockTransaction).not.toHaveBeenCalled() expect(mockTransaction).not.toHaveBeenCalled()
}) })
it('geen open PR → enqueue OK (gate niet actief)', async () => {
mockFindFirstPbi.mockResolvedValue(null)
mockFindManyTask.mockResolvedValue([makeBatchTask('pbi1-t1'), makeBatchTask('pbi2-t1', false, 'pbi-2')])
mockTransaction.mockResolvedValue([
{ id: 'job-a', task_id: 'pbi1-t1' },
{ id: 'job-b', task_id: 'pbi2-t1' },
])
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['pbi1-t1', 'pbi2-t1'])
expect(result).toEqual({ success: true, count: 2 })
expect(mockTransaction).toHaveBeenCalled()
})
it('open PR op PBI-A, input bevat alleen PBI-A taken → OK', async () => {
mockFindFirstPbi.mockResolvedValue({ id: 'pbi-1', pr_url: 'https://github.com/org/repo/pull/42' })
mockFindManyTask.mockResolvedValue([makeBatchTask('pbi1-t1', false, 'pbi-1'), makeBatchTask('pbi1-t2', false, 'pbi-1')])
mockTransaction.mockResolvedValue([
{ id: 'job-a', task_id: 'pbi1-t1' },
{ id: 'job-b', task_id: 'pbi1-t2' },
])
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['pbi1-t1', 'pbi1-t2'])
expect(result).toEqual({ success: true, count: 2 })
expect(mockTransaction).toHaveBeenCalled()
})
it('open PR op PBI-A, input bevat PBI-B taken → error met PR-URL', async () => {
const PR_URL = 'https://github.com/org/repo/pull/42'
mockFindFirstPbi.mockResolvedValue({ id: 'pbi-1', pr_url: PR_URL })
mockFindManyTask.mockResolvedValue([makeBatchTask('pbi2-t1', false, 'pbi-2')])
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['pbi2-t1'])
expect(result).toMatchObject({ error: expect.stringContaining(PR_URL), open_pr_url: PR_URL, open_pbi_id: 'pbi-1' })
expect(mockTransaction).not.toHaveBeenCalled()
})
it('PBI-A pr_merged_at gezet → gate retourneert null, PBI-B mag', async () => {
// pr_merged_at != null → findFirst returnt null (where-clause filtert het uit)
mockFindFirstPbi.mockResolvedValue(null)
mockFindManyTask.mockResolvedValue([makeBatchTask('pbi2-t1', false, 'pbi-2')])
mockTransaction.mockResolvedValue([{ id: 'job-a', task_id: 'pbi2-t1' }])
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['pbi2-t1'])
expect(result).toEqual({ success: true, count: 1 })
expect(mockTransaction).toHaveBeenCalled()
})
}) })

View file

@ -35,6 +35,7 @@ vi.mock('@/lib/prisma', () => ({
task: { findFirst: mockFindFirstTask, findMany: mockFindManyTask }, task: { findFirst: mockFindFirstTask, findMany: mockFindManyTask },
product: { findFirst: mockFindFirstProduct }, product: { findFirst: mockFindFirstProduct },
sprint: { findFirst: mockFindFirstSprint }, sprint: { findFirst: mockFindFirstSprint },
pbi: { findFirst: vi.fn().mockResolvedValue(null) },
claudeJob: { claudeJob: {
findFirst: mockFindFirstJob, findFirst: mockFindFirstJob,
create: mockCreateJob, create: mockCreateJob,
@ -298,6 +299,7 @@ describe('previewEnqueueAllAction', () => {
const makeBatchTask = (id: string, hasActiveJob = false) => ({ const makeBatchTask = (id: string, hasActiveJob = false) => ({
id, id,
story: { pbi_id: 'pbi-1' },
claude_jobs: hasActiveJob ? [{ id: 'job-active' }] : [], claude_jobs: hasActiveJob ? [{ id: 'job-active' }] : [],
}) })

View file

@ -12,7 +12,7 @@ type EnqueueResult =
type EnqueueAllResult = type EnqueueAllResult =
| { success: true; count: number } | { success: true; count: number }
| { error: string } | { error: string; open_pr_url?: string; open_pbi_id?: string }
type CancelResult = { success: true } | { error: string } type CancelResult = { success: true } | { error: string }
@ -249,6 +249,7 @@ export async function enqueueClaudeJobsBatchAction(
}, },
select: { select: {
id: true, id: true,
story: { select: { pbi_id: true } },
claude_jobs: { claude_jobs: {
where: { status: { in: ACTIVE_JOB_STATUSES } }, where: { status: { in: ACTIVE_JOB_STATUSES } },
select: { id: true }, select: { id: true },
@ -260,6 +261,23 @@ export async function enqueueClaudeJobsBatchAction(
return { error: 'Een of meer taken zijn niet toegankelijk voor deze gebruiker' } return { error: 'Een of meer taken zijn niet toegankelijk voor deze gebruiker' }
} }
// Gate: blokkeer taken van een nieuwe PBI zolang een andere PBI een open PR heeft
const openPrPbi = await prisma.pbi.findFirst({
where: { product_id: productId, pr_url: { not: null }, pr_merged_at: null },
select: { id: true, pr_url: true },
})
if (openPrPbi) {
const hasTaskFromOtherPbi = authorizedTasks.some(t => t.story.pbi_id !== openPrPbi.id)
if (hasTaskFromOtherPbi) {
return {
error: `Vorige PBI heeft een open PR. Merge eerst PR ${openPrPbi.pr_url} voordat je een nieuwe PBI start.`,
open_pr_url: openPrPbi.pr_url!,
open_pbi_id: openPrPbi.id,
}
}
}
const queueable = authorizedTasks.filter(t => t.claude_jobs.length === 0) const queueable = authorizedTasks.filter(t => t.claude_jobs.length === 0)
if (queueable.length === 0) return { success: true, count: 0 } if (queueable.length === 0) return { success: true, count: 0 }

View file

@ -53,11 +53,14 @@ Wanneer de NAS-agent (`/opt/agent/`) een batch jobs uitvoert:
|---|---|---| |---|---|---|
| Start run | `git checkout -b feat/<batch-slug>` lokaal | `gh pr create` | | Start run | `git checkout -b feat/<batch-slug>` lokaal | `gh pr create` |
| Na elke taak | `git add -A && git commit -m "<type>(ST-XXX): <title>"` | `git push` | | Na elke taak | `git add -A && git commit -m "<type>(ST-XXX): <title>"` | `git push` |
| Queue leeg | `git push -u origin <branch>` + `gh pr create` | — | | Queue leeg | `git push -u origin <branch>` + `gh pr create` | `gh pr merge` |
| Na PR-create | `mcp__scrum4me__set_pbi_pr(pbi_id, pr_url)`**STOP** | idem |
- Alle commits accumuleren op dezelfde branch — lopende state blijft op disk tot de run klaar is. - Alle commits accumuleren op dezelfde branch — lopende state blijft op disk tot de run klaar is.
- Één PR per batch → één Vercel preview-deployment. - Één PR per batch → één Vercel preview-deployment.
- Single-task batch (1 job in queue): dezelfde flow — 1 commit → push + PR. - Single-task batch (1 job in queue): dezelfde flow — 1 commit → push + PR.
- **De agent mergt nooit.** Na PR-create: `set_pbi_pr` aanroepen en stoppen. Wacht op handmatige merge door de PB-owner.
- Als een volgende batch wordt geblokkeerd door de PBI-gate (open PR): toon de foutmelding (`open_pr_url`) aan de gebruiker. Niet opnieuw proberen.
#### End-to-end verificatie: 1 batch = 1 Vercel-deploy #### End-to-end verificatie: 1 batch = 1 Vercel-deploy

View file

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "pbis" ADD COLUMN "pr_merged_at" TIMESTAMP(3),
ADD COLUMN "pr_url" TEXT;

View file

@ -166,6 +166,8 @@ model Pbi {
priority Int priority Int
sort_order Float sort_order Float
status PbiStatus @default(READY) status PbiStatus @default(READY)
pr_url String?
pr_merged_at DateTime?
created_at DateTime @default(now()) created_at DateTime @default(now())
updated_at DateTime @updatedAt updated_at DateTime @updatedAt
stories Story[] stories Story[]