Merge pull request #5 from madhura68/feat/job-slv6i9xm

M13: Veilige Claude-agent-workflow (MCP-server-side)
This commit is contained in:
Janpeter Visser 2026-05-01 13:52:46 +02:00 committed by GitHub
commit 994f28f103
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 2108 additions and 74 deletions

61
CLAUDE.md Normal file
View file

@ -0,0 +1,61 @@
# CLAUDE.md — scrum4me-mcp
MCP server that exposes the Scrum4Me dev-flow as native tools for Claude Code.
## Agent worktree-flow
`wait_for_job` creates an isolated git worktree per job so agent changes never touch the user's main checkout.
### How it works
1. On successful claim, `wait_for_job` calls `createWorktreeForJob`:
- Worktree directory: `SCRUM4ME_AGENT_WORKTREE_DIR/<job-id>` (default: `~/.scrum4me-agent-worktrees/<job-id>`)
- Branch: `feat/job-<last-8-chars-of-job-id>` (timestamp-suffixed if branch already exists)
- Base: `origin/main`
2. Tool response includes `worktree_path` and `branch_name`.
3. **Work exclusively in `worktree_path`** — all file edits and commits go there.
4. On `update_job_status(done|failed)`, `removeWorktreeForJob` runs automatically:
- `keepBranch=true` if `done` and a `branch` was reported (agent pushed)
- `keepBranch=false` otherwise (branch deleted with worktree)
### Required configuration
Set env var per product:
```
SCRUM4ME_REPO_ROOT_<productId>=/absolute/path/to/local/clone
```
Or add to `~/.scrum4me-agent-config.json`:
```json
{
"repoRoots": {
"<productId>": "/absolute/path/to/local/clone"
}
}
```
If no repo root is found, `wait_for_job` rolls the claim back to QUEUED and returns an error.
## Manual worktree cleanup
Run `cleanup_my_worktrees` (no arguments) to scan `~/.scrum4me-agent-worktrees/` and remove worktrees for jobs that are in a terminal state (DONE, FAILED, CANCELLED). Worktrees for active jobs (QUEUED, CLAIMED, RUNNING) are left untouched. Returns `{ removed, kept, skipped }`.
## Key source files
| File | Purpose |
|---|---|
| `src/git/worktree.ts` | `createWorktreeForJob` + `removeWorktreeForJob` |
| `src/tools/wait-for-job.ts` | `resolveRepoRoot`, `rollbackClaim`, `attachWorktreeToJob` |
| `src/tools/update-job-status.ts` | `cleanupWorktreeForTerminalStatus` |
| `src/tools/cleanup-my-worktrees.ts` | `cleanup_my_worktrees` tool — scans + removes stale worktrees |
## Testing
```bash
npm test # vitest run
npm run typecheck # tsc --noEmit
```
All worktree helpers have unit tests under `__tests__/git/worktree.test.ts`, `__tests__/wait-for-job-worktree.test.ts`, and `__tests__/update-job-status-worktree.test.ts`.

View file

@ -25,8 +25,8 @@ activity and create todos via native tool calls instead of curl.
| `get_question_answer` | Fetch the current status + answer of a previously-asked question | n/a | | `get_question_answer` | Fetch the current status + answer of a previously-asked question | n/a |
| `list_open_questions` | List own open/answered questions, most recent first (max 50) | n/a | | `list_open_questions` | List own open/answered questions, most recent first (max 50) | n/a |
| `cancel_question` | Cancel an own open question (asker-only) | no | | `cancel_question` | Cancel an own open question (asker-only) | no |
| `wait_for_job` | Block until a QUEUED ClaudeJob is available, claim it atomically, return full task context with frozen `plan_snapshot` | no | | `wait_for_job` | Block until a QUEUED ClaudeJob is available, claim it atomically, return full task context with frozen `plan_snapshot`, `worktree_path`, and `branch_name` | no |
| `update_job_status` | Report job transition to `running`, `done`, or `failed`; triggers SSE event to UI | no | | `update_job_status` | Report job transition to `running`, `done`, or `failed`; triggers SSE event to UI; cleans up worktree on terminal transitions | no |
| `verify_task_against_plan` | Compare frozen `plan_snapshot` against current plan + story logs + commits; returns per-AC ✓/✗/? heuristic and drift-score | yes (read-only) | | `verify_task_against_plan` | Compare frozen `plan_snapshot` against current plan + story logs + commits; returns per-AC ✓/✗/? heuristic and drift-score | yes (read-only) |
Demo accounts may read but writes return `PERMISSION_DENIED`. Demo accounts may read but writes return `PERMISSION_DENIED`.
@ -116,6 +116,46 @@ Add to `~/.claude/mcp_servers.json`:
Restart Claude Code. The 9 tools and 1 prompt show up under the Restart Claude Code. The 9 tools and 1 prompt show up under the
`scrum4me` namespace. `scrum4me` namespace.
## Agent worktree-flow
When a job is claimed via `wait_for_job`, the MCP server automatically creates an isolated git worktree for the job under `~/.scrum4me-agent-worktrees/<job-id>/` with a dedicated branch `feat/job-<suffix>`. The tool response includes:
- `worktree_path` — absolute path to the worktree directory
- `branch_name` — the branch checked out in that worktree
**The agent must work exclusively inside `worktree_path`**. All file edits and commits belong there; the user's main checkout stays clean.
When `update_job_status` is called with `done` or `failed`, the worktree is automatically removed. If the agent reported a `branch` (indicating a push), the local branch is preserved on `done`; otherwise it is deleted together with the worktree directory.
### Required env vars
| Variable | Purpose |
|---|---|
| `SCRUM4ME_AGENT_WORKTREE_DIR` | Override the default worktree parent directory (default: `~/.scrum4me-agent-worktrees`) |
| `SCRUM4ME_REPO_ROOT_<productId>` | Absolute path to the local git clone for that product, e.g. `SCRUM4ME_REPO_ROOT_cmohrysyj0000rd17clnjy4tc=/home/user/projects/scrum4me` |
Alternatively, configure repo roots in `~/.scrum4me-agent-config.json`:
```json
{
"repoRoots": {
"<productId>": "/home/user/projects/scrum4me"
}
}
```
If no repo root is configured for the product, `wait_for_job` rolls back the claim to `QUEUED` and returns an error.
### Smoke-test checklist
After starting the server on the feature branch:
1. Enqueue a job in Scrum4Me (Solo Paneel → Start agent).
2. Call `wait_for_job` — response must contain `worktree_path` and `branch_name`.
3. In the **main checkout**: `git worktree list` → the agent worktree appears.
4. In the **main checkout**: `git status` → clean (no agent changes).
5. Call `update_job_status(done)` → worktree directory disappears.
## Schema sync ## Schema sync
The Prisma schema is the source of truth in the upstream Scrum4Me The Prisma schema is the source of truth in the upstream Scrum4Me

View file

@ -0,0 +1,171 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import * as path from 'node:path'
import * as os from 'node:os'
vi.mock('../src/prisma.js', () => ({
prisma: {
claudeJob: { findMany: vi.fn() },
},
}))
vi.mock('../src/git/worktree.js', () => ({
removeWorktreeForJob: vi.fn(),
}))
vi.mock('../src/tools/wait-for-job.js', async (importOriginal) => {
const original = await importOriginal<typeof import('../src/tools/wait-for-job.js')>()
return { ...original, resolveRepoRoot: vi.fn() }
})
vi.mock('node:fs/promises', () => ({
readdir: vi.fn(),
}))
import { prisma } from '../src/prisma.js'
import { removeWorktreeForJob } from '../src/git/worktree.js'
import { resolveRepoRoot } from '../src/tools/wait-for-job.js'
import * as fsPromises from 'node:fs/promises'
import { cleanupWorktrees, listWorktreeJobIds, getWorktreeParent } from '../src/tools/cleanup-my-worktrees.js'
const mockPrisma = prisma as unknown as {
claudeJob: { findMany: ReturnType<typeof vi.fn> }
}
const mockRemove = removeWorktreeForJob as ReturnType<typeof vi.fn>
const mockResolve = resolveRepoRoot as ReturnType<typeof vi.fn>
const mockReaddir = fsPromises.readdir as ReturnType<typeof vi.fn>
const REPO_ROOT = '/repos/my-project'
const USER_ID = 'user-1'
const PRODUCT_ID = 'product-1'
const WORKTREE_PARENT = '/home/user/.scrum4me-agent-worktrees'
function makeDirent(name: string, isDir = true) {
return { name, isDirectory: () => isDir } as unknown as import('node:fs').Dirent
}
beforeEach(() => {
vi.clearAllMocks()
mockResolve.mockResolvedValue(REPO_ROOT)
mockRemove.mockResolvedValue({ removed: true })
})
describe('getWorktreeParent', () => {
it('uses SCRUM4ME_AGENT_WORKTREE_DIR env var when set', async () => {
process.env.SCRUM4ME_AGENT_WORKTREE_DIR = '/custom/dir'
expect(await getWorktreeParent()).toBe('/custom/dir')
delete process.env.SCRUM4ME_AGENT_WORKTREE_DIR
})
it('defaults to ~/.scrum4me-agent-worktrees', async () => {
delete process.env.SCRUM4ME_AGENT_WORKTREE_DIR
expect(await getWorktreeParent()).toBe(path.join(os.homedir(), '.scrum4me-agent-worktrees'))
})
})
describe('listWorktreeJobIds', () => {
it('returns directory names from the worktree parent', async () => {
mockReaddir.mockResolvedValue([makeDirent('job-aaa'), makeDirent('job-bbb'), makeDirent('file.txt', false)])
const ids = await listWorktreeJobIds(WORKTREE_PARENT)
expect(ids).toEqual(['job-aaa', 'job-bbb'])
})
it('returns empty array when parent does not exist', async () => {
mockReaddir.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
expect(await listWorktreeJobIds(WORKTREE_PARENT)).toEqual([])
})
})
describe('cleanupWorktrees', () => {
it('removes worktrees for DONE/FAILED/CANCELLED jobs', async () => {
mockReaddir.mockResolvedValue([
makeDirent('job-done'),
makeDirent('job-failed'),
makeDirent('job-cancelled'),
])
mockPrisma.claudeJob.findMany.mockResolvedValue([
{ id: 'job-done', status: 'DONE', product_id: PRODUCT_ID, branch: 'feat/job-done' },
{ id: 'job-failed', status: 'FAILED', product_id: PRODUCT_ID, branch: null },
{ id: 'job-cancelled', status: 'CANCELLED', product_id: PRODUCT_ID, branch: null },
])
const result = await cleanupWorktrees(WORKTREE_PARENT, USER_ID)
expect(result.removed).toEqual(expect.arrayContaining(['job-done', 'job-failed', 'job-cancelled']))
expect(result.kept).toEqual([])
expect(result.skipped).toEqual([])
expect(mockRemove).toHaveBeenCalledTimes(3)
})
it('keeps worktrees for QUEUED/CLAIMED/RUNNING jobs', async () => {
mockReaddir.mockResolvedValue([
makeDirent('job-queued'),
makeDirent('job-claimed'),
makeDirent('job-running'),
])
mockPrisma.claudeJob.findMany.mockResolvedValue([
{ id: 'job-queued', status: 'QUEUED', product_id: PRODUCT_ID, branch: null },
{ id: 'job-claimed', status: 'CLAIMED', product_id: PRODUCT_ID, branch: null },
{ id: 'job-running', status: 'RUNNING', product_id: PRODUCT_ID, branch: null },
])
const result = await cleanupWorktrees(WORKTREE_PARENT, USER_ID)
expect(result.kept).toEqual(expect.arrayContaining(['job-queued', 'job-claimed', 'job-running']))
expect(result.removed).toEqual([])
expect(mockRemove).not.toHaveBeenCalled()
})
it('calls removeWorktreeForJob with keepBranch=true for DONE with branch', async () => {
mockReaddir.mockResolvedValue([makeDirent('job-done')])
mockPrisma.claudeJob.findMany.mockResolvedValue([
{ id: 'job-done', status: 'DONE', product_id: PRODUCT_ID, branch: 'feat/job-done' },
])
await cleanupWorktrees(WORKTREE_PARENT, USER_ID)
expect(mockRemove).toHaveBeenCalledWith({ repoRoot: REPO_ROOT, jobId: 'job-done', keepBranch: true })
})
it('calls removeWorktreeForJob with keepBranch=false for FAILED jobs', async () => {
mockReaddir.mockResolvedValue([makeDirent('job-failed')])
mockPrisma.claudeJob.findMany.mockResolvedValue([
{ id: 'job-failed', status: 'FAILED', product_id: PRODUCT_ID, branch: null },
])
await cleanupWorktrees(WORKTREE_PARENT, USER_ID)
expect(mockRemove).toHaveBeenCalledWith({ repoRoot: REPO_ROOT, jobId: 'job-failed', keepBranch: false })
})
it('skips orphan worktrees (no DB record)', async () => {
mockReaddir.mockResolvedValue([makeDirent('job-orphan')])
mockPrisma.claudeJob.findMany.mockResolvedValue([])
const result = await cleanupWorktrees(WORKTREE_PARENT, USER_ID)
expect(result.skipped).toContain('job-orphan')
expect(mockRemove).not.toHaveBeenCalled()
})
it('skips worktrees when no repoRoot is configured', async () => {
mockReaddir.mockResolvedValue([makeDirent('job-norepo')])
mockPrisma.claudeJob.findMany.mockResolvedValue([
{ id: 'job-norepo', status: 'FAILED', product_id: 'unknown-product', branch: null },
])
mockResolve.mockResolvedValue(null)
const result = await cleanupWorktrees(WORKTREE_PARENT, USER_ID)
expect(result.skipped).toContain('job-norepo')
expect(mockRemove).not.toHaveBeenCalled()
})
it('returns empty result when worktree parent is empty', async () => {
mockReaddir.mockResolvedValue([])
const result = await cleanupWorktrees(WORKTREE_PARENT, USER_ID)
expect(result).toEqual({ removed: [], kept: [], skipped: [] })
expect(mockPrisma.claudeJob.findMany).not.toHaveBeenCalled()
})
})

69
__tests__/git/pr.test.ts Normal file
View file

@ -0,0 +1,69 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockExecFile } = vi.hoisted(() => ({ mockExecFile: vi.fn() }))
vi.mock('node:child_process', () => ({ execFile: mockExecFile }))
vi.mock('node:util', () => ({
promisify:
(fn: (...args: unknown[]) => void) =>
(...args: unknown[]) =>
new Promise((resolve, reject) =>
fn(...args, (err: Error | null, result: unknown) => (err ? reject(err) : resolve(result))),
),
}))
import { createPullRequest } from '../../src/git/pr.js'
beforeEach(() => {
vi.clearAllMocks()
})
describe('createPullRequest', () => {
it('returns PR URL when gh succeeds', async () => {
mockExecFile.mockImplementation(
(_cmd: string, _args: string[], _opts: unknown, cb: (err: null, res: { stdout: string; stderr: string }) => void) =>
cb(null, { stdout: 'Creating pull request...\nhttps://github.com/org/repo/pull/42\n', stderr: '' }),
)
const result = await createPullRequest({
worktreePath: '/worktrees/job-abc',
branchName: 'feat/job-abc',
title: 'SCRUM-1: Add feature',
body: 'Summary\n\n---\n\n*Auto-generated*',
})
expect(result).toEqual({ url: 'https://github.com/org/repo/pull/42' })
})
it('returns error when gh is not installed (ENOENT)', async () => {
mockExecFile.mockImplementation(
(_cmd: string, _args: string[], _opts: unknown, cb: (err: Error) => void) =>
cb(Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' })),
)
const result = await createPullRequest({
worktreePath: '/worktrees/job-abc',
branchName: 'feat/job-abc',
title: 'My PR',
body: 'Body',
})
expect(result).toMatchObject({ error: expect.stringContaining('gh CLI not found') })
})
it('returns error on generic gh failure', async () => {
mockExecFile.mockImplementation(
(_cmd: string, _args: string[], _opts: unknown, cb: (err: Error) => void) =>
cb(new Error('authentication required')),
)
const result = await createPullRequest({
worktreePath: '/worktrees/job-abc',
branchName: 'feat/job-abc',
title: 'My PR',
body: 'Body',
})
expect(result).toMatchObject({ error: expect.stringContaining('gh pr create failed') })
})
})

View file

@ -0,0 +1,93 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('node:child_process', () => ({
execFile: vi.fn(),
}))
import { execFile } from 'node:child_process'
import { pushBranchForJob } from '../../src/git/push.js'
// promisify(execFile) will call execFile(cmd, args, opts, cb) internally
type ExecCallback = (err: Error | null, result?: { stdout: string; stderr: string }) => void
const mockExec = execFile as unknown as ReturnType<typeof vi.fn>
const SHA_HEAD = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
const SHA_BASE = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
beforeEach(() => {
vi.clearAllMocks()
})
describe('pushBranchForJob', () => {
it('returns pushed=true with remoteRef on successful push', async () => {
mockExec.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb: ExecCallback) => {
if (args.includes('HEAD')) return cb(null, { stdout: `${SHA_HEAD}\n`, stderr: '' })
if (args.includes('origin/main')) return cb(null, { stdout: `${SHA_BASE}\n`, stderr: '' })
// git push -u origin <branch>
return cb(null, { stdout: '', stderr: '' })
})
const result = await pushBranchForJob({ worktreePath: '/wt/job-abc', branchName: 'feat/job-abc' })
expect(result).toEqual({ pushed: true, remoteRef: 'refs/heads/feat/job-abc' })
})
it('returns no-changes when HEAD equals origin/main', async () => {
mockExec.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb: ExecCallback) => {
if (args.includes('HEAD') || args.includes('origin/main')) {
return cb(null, { stdout: `${SHA_BASE}\n`, stderr: '' })
}
return cb(null, { stdout: '', stderr: '' })
})
const result = await pushBranchForJob({ worktreePath: '/wt/job-abc', branchName: 'feat/job-abc' })
expect(result).toEqual({ pushed: false, reason: 'no-changes', stderr: '' })
})
it('returns no-credentials when push fails with Authentication failed', async () => {
const authError = Object.assign(new Error('git push failed'), {
stderr: 'fatal: Authentication failed for https://github.com/...',
})
mockExec.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb: ExecCallback) => {
if (args.includes('HEAD')) return cb(null, { stdout: `${SHA_HEAD}\n`, stderr: '' })
if (args.includes('origin/main')) return cb(null, { stdout: `${SHA_BASE}\n`, stderr: '' })
return cb(authError)
})
const result = await pushBranchForJob({ worktreePath: '/wt/job-abc', branchName: 'feat/job-abc' })
expect(result).toMatchObject({ pushed: false, reason: 'no-credentials' })
expect((result as { stderr: string }).stderr).toContain('Authentication failed')
})
it('returns conflict when push is rejected (non-fast-forward)', async () => {
const conflictError = Object.assign(new Error('git push failed'), {
stderr: '! [rejected] feat/job-abc -> feat/job-abc (non-fast-forward)',
})
mockExec.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb: ExecCallback) => {
if (args.includes('HEAD')) return cb(null, { stdout: `${SHA_HEAD}\n`, stderr: '' })
if (args.includes('origin/main')) return cb(null, { stdout: `${SHA_BASE}\n`, stderr: '' })
return cb(conflictError)
})
const result = await pushBranchForJob({ worktreePath: '/wt/job-abc', branchName: 'feat/job-abc' })
expect(result).toMatchObject({ pushed: false, reason: 'conflict' })
})
it('returns unknown for unrecognised push errors', async () => {
const unknownError = Object.assign(new Error('git push failed'), {
stderr: 'error: some unexpected thing happened',
})
mockExec.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb: ExecCallback) => {
if (args.includes('HEAD')) return cb(null, { stdout: `${SHA_HEAD}\n`, stderr: '' })
if (args.includes('origin/main')) return cb(null, { stdout: `${SHA_BASE}\n`, stderr: '' })
return cb(unknownError)
})
const result = await pushBranchForJob({ worktreePath: '/wt/job-abc', branchName: 'feat/job-abc' })
expect(result).toMatchObject({ pushed: false, reason: 'unknown' })
})
})

View file

@ -0,0 +1,204 @@
import { describe, it, expect, afterEach } from 'vitest'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import * as os from 'node:os'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { createWorktreeForJob, removeWorktreeForJob } from '../../src/git/worktree.js'
const exec = promisify(execFile)
async function git(args: string[], cwd: string) {
return exec('git', args, { cwd })
}
async function setupRepo(): Promise<{ repoDir: string; originDir: string }> {
const base = os.tmpdir()
const originDir = await fs.mkdtemp(path.join(base, 'scrum4me-origin-'))
const repoDir = await fs.mkdtemp(path.join(base, 'scrum4me-repo-'))
await git(['init', '--bare'], originDir)
await git(['init'], repoDir)
await git(['config', 'user.email', 'test@test.com'], repoDir)
await git(['config', 'user.name', 'Test'], repoDir)
await git(['remote', 'add', 'origin', originDir], repoDir)
await fs.writeFile(path.join(repoDir, 'README.md'), '# test')
await git(['add', '.'], repoDir)
await git(['commit', '-m', 'init'], repoDir)
await git(['push', 'origin', 'HEAD:main'], repoDir)
return { repoDir, originDir }
}
describe('createWorktreeForJob', () => {
const tmpDirs: string[] = []
const originalWorktreeDir = process.env.SCRUM4ME_AGENT_WORKTREE_DIR
afterEach(async () => {
if (originalWorktreeDir === undefined) {
delete process.env.SCRUM4ME_AGENT_WORKTREE_DIR
} else {
process.env.SCRUM4ME_AGENT_WORKTREE_DIR = originalWorktreeDir
}
for (const dir of tmpDirs.splice(0)) {
await fs.rm(dir, { recursive: true, force: true })
}
})
async function makeWorktreeParent(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'scrum4me-worktrees-'))
tmpDirs.push(dir)
process.env.SCRUM4ME_AGENT_WORKTREE_DIR = dir
return dir
}
it('creates a worktree directory with the correct branch as HEAD', async () => {
const { repoDir, originDir } = await setupRepo()
tmpDirs.push(repoDir, originDir)
const wtParent = await makeWorktreeParent()
const result = await createWorktreeForJob({
repoRoot: repoDir,
jobId: 'job-001',
branchName: 'feat/job-001',
baseRef: 'origin/main',
})
const stat = await fs.stat(result.worktreePath)
expect(stat.isDirectory()).toBe(true)
const { stdout } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], result.worktreePath)
expect(stdout.trim()).toBe('feat/job-001')
expect(result.branchName).toBe('feat/job-001')
expect(result.worktreePath).toBe(path.join(wtParent, 'job-001'))
})
it('suffixes branch name with timestamp when branch already exists', async () => {
const { repoDir, originDir } = await setupRepo()
tmpDirs.push(repoDir, originDir)
await makeWorktreeParent()
await git(['branch', 'feat/job-002'], repoDir)
const result = await createWorktreeForJob({
repoRoot: repoDir,
jobId: 'job-002',
branchName: 'feat/job-002',
baseRef: 'origin/main',
})
expect(result.branchName).toMatch(/^feat\/job-002-\d+$/)
const { stdout } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], result.worktreePath)
expect(stdout.trim()).toBe(result.branchName)
})
it('rejects when worktree path already exists', async () => {
const { repoDir, originDir } = await setupRepo()
tmpDirs.push(repoDir, originDir)
const wtParent = await makeWorktreeParent()
const existingPath = path.join(wtParent, 'job-003')
await fs.mkdir(existingPath)
await expect(
createWorktreeForJob({
repoRoot: repoDir,
jobId: 'job-003',
branchName: 'feat/job-003',
baseRef: 'origin/main',
}),
).rejects.toThrow('Worktree path already exists')
})
})
describe('removeWorktreeForJob', () => {
const tmpDirs: string[] = []
const originalWorktreeDir = process.env.SCRUM4ME_AGENT_WORKTREE_DIR
afterEach(async () => {
if (originalWorktreeDir === undefined) {
delete process.env.SCRUM4ME_AGENT_WORKTREE_DIR
} else {
process.env.SCRUM4ME_AGENT_WORKTREE_DIR = originalWorktreeDir
}
for (const dir of tmpDirs.splice(0)) {
await fs.rm(dir, { recursive: true, force: true })
}
})
async function makeWorktreeParent(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'scrum4me-worktrees-'))
tmpDirs.push(dir)
process.env.SCRUM4ME_AGENT_WORKTREE_DIR = dir
return dir
}
it('removes worktree directory and deletes branch by default', async () => {
const { repoDir, originDir } = await setupRepo()
tmpDirs.push(repoDir, originDir)
const wtParent = await makeWorktreeParent()
const { worktreePath, branchName } = await createWorktreeForJob({
repoRoot: repoDir,
jobId: 'job-rm-01',
branchName: 'feat/job-rm-01',
baseRef: 'origin/main',
})
const result = await removeWorktreeForJob({ repoRoot: repoDir, jobId: 'job-rm-01' })
expect(result.removed).toBe(true)
await expect(fs.access(worktreePath)).rejects.toThrow()
await expect(fs.access(path.join(wtParent, 'job-rm-01'))).rejects.toThrow()
// Branch should be deleted
await expect(
exec('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`], {
cwd: repoDir,
}),
).rejects.toThrow()
})
it('removes worktree directory but keeps branch when keepBranch=true', async () => {
const { repoDir, originDir } = await setupRepo()
tmpDirs.push(repoDir, originDir)
const wtParent = await makeWorktreeParent()
const { worktreePath, branchName } = await createWorktreeForJob({
repoRoot: repoDir,
jobId: 'job-rm-02',
branchName: 'feat/job-rm-02',
baseRef: 'origin/main',
})
const result = await removeWorktreeForJob({
repoRoot: repoDir,
jobId: 'job-rm-02',
keepBranch: true,
})
expect(result.removed).toBe(true)
await expect(fs.access(worktreePath)).rejects.toThrow()
await expect(fs.access(path.join(wtParent, 'job-rm-02'))).rejects.toThrow()
// Branch should still exist
const { stdout } = await exec(
'git',
['show-ref', '--verify', `refs/heads/${branchName}`],
{ cwd: repoDir },
)
expect(stdout).toContain(branchName)
})
it('returns { removed: false } when worktree does not exist', async () => {
const { repoDir, originDir } = await setupRepo()
tmpDirs.push(repoDir, originDir)
await makeWorktreeParent()
const result = await removeWorktreeForJob({ repoRoot: repoDir, jobId: 'job-rm-nonexistent' })
expect(result.removed).toBe(false)
})
})

View file

@ -0,0 +1,85 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../src/prisma.js', () => ({
prisma: {
product: { findUnique: vi.fn() },
task: { findUnique: vi.fn() },
},
}))
vi.mock('../src/git/pr.js', () => ({
createPullRequest: vi.fn(),
}))
import { prisma } from '../src/prisma.js'
import { createPullRequest } from '../src/git/pr.js'
import { maybeCreateAutoPr } from '../src/tools/update-job-status.js'
const mockPrisma = prisma as unknown as {
product: { findUnique: ReturnType<typeof vi.fn> }
task: { findUnique: ReturnType<typeof vi.fn> }
}
const mockCreatePr = createPullRequest as ReturnType<typeof vi.fn>
const BASE_OPTS = {
jobId: 'job-abc',
productId: 'prod-1',
taskId: 'task-1',
worktreePath: '/wt/job-abc',
branchName: 'feat/job-abc',
summary: 'Implemented the feature',
}
beforeEach(() => {
vi.clearAllMocks()
mockPrisma.product.findUnique.mockResolvedValue({ auto_pr: true })
mockPrisma.task.findUnique.mockResolvedValue({
title: 'Add feature',
story: { code: 'SCRUM-42' },
})
mockCreatePr.mockResolvedValue({ url: 'https://github.com/org/repo/pull/99' })
})
describe('maybeCreateAutoPr', () => {
it('returns PR URL when auto_pr=true and gh succeeds', async () => {
const url = await maybeCreateAutoPr(BASE_OPTS)
expect(url).toBe('https://github.com/org/repo/pull/99')
expect(mockCreatePr).toHaveBeenCalledWith({
worktreePath: BASE_OPTS.worktreePath,
branchName: BASE_OPTS.branchName,
title: 'SCRUM-42: Add feature',
body: expect.stringContaining(BASE_OPTS.summary),
})
})
it('returns null when auto_pr=false', async () => {
mockPrisma.product.findUnique.mockResolvedValue({ auto_pr: false })
const url = await maybeCreateAutoPr(BASE_OPTS)
expect(url).toBeNull()
expect(mockCreatePr).not.toHaveBeenCalled()
})
it('uses task title without code prefix when story has no code', async () => {
mockPrisma.task.findUnique.mockResolvedValue({
title: 'Add feature',
story: { code: null },
})
await maybeCreateAutoPr(BASE_OPTS)
expect(mockCreatePr).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Add feature' }),
)
})
it('returns null and does not throw when gh fails', async () => {
mockCreatePr.mockResolvedValue({ error: 'gh CLI not found' })
const url = await maybeCreateAutoPr(BASE_OPTS)
expect(url).toBeNull()
})
it('returns null when product not found', async () => {
mockPrisma.product.findUnique.mockResolvedValue(null)
const url = await maybeCreateAutoPr(BASE_OPTS)
expect(url).toBeNull()
expect(mockCreatePr).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,110 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import * as path from 'node:path'
vi.mock('../src/git/push.js', () => ({
pushBranchForJob: vi.fn(),
}))
import { pushBranchForJob } from '../src/git/push.js'
import { prepareDoneUpdate } from '../src/tools/update-job-status.js'
const mockPush = pushBranchForJob as ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
})
describe('prepareDoneUpdate', () => {
const originalEnv = { ...process.env }
afterEach(() => {
process.env.SCRUM4ME_AGENT_WORKTREE_DIR = originalEnv.SCRUM4ME_AGENT_WORKTREE_DIR
})
it('returns DONE with pushedAt and branchOverride when push succeeds', async () => {
process.env.SCRUM4ME_AGENT_WORKTREE_DIR = '/wt'
mockPush.mockResolvedValue({ pushed: true, remoteRef: 'refs/heads/feat/job-abc' })
const plan = await prepareDoneUpdate('job-abc', 'feat/job-abc')
expect(plan.dbStatus).toBe('DONE')
expect(plan.pushedAt).toBeInstanceOf(Date)
expect(plan.branchOverride).toBe('feat/job-abc')
expect(plan.errorOverride).toBeUndefined()
expect(plan.skipWorktreeCleanup).toBe(false)
expect(mockPush).toHaveBeenCalledWith({
worktreePath: path.join('/wt', 'job-abc'),
branchName: 'feat/job-abc',
})
})
it('derives branchName from jobId when branch is undefined', async () => {
process.env.SCRUM4ME_AGENT_WORKTREE_DIR = '/wt'
mockPush.mockResolvedValue({ pushed: true, remoteRef: 'refs/heads/feat/job-abc12345' })
await prepareDoneUpdate('job-abc12345', undefined)
expect(mockPush).toHaveBeenCalledWith(
expect.objectContaining({ branchName: 'feat/job-abc12345' }),
)
})
it('returns DONE without pushedAt when no-changes', async () => {
process.env.SCRUM4ME_AGENT_WORKTREE_DIR = '/wt'
mockPush.mockResolvedValue({ pushed: false, reason: 'no-changes', stderr: '' })
const plan = await prepareDoneUpdate('job-abc', 'feat/job-abc')
expect(plan.dbStatus).toBe('DONE')
expect(plan.pushedAt).toBeUndefined()
expect(plan.branchOverride).toBeUndefined()
expect(plan.errorOverride).toBeUndefined()
expect(plan.skipWorktreeCleanup).toBe(false)
})
it('returns FAILED with error and skipWorktreeCleanup when no-credentials', async () => {
process.env.SCRUM4ME_AGENT_WORKTREE_DIR = '/wt'
mockPush.mockResolvedValue({
pushed: false,
reason: 'no-credentials',
stderr: 'fatal: Authentication failed',
})
const plan = await prepareDoneUpdate('job-abc', 'feat/job-abc')
expect(plan.dbStatus).toBe('FAILED')
expect(plan.errorOverride).toContain('push failed (no-credentials)')
expect(plan.errorOverride).toContain('Authentication failed')
expect(plan.skipWorktreeCleanup).toBe(true)
})
it('returns FAILED with error and skipWorktreeCleanup when conflict', async () => {
process.env.SCRUM4ME_AGENT_WORKTREE_DIR = '/wt'
mockPush.mockResolvedValue({
pushed: false,
reason: 'conflict',
stderr: '! [rejected] non-fast-forward',
})
const plan = await prepareDoneUpdate('job-abc', 'feat/job-abc')
expect(plan.dbStatus).toBe('FAILED')
expect(plan.errorOverride).toContain('push failed (conflict)')
expect(plan.skipWorktreeCleanup).toBe(true)
})
it('returns FAILED with error and skipWorktreeCleanup when unknown push error', async () => {
process.env.SCRUM4ME_AGENT_WORKTREE_DIR = '/wt'
mockPush.mockResolvedValue({
pushed: false,
reason: 'unknown',
stderr: 'something went wrong',
})
const plan = await prepareDoneUpdate('job-abc', 'feat/job-abc')
expect(plan.dbStatus).toBe('FAILED')
expect(plan.skipWorktreeCleanup).toBe(true)
})
})

View file

@ -0,0 +1,84 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../src/git/worktree.js', () => ({
removeWorktreeForJob: vi.fn(),
}))
vi.mock('../src/tools/wait-for-job.js', async (importOriginal) => {
const original = await importOriginal<typeof import('../src/tools/wait-for-job.js')>()
return {
...original,
resolveRepoRoot: vi.fn(),
}
})
import { removeWorktreeForJob } from '../src/git/worktree.js'
import { resolveRepoRoot } from '../src/tools/wait-for-job.js'
import { cleanupWorktreeForTerminalStatus } from '../src/tools/update-job-status.js'
const mockRemove = removeWorktreeForJob as ReturnType<typeof vi.fn>
const mockResolve = resolveRepoRoot as ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
})
describe('cleanupWorktreeForTerminalStatus', () => {
it('calls removeWorktreeForJob with keepBranch=true when done and branch set', async () => {
mockResolve.mockResolvedValue('/repos/my-project')
mockRemove.mockResolvedValue({ removed: true })
await cleanupWorktreeForTerminalStatus('prod-001', 'job-abc', 'done', 'feat/job-abc')
expect(mockRemove).toHaveBeenCalledWith({
repoRoot: '/repos/my-project',
jobId: 'job-abc',
keepBranch: true,
})
})
it('calls removeWorktreeForJob with keepBranch=false when done but no branch', async () => {
mockResolve.mockResolvedValue('/repos/my-project')
mockRemove.mockResolvedValue({ removed: true })
await cleanupWorktreeForTerminalStatus('prod-001', 'job-abc', 'done', undefined)
expect(mockRemove).toHaveBeenCalledWith({
repoRoot: '/repos/my-project',
jobId: 'job-abc',
keepBranch: false,
})
})
it('calls removeWorktreeForJob with keepBranch=false when failed', async () => {
mockResolve.mockResolvedValue('/repos/my-project')
mockRemove.mockResolvedValue({ removed: true })
await cleanupWorktreeForTerminalStatus('prod-001', 'job-abc', 'failed', 'feat/job-abc')
expect(mockRemove).toHaveBeenCalledWith({
repoRoot: '/repos/my-project',
jobId: 'job-abc',
keepBranch: false,
})
})
it('skips cleanup and does not throw when no repoRoot configured', async () => {
mockResolve.mockResolvedValue(null)
await expect(
cleanupWorktreeForTerminalStatus('prod-no-root', 'job-abc', 'done', undefined),
).resolves.toBeUndefined()
expect(mockRemove).not.toHaveBeenCalled()
})
it('does not throw when removeWorktreeForJob fails (best-effort)', async () => {
mockResolve.mockResolvedValue('/repos/my-project')
mockRemove.mockRejectedValue(new Error('git error'))
await expect(
cleanupWorktreeForTerminalStatus('prod-001', 'job-abc', 'done', 'feat/job-abc'),
).resolves.toBeUndefined()
})
})

View file

@ -0,0 +1,123 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../src/prisma.js', () => ({
prisma: {
task: { findUnique: vi.fn() },
claudeJob: { update: vi.fn() },
},
}))
vi.mock('../src/verify/classify.js', () => ({
classifyDiffAgainstPlan: vi.fn(),
}))
import { prisma } from '../src/prisma.js'
import { classifyDiffAgainstPlan } from '../src/verify/classify.js'
import { getDiffInWorktree, saveVerifyResult } from '../src/tools/verify-task-against-plan.js'
const mockPrisma = prisma as unknown as {
task: { findUnique: ReturnType<typeof vi.fn> }
claudeJob: { update: ReturnType<typeof vi.fn> }
}
const mockClassify = classifyDiffAgainstPlan as ReturnType<typeof vi.fn>
// Mock node:child_process so getDiffInWorktree doesn't need a real git repo
vi.mock('node:child_process', () => ({
execFile: vi.fn(),
}))
import { execFile } from 'node:child_process'
const mockExecFile = execFile as unknown as ReturnType<typeof vi.fn>
// Promisify internally calls execFile in callback form: (cmd, args, opts, cb)
function stubExecFile(stdout: string) {
mockExecFile.mockImplementation(
(_cmd: string, _args: string[], _opts: unknown, cb: (err: null, result: { stdout: string; stderr: string }) => void) => {
cb(null, { stdout, stderr: '' })
},
)
}
function stubExecFileError(message: string) {
mockExecFile.mockImplementation(
(_cmd: string, _args: string[], _opts: unknown, cb: (err: Error) => void) => {
cb(new Error(message))
},
)
}
const ALIGNED_DIFF = `diff --git a/src/verify/classify.ts b/src/verify/classify.ts
--- a/src/verify/classify.ts
+++ b/src/verify/classify.ts
+export function classifyDiffAgainstPlan(opts) {}`
describe('getDiffInWorktree', () => {
beforeEach(() => vi.clearAllMocks())
it('returns stdout from git diff', async () => {
stubExecFile(ALIGNED_DIFF)
const result = await getDiffInWorktree('/worktrees/job-abc')
expect(result).toBe(ALIGNED_DIFF)
expect(mockExecFile).toHaveBeenCalledWith(
'git',
['diff', 'origin/main...HEAD'],
expect.objectContaining({ cwd: '/worktrees/job-abc' }),
expect.any(Function),
)
})
it('throws when git diff fails', async () => {
stubExecFileError('not a git repo')
await expect(getDiffInWorktree('/bad/path')).rejects.toThrow('not a git repo')
})
})
describe('saveVerifyResult', () => {
beforeEach(() => vi.clearAllMocks())
it('updates claudeJob with the given verify_result', async () => {
mockPrisma.claudeJob.update.mockResolvedValue({})
await saveVerifyResult('job-123', 'ALIGNED')
expect(mockPrisma.claudeJob.update).toHaveBeenCalledWith({
where: { id: 'job-123' },
data: { verify_result: 'ALIGNED' },
})
})
})
describe('verify_task_against_plan — integration of helpers', () => {
beforeEach(() => {
vi.clearAllMocks()
stubExecFile(ALIGNED_DIFF)
mockClassify.mockReturnValue({ result: 'ALIGNED', reasoning: 'All paths covered.' })
mockPrisma.claudeJob.update.mockResolvedValue({})
})
it('happy path: runs diff, classifies, saves verify_result', async () => {
// Simulate: getDiffInWorktree + classifyDiffAgainstPlan + saveVerifyResult
const diff = await getDiffInWorktree('/wt/job-abc')
mockClassify({ diff, plan: 'Modify `src/verify/classify.ts`.' })
expect(mockClassify).toHaveBeenCalledWith(expect.objectContaining({ diff }))
await saveVerifyResult('job-123', 'ALIGNED')
expect(mockPrisma.claudeJob.update).toHaveBeenCalledWith({
where: { id: 'job-123' },
data: { verify_result: 'ALIGNED' },
})
})
it('EMPTY path: saves EMPTY to DB', async () => {
stubExecFile('') // no diff output
mockClassify.mockReturnValue({ result: 'EMPTY', reasoning: 'Geen bestandswijzigingen in de diff.' })
const diff = await getDiffInWorktree('/wt/job-xyz')
const { result } = mockClassify({ diff, plan: null })
expect(result).toBe('EMPTY')
await saveVerifyResult('job-xyz', 'EMPTY')
expect(mockPrisma.claudeJob.update).toHaveBeenCalledWith({
where: { id: 'job-xyz' },
data: { verify_result: 'EMPTY' },
})
})
})

View file

@ -0,0 +1,126 @@
import { describe, it, expect } from 'vitest'
import { classifyDiffAgainstPlan } from '../../src/verify/classify.js'
// Helpers to build minimal unified diff snippets
function makeDiff(files: string[], linesPerFile = 5): string {
return files
.map(
(f) =>
`diff --git a/${f} b/${f}\n--- a/${f}\n+++ b/${f}\n` +
Array.from({ length: linesPerFile }, (_, i) => `+added line ${i}`).join('\n'),
)
.join('\n')
}
function largeFileDiff(file: string, addedLines = 60): string {
return (
`diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n` +
Array.from({ length: addedLines }, (_, i) => `+line ${i}`).join('\n')
)
}
describe('classifyDiffAgainstPlan — empty diff', () => {
it('returns EMPTY for blank diff string', () => {
const r = classifyDiffAgainstPlan({ diff: '', plan: 'some plan' })
expect(r.result).toBe('EMPTY')
expect(r.reasoning).toMatch(/geen bestandswijzigingen/i)
})
it('returns EMPTY for diff with no +++ b/ lines', () => {
const r = classifyDiffAgainstPlan({ diff: 'Binary files differ\n', plan: 'plan' })
expect(r.result).toBe('EMPTY')
})
})
describe('classifyDiffAgainstPlan — no plan baseline', () => {
it('returns ALIGNED for null plan with small diff', () => {
const r = classifyDiffAgainstPlan({ diff: makeDiff(['src/foo.ts']), plan: null })
expect(r.result).toBe('ALIGNED')
})
it('returns ALIGNED for empty string plan with small diff', () => {
const r = classifyDiffAgainstPlan({ diff: makeDiff(['src/foo.ts']), plan: ' ' })
expect(r.result).toBe('ALIGNED')
})
it('returns DIVERGENT for null plan with large diff (>50 changed lines)', () => {
const r = classifyDiffAgainstPlan({ diff: largeFileDiff('src/big.ts', 60), plan: null })
expect(r.result).toBe('DIVERGENT')
})
})
describe('classifyDiffAgainstPlan — plan has no extractable paths', () => {
const plan = 'Add a new feature. Update the logic. Write tests.'
it('returns ALIGNED for small diff when plan has no paths', () => {
const r = classifyDiffAgainstPlan({ diff: makeDiff(['src/feature.ts']), plan })
expect(r.result).toBe('ALIGNED')
})
it('returns DIVERGENT for large diff when plan has no paths', () => {
const r = classifyDiffAgainstPlan({ diff: largeFileDiff('src/feature.ts', 60), plan })
expect(r.result).toBe('DIVERGENT')
})
})
describe('classifyDiffAgainstPlan — PARTIAL coverage', () => {
const plan = 'Edit `src/api.ts` and `src/ui.ts`.'
it('returns PARTIAL when only one of two plan paths is in diff', () => {
const diff = makeDiff(['src/api.ts'])
const r = classifyDiffAgainstPlan({ diff, plan })
expect(r.result).toBe('PARTIAL')
expect(r.reasoning).toMatch(/1\/2/)
expect(r.reasoning).toMatch(/ontbrekend/i)
})
it('returns PARTIAL when none of the plan paths are in diff', () => {
const diff = makeDiff(['src/unrelated.ts'])
const r = classifyDiffAgainstPlan({ diff, plan })
expect(r.result).toBe('PARTIAL')
expect(r.reasoning).toMatch(/0\/2/)
})
})
describe('classifyDiffAgainstPlan — ALIGNED', () => {
it('returns ALIGNED when all plan paths are in diff with ratio < 3', () => {
const plan = 'Modify `src/a.ts` and `src/b.ts`.'
const diff = makeDiff(['src/a.ts', 'src/b.ts', 'src/c.ts']) // ratio 3/2 = 1.5 < 3
const r = classifyDiffAgainstPlan({ diff, plan })
expect(r.result).toBe('ALIGNED')
expect(r.reasoning).toMatch(/alle 2/i)
})
it('returns ALIGNED for exact 1-to-1 match', () => {
const plan = 'Update `src/verify/classify.ts`.'
const diff = makeDiff(['src/verify/classify.ts'])
const r = classifyDiffAgainstPlan({ diff, plan })
expect(r.result).toBe('ALIGNED')
})
it('suffix-matches short plan path against full diff path', () => {
const plan = 'Edit `classify.ts` in the verify module.'
const diff = makeDiff(['src/verify/classify.ts'])
const r = classifyDiffAgainstPlan({ diff, plan })
expect(r.result).toBe('ALIGNED')
})
})
describe('classifyDiffAgainstPlan — DIVERGENT (scope creep)', () => {
it('returns DIVERGENT when diff has 3x+ more paths than plan', () => {
const plan = 'Update `src/a.ts`.'
// 1 plan path, 4 diff paths → ratio 4.0 >= 3
const diff = makeDiff(['src/a.ts', 'src/b.ts', 'src/c.ts', 'src/d.ts'])
const r = classifyDiffAgainstPlan({ diff, plan })
expect(r.result).toBe('DIVERGENT')
expect(r.reasoning).toMatch(/4\.0x/)
})
it('shows extra file paths in reasoning', () => {
const plan = 'Modify `src/core.ts`.'
const diff = makeDiff(['src/core.ts', 'src/extra1.ts', 'src/extra2.ts', 'src/extra3.ts', 'src/extra4.ts'])
const r = classifyDiffAgainstPlan({ diff, plan })
expect(r.result).toBe('DIVERGENT')
expect(r.reasoning).toMatch(/extra/i)
})
})

View file

@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../src/prisma.js', () => ({ vi.mock('../src/prisma.js', () => ({
prisma: { prisma: {
$executeRaw: vi.fn(), $queryRaw: vi.fn(),
$transaction: vi.fn(), $transaction: vi.fn(),
}, },
})) }))
@ -11,27 +11,41 @@ import { prisma } from '../src/prisma.js'
import { resetStaleClaimedJobs, tryClaimJob } from '../src/tools/wait-for-job.js' import { resetStaleClaimedJobs, tryClaimJob } from '../src/tools/wait-for-job.js'
const mockPrisma = prisma as unknown as { const mockPrisma = prisma as unknown as {
$executeRaw: ReturnType<typeof vi.fn> $queryRaw: ReturnType<typeof vi.fn>
$transaction: ReturnType<typeof vi.fn> $transaction: ReturnType<typeof vi.fn>
} }
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
// Default: no stale jobs returned from either query
mockPrisma.$queryRaw.mockResolvedValue([])
}) })
describe('resetStaleClaimedJobs', () => { describe('resetStaleClaimedJobs', () => {
it('resets plan_snapshot to NULL when resetting stale claimed jobs', async () => { it('runs two $queryRaw calls: one for FAILED, one for QUEUED re-enqueue', async () => {
mockPrisma.$executeRaw.mockResolvedValue(0)
await resetStaleClaimedJobs('user-1') await resetStaleClaimedJobs('user-1')
// Two queries: failed jobs + requeued jobs
expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(2)
})
expect(mockPrisma.$executeRaw).toHaveBeenCalledOnce() it('FAILED query includes plan_snapshot = NULL reset and retry_count >= 2', async () => {
// Verify the template literal includes plan_snapshot = NULL await resetStaleClaimedJobs('user-1')
const call = mockPrisma.$executeRaw.mock.calls[0] const calls = mockPrisma.$queryRaw.mock.calls
const sqlParts: string[] = call[0] // First call: FAILED transition
const fullSql = sqlParts.join('') const failedSql = (calls[0][0] as string[]).join('')
expect(fullSql).toContain('plan_snapshot = NULL') expect(failedSql).toContain("status = 'FAILED'")
expect(fullSql).toContain("status = 'QUEUED'") expect(failedSql).toContain('retry_count >= 2')
expect(fullSql).toContain('claimed_at < NOW()') })
it('QUEUED re-enqueue query includes plan_snapshot = NULL and retry_count increment', async () => {
await resetStaleClaimedJobs('user-1')
const calls = mockPrisma.$queryRaw.mock.calls
// Second call: re-enqueue transition
const requeueSql = (calls[1][0] as string[]).join('')
expect(requeueSql).toContain("status = 'QUEUED'")
expect(requeueSql).toContain('plan_snapshot = NULL')
expect(requeueSql).toContain('retry_count = retry_count + 1')
expect(requeueSql).toContain('retry_count < 2')
}) })
}) })

View file

@ -0,0 +1,138 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import * as os from 'node:os'
import * as path from 'node:path'
import * as fs from 'node:fs/promises'
vi.mock('../src/prisma.js', () => ({
prisma: {
$executeRaw: vi.fn(),
},
}))
vi.mock('../src/git/worktree.js', () => ({
createWorktreeForJob: vi.fn(),
}))
import { prisma } from '../src/prisma.js'
import { createWorktreeForJob } from '../src/git/worktree.js'
import { resolveRepoRoot, rollbackClaim, attachWorktreeToJob } from '../src/tools/wait-for-job.js'
const mockPrisma = prisma as unknown as { $executeRaw: ReturnType<typeof vi.fn> }
const mockCreateWorktree = createWorktreeForJob as ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
})
describe('resolveRepoRoot', () => {
const originalEnv = { ...process.env }
afterEach(() => {
// Restore env
for (const key of Object.keys(process.env)) {
if (key.startsWith('SCRUM4ME_REPO_ROOT_')) delete process.env[key]
}
Object.assign(process.env, originalEnv)
})
it('returns value from env var when set', async () => {
process.env['SCRUM4ME_REPO_ROOT_prod-001'] = '/repos/my-project'
const result = await resolveRepoRoot('prod-001')
expect(result).toBe('/repos/my-project')
})
it('returns null when no env var and no config file', async () => {
delete process.env['SCRUM4ME_REPO_ROOT_prod-999']
// Config file at home won't have this productId in CI
const result = await resolveRepoRoot('prod-999-nonexistent')
expect(result).toBeNull()
})
it('reads from config file when env var is absent', async () => {
const configPath = path.join(os.homedir(), '.scrum4me-agent-config.json')
const config = { repoRoots: { 'prod-config': '/repos/from-config' } }
let wroteConfig = false
try {
await fs.writeFile(configPath, JSON.stringify(config), 'utf-8')
wroteConfig = true
delete process.env['SCRUM4ME_REPO_ROOT_prod-config']
const result = await resolveRepoRoot('prod-config')
expect(result).toBe('/repos/from-config')
} finally {
// Clean up only what we wrote — don't delete if it pre-existed
if (wroteConfig) {
try {
const existing = JSON.parse(await fs.readFile(configPath, 'utf-8'))
delete existing.repoRoots?.['prod-config']
if (Object.keys(existing.repoRoots ?? {}).length === 0 && Object.keys(existing).length === 1) {
await fs.rm(configPath)
} else {
await fs.writeFile(configPath, JSON.stringify(existing), 'utf-8')
}
} catch {
await fs.rm(configPath).catch(() => {})
}
}
}
})
})
describe('attachWorktreeToJob', () => {
const originalEnv = { ...process.env }
afterEach(() => {
for (const key of Object.keys(process.env)) {
if (key.startsWith('SCRUM4ME_REPO_ROOT_')) delete process.env[key]
}
Object.assign(process.env, originalEnv)
})
it('returns worktree_path and branch_name on success', async () => {
process.env['SCRUM4ME_REPO_ROOT_prod-001'] = '/repos/my-project'
mockCreateWorktree.mockResolvedValue({
worktreePath: '/home/user/.scrum4me-agent-worktrees/job-abc12345',
branchName: 'feat/job-abc12345',
})
mockPrisma.$executeRaw.mockResolvedValue(0)
const result = await attachWorktreeToJob('prod-001', 'job-abc12345')
expect(result).toEqual({
worktree_path: '/home/user/.scrum4me-agent-worktrees/job-abc12345',
branch_name: 'feat/job-abc12345',
})
expect(mockCreateWorktree).toHaveBeenCalledWith({
repoRoot: '/repos/my-project',
jobId: 'job-abc12345',
branchName: 'feat/job-abc12345',
})
})
it('rolls back claim and returns error when no repoRoot configured', async () => {
delete process.env['SCRUM4ME_REPO_ROOT_prod-no-root']
mockPrisma.$executeRaw.mockResolvedValue(0)
const result = await attachWorktreeToJob('prod-no-root', 'job-xyz')
expect('error' in result).toBe(true)
expect((result as { error: string }).error).toContain('No repo root configured')
expect(mockPrisma.$executeRaw).toHaveBeenCalledOnce()
const sqlParts: string[] = mockPrisma.$executeRaw.mock.calls[0][0]
expect(sqlParts.join('')).toContain("status = 'QUEUED'")
})
it('rolls back claim and returns error when createWorktreeForJob throws', async () => {
process.env['SCRUM4ME_REPO_ROOT_prod-001'] = '/repos/my-project'
mockCreateWorktree.mockRejectedValue(new Error('git fetch failed'))
mockPrisma.$executeRaw.mockResolvedValue(0)
const result = await attachWorktreeToJob('prod-001', 'job-fail')
expect('error' in result).toBe(true)
expect((result as { error: string }).error).toContain('git fetch failed')
expect(mockPrisma.$executeRaw).toHaveBeenCalledOnce()
const sqlParts: string[] = mockPrisma.$executeRaw.mock.calls[0][0]
expect(sqlParts.join('')).toContain("status = 'QUEUED'")
})
})

View file

@ -57,6 +57,13 @@ enum SprintStatus {
COMPLETED COMPLETED
} }
enum VerifyResult {
ALIGNED
PARTIAL
EMPTY
DIVERGENT
}
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
username String @unique username String @unique
@ -120,6 +127,7 @@ model Product {
description String? description String?
repo_url String? repo_url String?
definition_of_done String definition_of_done String
auto_pr Boolean @default(false)
archived Boolean @default(false) archived Boolean @default(false)
created_at DateTime @default(now()) created_at DateTime @default(now())
updated_at DateTime @updatedAt updated_at DateTime @updatedAt
@ -232,6 +240,7 @@ model Task {
priority Int priority Int
sort_order Float sort_order Float
status TaskStatus @default(TO_DO) status TaskStatus @default(TO_DO)
verify_only Boolean @default(false)
created_at DateTime @default(now()) created_at DateTime @default(now())
updated_at DateTime @updatedAt updated_at DateTime @updatedAt
claude_questions ClaudeQuestion[] claude_questions ClaudeQuestion[]
@ -256,10 +265,14 @@ model ClaudeJob {
claimed_at DateTime? claimed_at DateTime?
started_at DateTime? started_at DateTime?
finished_at DateTime? finished_at DateTime?
pushed_at DateTime?
plan_snapshot String? plan_snapshot String?
branch String? branch String?
pr_url String?
summary String? summary String?
error String? error String?
verify_result VerifyResult?
retry_count Int @default(0)
created_at DateTime @default(now()) created_at DateTime @default(now())
updated_at DateTime @updatedAt updated_at DateTime @updatedAt

38
src/git/pr.ts Normal file
View file

@ -0,0 +1,38 @@
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
const exec = promisify(execFile)
export async function createPullRequest(opts: {
worktreePath: string
branchName: string
title: string
body: string
}): Promise<{ url: string } | { error: string }> {
const { worktreePath, branchName, title, body } = opts
try {
const { stdout } = await exec(
'gh',
['pr', 'create', '--title', title, '--body', body, '--head', branchName],
{ cwd: worktreePath },
)
// gh prints the PR URL as the last non-empty line
const lines = stdout.trim().split('\n').filter(Boolean)
const url = lines[lines.length - 1]?.trim() ?? ''
if (!url.startsWith('http')) {
return { error: `gh pr create produced unexpected output: ${stdout.slice(0, 200)}` }
}
return { url }
} catch (err: unknown) {
const msg = (err as { message?: string }).message ?? String(err)
const isNotFound =
msg.includes('command not found') ||
msg.includes('is not recognized') ||
msg.includes('ENOENT')
if (isNotFound) {
return { error: 'gh CLI not found — install GitHub CLI to enable auto-PR' }
}
return { error: `gh pr create failed: ${msg.slice(0, 300)}` }
}
}

53
src/git/push.ts Normal file
View file

@ -0,0 +1,53 @@
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
const exec = promisify(execFile)
type PushSuccess = { pushed: true; remoteRef: string }
type PushFailure = {
pushed: false
reason: 'no-credentials' | 'conflict' | 'no-changes' | 'unknown'
stderr: string
}
export type PushResult = PushSuccess | PushFailure
export async function pushBranchForJob(opts: {
worktreePath: string
branchName: string
}): Promise<PushResult> {
const { worktreePath, branchName } = opts
// Detect no new commits vs origin/main
let headSha: string
let baseSha: string
try {
const [headResult, baseResult] = await Promise.all([
exec('git', ['rev-parse', 'HEAD'], { cwd: worktreePath }),
exec('git', ['rev-parse', 'origin/main'], { cwd: worktreePath }),
])
headSha = headResult.stdout.trim()
baseSha = baseResult.stdout.trim()
} catch (err) {
return { pushed: false, reason: 'unknown', stderr: (err as Error).message }
}
if (headSha === baseSha) {
return { pushed: false, reason: 'no-changes', stderr: '' }
}
// Push
try {
await exec('git', ['push', '-u', 'origin', branchName], { cwd: worktreePath })
return { pushed: true, remoteRef: `refs/heads/${branchName}` }
} catch (err) {
const stderr = (err as { stderr?: string }).stderr ?? (err as Error).message ?? ''
if (/Authentication failed|could not read Username/i.test(stderr)) {
return { pushed: false, reason: 'no-credentials', stderr }
}
if (/non-fast-forward|already exists on the remote|rejected/i.test(stderr)) {
return { pushed: false, reason: 'conflict', stderr }
}
return { pushed: false, reason: 'unknown', stderr }
}
}

97
src/git/worktree.ts Normal file
View file

@ -0,0 +1,97 @@
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import * as path from 'node:path'
import * as os from 'node:os'
import * as fs from 'node:fs/promises'
const exec = promisify(execFile)
async function branchExists(repoRoot: string, name: string): Promise<boolean> {
try {
await exec('git', ['show-ref', '--verify', '--quiet', `refs/heads/${name}`], { cwd: repoRoot })
return true
} catch {
return false
}
}
export async function createWorktreeForJob(opts: {
repoRoot: string
jobId: string
branchName: string
baseRef?: string
}): Promise<{ worktreePath: string; branchName: string }> {
const { repoRoot, jobId, baseRef = 'origin/main' } = opts
let { branchName } = opts
const parent =
process.env.SCRUM4ME_AGENT_WORKTREE_DIR ??
path.join(os.homedir(), '.scrum4me-agent-worktrees')
await fs.mkdir(parent, { recursive: true })
const worktreePath = path.join(parent, jobId)
// Reject if worktree path already exists — caller must remove it first
try {
await fs.access(worktreePath)
throw new Error(
`Worktree path already exists: ${worktreePath}. Call removeWorktreeForJob first.`,
)
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
}
await exec('git', ['fetch', 'origin', '--prune'], { cwd: repoRoot })
// Suffix with timestamp when branch already exists
if (await branchExists(repoRoot, branchName)) {
branchName = `${branchName}-${Date.now()}`
}
await exec('git', ['worktree', 'add', '-b', branchName, worktreePath, baseRef], {
cwd: repoRoot,
})
return { worktreePath, branchName }
}
export async function removeWorktreeForJob(opts: {
repoRoot: string
jobId: string
keepBranch?: boolean
}): Promise<{ removed: boolean }> {
const { repoRoot, jobId, keepBranch = false } = opts
const parent =
process.env.SCRUM4ME_AGENT_WORKTREE_DIR ??
path.join(os.homedir(), '.scrum4me-agent-worktrees')
const worktreePath = path.join(parent, jobId)
try {
await fs.access(worktreePath)
} catch {
return { removed: false }
}
let branchName: string | undefined
if (!keepBranch) {
try {
const { stdout } = await exec('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
cwd: worktreePath,
})
branchName = stdout.trim()
} catch {
// worktree HEAD unreadable — skip branch deletion
}
}
await exec('git', ['worktree', 'remove', '--force', worktreePath], { cwd: repoRoot })
if (!keepBranch && branchName && (await branchExists(repoRoot, branchName))) {
await exec('git', ['branch', '-D', branchName], { cwd: repoRoot })
}
return { removed: true }
}

View file

@ -20,6 +20,7 @@ import { registerCancelQuestionTool } from './tools/cancel-question.js'
import { registerWaitForJobTool } from './tools/wait-for-job.js' import { registerWaitForJobTool } from './tools/wait-for-job.js'
import { registerUpdateJobStatusTool } from './tools/update-job-status.js' import { registerUpdateJobStatusTool } from './tools/update-job-status.js'
import { registerVerifyTaskAgainstPlanTool } from './tools/verify-task-against-plan.js' import { registerVerifyTaskAgainstPlanTool } from './tools/verify-task-against-plan.js'
import { registerCleanupMyWorktreesTool } from './tools/cleanup-my-worktrees.js'
import { registerImplementNextStoryPrompt } from './prompts/implement-next-story.js' import { registerImplementNextStoryPrompt } from './prompts/implement-next-story.js'
const VERSION = '0.1.0' const VERSION = '0.1.0'
@ -53,6 +54,7 @@ async function main() {
registerWaitForJobTool(server) registerWaitForJobTool(server)
registerUpdateJobStatusTool(server) registerUpdateJobStatusTool(server)
registerVerifyTaskAgainstPlanTool(server) registerVerifyTaskAgainstPlanTool(server)
registerCleanupMyWorktreesTool(server)
registerImplementNextStoryPrompt(server) registerImplementNextStoryPrompt(server)
const transport = new StdioServerTransport() const transport = new StdioServerTransport()

View file

@ -0,0 +1,107 @@
import { z } from 'zod'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import * as os from 'node:os'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { prisma } from '../prisma.js'
import { requireWriteAccess } from '../auth.js'
import { toolJson, withToolErrors } from '../errors.js'
import { removeWorktreeForJob } from '../git/worktree.js'
import { resolveRepoRoot } from './wait-for-job.js'
const TERMINAL_STATUSES = new Set(['DONE', 'FAILED', 'CANCELLED'])
const ACTIVE_STATUSES = new Set(['QUEUED', 'CLAIMED', 'RUNNING'])
const inputSchema = z.object({})
export async function getWorktreeParent(): Promise<string> {
return (
process.env.SCRUM4ME_AGENT_WORKTREE_DIR ??
path.join(os.homedir(), '.scrum4me-agent-worktrees')
)
}
export async function listWorktreeJobIds(worktreeParent: string): Promise<string[]> {
try {
const entries = await fs.readdir(worktreeParent, { withFileTypes: true })
return entries.filter((e) => e.isDirectory()).map((e) => e.name)
} catch {
return []
}
}
export async function cleanupWorktrees(
worktreeParent: string,
userId: string,
): Promise<{ removed: string[]; kept: string[]; skipped: string[] }> {
const jobIds = await listWorktreeJobIds(worktreeParent)
const removed: string[] = []
const kept: string[] = []
const skipped: string[] = []
if (jobIds.length === 0) return { removed, kept, skipped }
const jobs = await prisma.claudeJob.findMany({
where: { id: { in: jobIds }, user_id: userId },
select: { id: true, status: true, product_id: true, branch: true },
})
const jobMap = new Map(jobs.map((j) => [j.id, j]))
for (const jobId of jobIds) {
const job = jobMap.get(jobId)
// No DB record for this jobId — orphan worktree, skip safely
if (!job) {
skipped.push(jobId)
continue
}
if (ACTIVE_STATUSES.has(job.status)) {
kept.push(jobId)
continue
}
if (TERMINAL_STATUSES.has(job.status)) {
const repoRoot = await resolveRepoRoot(job.product_id)
if (!repoRoot) {
skipped.push(jobId)
continue
}
// Keep branch for DONE jobs that already pushed (job.branch is set)
const keepBranch = job.status === 'DONE' && job.branch !== null
try {
await removeWorktreeForJob({ repoRoot, jobId, keepBranch })
removed.push(jobId)
} catch {
skipped.push(jobId)
}
}
}
return { removed, kept, skipped }
}
export function registerCleanupMyWorktreesTool(server: McpServer) {
server.registerTool(
'cleanup_my_worktrees',
{
title: 'Cleanup my worktrees',
description:
'Remove stale git worktrees left by crashed or cancelled agent runs. ' +
'Scans ~/.scrum4me-agent-worktrees/ (or SCRUM4ME_AGENT_WORKTREE_DIR) for job directories, ' +
'looks up each job\'s status, and removes worktrees whose jobs are in a terminal state ' +
'(DONE, FAILED, CANCELLED). Worktrees for active jobs (QUEUED, CLAIMED, RUNNING) are kept. ' +
'Returns { removed, kept, skipped } for inspection.',
inputSchema,
annotations: { readOnlyHint: false },
},
async () =>
withToolErrors(async () => {
const auth = await requireWriteAccess()
const worktreeParent = await getWorktreeParent()
const result = await cleanupWorktrees(worktreeParent, auth.userId)
return toolJson(result)
}),
)
}

View file

@ -5,9 +5,15 @@
import { z } from 'zod' import { z } from 'zod'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { Client } from 'pg' import { Client } from 'pg'
import * as os from 'node:os'
import * as path from 'node:path'
import { prisma } from '../prisma.js' import { prisma } from '../prisma.js'
import { requireWriteAccess } from '../auth.js' import { requireWriteAccess } from '../auth.js'
import { toolJson, toolError, withToolErrors } from '../errors.js' import { toolJson, toolError, withToolErrors } from '../errors.js'
import { removeWorktreeForJob } from '../git/worktree.js'
import { resolveRepoRoot } from './wait-for-job.js'
import { pushBranchForJob } from '../git/push.js'
import { createPullRequest } from '../git/pr.js'
const inputSchema = z.object({ const inputSchema = z.object({
job_id: z.string().min(1), job_id: z.string().min(1),
@ -17,12 +23,114 @@ const inputSchema = z.object({
error: z.string().max(2_000).optional(), error: z.string().max(2_000).optional(),
}) })
export async function cleanupWorktreeForTerminalStatus(
productId: string,
jobId: string,
status: 'done' | 'failed',
branch: string | undefined,
): Promise<void> {
const repoRoot = await resolveRepoRoot(productId)
if (!repoRoot) return
// Keep branch when job is done and a branch was reported (agent pushed)
const keepBranch = status === 'done' && branch !== undefined
try {
await removeWorktreeForJob({ repoRoot, jobId, keepBranch })
} catch (err) {
console.warn(`[update_job_status] Worktree cleanup failed for job ${jobId}:`, err)
}
}
export type DoneUpdatePlan = {
dbStatus: 'DONE' | 'FAILED'
pushedAt: Date | undefined
branchOverride: string | undefined
errorOverride: string | undefined
skipWorktreeCleanup: boolean
}
export async function prepareDoneUpdate(
jobId: string,
branch: string | undefined,
): Promise<DoneUpdatePlan> {
const worktreeDir =
process.env.SCRUM4ME_AGENT_WORKTREE_DIR ?? path.join(os.homedir(), '.scrum4me-agent-worktrees')
const worktreePath = path.join(worktreeDir, jobId)
const branchName = branch ?? `feat/job-${jobId.slice(-8)}`
const pushResult = await pushBranchForJob({ worktreePath, branchName })
if (pushResult.pushed) {
return {
dbStatus: 'DONE',
pushedAt: new Date(),
branchOverride: branchName,
errorOverride: undefined,
skipWorktreeCleanup: false,
}
}
if (pushResult.reason === 'no-changes') {
return {
dbStatus: 'DONE',
pushedAt: undefined,
branchOverride: undefined,
errorOverride: undefined,
skipWorktreeCleanup: false,
}
}
// Push failed — job becomes FAILED, worktree stays for manual inspection
const snippet = pushResult.stderr.slice(0, 200)
return {
dbStatus: 'FAILED',
pushedAt: undefined,
branchOverride: undefined,
errorOverride: `push failed (${pushResult.reason}): ${snippet}`,
skipWorktreeCleanup: true,
}
}
const DB_STATUS_MAP = { const DB_STATUS_MAP = {
running: 'RUNNING', running: 'RUNNING',
done: 'DONE', done: 'DONE',
failed: 'FAILED', failed: 'FAILED',
} as const } as const
export async function maybeCreateAutoPr(opts: {
jobId: string
productId: string
taskId: string
worktreePath: string
branchName: string
summary: string | undefined
}): Promise<string | null> {
const { jobId, productId, taskId, worktreePath, branchName, summary } = opts
const product = await prisma.product.findUnique({
where: { id: productId },
select: { auto_pr: true },
})
if (!product?.auto_pr) return null
const task = await prisma.task.findUnique({
where: { id: taskId },
select: { title: true, story: { select: { code: true } } },
})
if (!task) return null
const title = task.story.code ? `${task.story.code}: ${task.title}` : task.title
const body = summary
? `${summary}\n\n---\n\n*Auto-generated by Scrum4Me agent*`
: '*Auto-generated by Scrum4Me agent*'
const result = await createPullRequest({ worktreePath, branchName, title, body })
if ('url' in result) return result.url
console.warn(`[update_job_status] auto-PR skipped for job ${jobId}:`, result.error)
return null
}
export function registerUpdateJobStatusTool(server: McpServer) { export function registerUpdateJobStatusTool(server: McpServer) {
server.registerTool( server.registerTool(
'update_job_status', 'update_job_status',
@ -60,22 +168,61 @@ export function registerUpdateJobStatusTool(server: McpServer) {
return toolError(`Job is already in terminal state: ${job.status.toLowerCase()}`) return toolError(`Job is already in terminal state: ${job.status.toLowerCase()}`)
} }
const dbStatus = DB_STATUS_MAP[status] // For DONE: push first, adjust DB status based on result
let actualStatus = status
let pushedAt: Date | undefined
let branchToWrite = branch
let errorToWrite = error
let skipWorktreeCleanup = false
if (status === 'done') {
const plan = await prepareDoneUpdate(job_id, branch)
actualStatus = plan.dbStatus === 'DONE' ? 'done' : 'failed'
pushedAt = plan.pushedAt
if (plan.branchOverride !== undefined) branchToWrite = plan.branchOverride
if (plan.errorOverride !== undefined) errorToWrite = plan.errorOverride
skipWorktreeCleanup = plan.skipWorktreeCleanup
}
// Auto-PR: best-effort, only when push actually happened
let prUrl: string | null = null
if (actualStatus === 'done' && pushedAt && branchToWrite) {
const worktreeDir =
process.env.SCRUM4ME_AGENT_WORKTREE_DIR ??
path.join(os.homedir(), '.scrum4me-agent-worktrees')
prUrl = await maybeCreateAutoPr({
jobId: job_id,
productId: job.product_id,
taskId: job.task_id,
worktreePath: path.join(worktreeDir, job_id),
branchName: branchToWrite,
summary,
}).catch((err) => {
console.warn(`[update_job_status] auto-PR error for job ${job_id}:`, err)
return null
})
}
const dbStatus = DB_STATUS_MAP[actualStatus as keyof typeof DB_STATUS_MAP]
const now = new Date() const now = new Date()
const updated = await prisma.claudeJob.update({ const updated = await prisma.claudeJob.update({
where: { id: job_id }, where: { id: job_id },
data: { data: {
status: dbStatus, status: dbStatus,
...(status === 'running' ? { started_at: now } : {}), ...(actualStatus === 'running' ? { started_at: now } : {}),
...(status === 'done' || status === 'failed' ? { finished_at: now } : {}), ...(actualStatus === 'done' || actualStatus === 'failed' ? { finished_at: now } : {}),
...(branch !== undefined ? { branch } : {}), ...(branchToWrite !== undefined ? { branch: branchToWrite } : {}),
...(pushedAt !== undefined ? { pushed_at: pushedAt } : {}),
...(summary !== undefined ? { summary } : {}), ...(summary !== undefined ? { summary } : {}),
...(error !== undefined ? { error } : {}), ...(errorToWrite !== undefined ? { error: errorToWrite } : {}),
...(prUrl !== null ? { pr_url: prUrl } : {}),
}, },
select: { select: {
id: true, id: true,
status: true, status: true,
branch: true, branch: true,
pushed_at: true,
pr_url: true,
summary: true, summary: true,
error: true, error: true,
started_at: true, started_at: true,
@ -96,8 +243,10 @@ export function registerUpdateJobStatusTool(server: McpServer) {
task_id: job.task_id, task_id: job.task_id,
user_id: job.user_id, user_id: job.user_id,
product_id: job.product_id, product_id: job.product_id,
status, status: actualStatus,
branch: updated.branch ?? undefined, branch: updated.branch ?? undefined,
pushed_at: updated.pushed_at?.toISOString() ?? undefined,
pr_url: updated.pr_url ?? undefined,
summary: updated.summary ?? undefined, summary: updated.summary ?? undefined,
error: updated.error ?? undefined, error: updated.error ?? undefined,
}), }),
@ -108,10 +257,17 @@ export function registerUpdateJobStatusTool(server: McpServer) {
// non-fatal — status is already persisted // non-fatal — status is already persisted
} }
// Best-effort worktree cleanup on terminal transitions (skip if push failed — worktree preserved)
if ((actualStatus === 'done' || actualStatus === 'failed') && !skipWorktreeCleanup) {
await cleanupWorktreeForTerminalStatus(job.product_id, job_id, actualStatus, branchToWrite)
}
return toolJson({ return toolJson({
job_id: updated.id, job_id: updated.id,
status, status: actualStatus,
branch: updated.branch, branch: updated.branch,
pushed_at: updated.pushed_at?.toISOString() ?? null,
pr_url: updated.pr_url ?? null,
summary: updated.summary, summary: updated.summary,
error: updated.error, error: updated.error,
started_at: updated.started_at?.toISOString() ?? null, started_at: updated.started_at?.toISOString() ?? null,

View file

@ -1,28 +1,47 @@
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { z } from 'zod' import { z } from 'zod'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { prisma } from '../prisma.js' import { prisma } from '../prisma.js'
import { getAuth } from '../auth.js' import { getAuth } from '../auth.js'
import { userCanAccessTask } from '../access.js' import { userCanAccessTask } from '../access.js'
import { toolError, toolJson, withToolErrors } from '../errors.js' import { toolError, toolJson, withToolErrors } from '../errors.js'
import { buildVerifyResult, renderMarkdownReport } from '../lib/verify-plan.js' import { classifyDiffAgainstPlan, type VerifyResultValue } from '../verify/classify.js'
const exec = promisify(execFile)
const inputSchema = z.object({ const inputSchema = z.object({
task_id: z.string().min(1), task_id: z.string().min(1),
worktree_path: z.string().min(1),
}) })
export async function getDiffInWorktree(worktreePath: string): Promise<string> {
const { stdout } = await exec('git', ['diff', 'origin/main...HEAD'], { cwd: worktreePath })
return stdout
}
export async function saveVerifyResult(jobId: string, result: VerifyResultValue): Promise<void> {
await prisma.claudeJob.update({
where: { id: jobId },
data: { verify_result: result },
})
}
export function registerVerifyTaskAgainstPlanTool(server: McpServer) { export function registerVerifyTaskAgainstPlanTool(server: McpServer) {
server.registerTool( server.registerTool(
'verify_task_against_plan', 'verify_task_against_plan',
{ {
title: 'Verify task against plan', title: 'Verify task against plan',
description: description:
'Compare the frozen plan_snapshot (captured at claim time) against current ' + 'Run `git diff origin/main...HEAD` in the worktree and compare it against the ' +
'task.implementation_plan, story logs, and commits. Returns a markdown report ' + 'frozen plan_snapshot captured at claim time. Returns ALIGNED|PARTIAL|EMPTY|DIVERGENT ' +
'with per-AC ✓/✗/? heuristic checks and a drift-score. Read-only — demo users allowed.', 'and saves verify_result on the active job. ' +
'Call this BEFORE update_job_status("done"). ' +
'If the result is EMPTY and task.verify_only is false, update_job_status("done") will be rejected.',
inputSchema, inputSchema,
annotations: { readOnlyHint: true }, annotations: { readOnlyHint: false },
}, },
async ({ task_id }) => async ({ task_id, worktree_path }) =>
withToolErrors(async () => { withToolErrors(async () => {
const auth = await getAuth() const auth = await getAuth()
if (!auth) return toolError('Unauthorized') if (!auth) return toolError('Unauthorized')
@ -34,62 +53,44 @@ export function registerVerifyTaskAgainstPlanTool(server: McpServer) {
where: { id: task_id }, where: { id: task_id },
select: { select: {
id: true, id: true,
title: true, verify_only: true,
implementation_plan: true,
story: {
select: {
id: true,
acceptance_criteria: true,
logs: {
orderBy: { created_at: 'asc' },
select: {
type: true,
content: true,
commit_hash: true,
commit_message: true,
},
},
},
},
claude_jobs: { claude_jobs: {
where: { status: { in: ['CLAIMED', 'RUNNING', 'DONE', 'FAILED'] } }, where: { status: { in: ['CLAIMED', 'RUNNING'] } },
orderBy: { created_at: 'desc' }, orderBy: { created_at: 'desc' },
take: 1, take: 1,
select: { plan_snapshot: true }, select: { id: true, plan_snapshot: true },
}, },
}, },
}) })
if (!task) return toolError(`Task ${task_id} not found`) if (!task) return toolError(`Task ${task_id} not found`)
const latestJob = task.claude_jobs[0] ?? null const activeJob = task.claude_jobs[0] ?? null
const planSnapshot = latestJob ? latestJob.plan_snapshot : null
const implementationLogs = task.story.logs let diff: string
.filter((l) => l.type === 'IMPLEMENTATION_PLAN') try {
.map((l) => l.content) diff = await getDiffInWorktree(worktree_path)
} catch (err) {
return toolError(
`git diff failed in worktree (${worktree_path}): ${(err as Error).message ?? 'unknown error'}`,
)
}
const commits = task.story.logs const { result, reasoning } = classifyDiffAgainstPlan({
.filter((l) => l.type === 'COMMIT') diff,
.map((l) => ({ hash: l.commit_hash, message: l.commit_message })) plan: activeJob?.plan_snapshot ?? null,
const result = buildVerifyResult({
taskId: task.id,
taskTitle: task.title,
planSnapshot,
currentPlan: task.implementation_plan,
acceptanceCriteriaText: task.story.acceptance_criteria,
implementationLogs,
commits,
}) })
if (activeJob) {
await saveVerifyResult(activeJob.id, result)
}
return toolJson({ return toolJson({
report: renderMarkdownReport(result), result: result.toLowerCase() as 'aligned' | 'partial' | 'empty' | 'divergent',
task_id: result.taskId, reasoning,
drift_score: result.driftScore, verify_only: task.verify_only,
ac_results: result.acceptanceCriteria, task_id,
plan_edited: result.planEdited, job_id: activeJob?.id ?? null,
has_baseline: result.hasBaseline,
}) })
}), }),
) )

View file

@ -5,9 +5,63 @@
import { z } from 'zod' import { z } from 'zod'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { Client } from 'pg' import { Client } from 'pg'
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import { prisma } from '../prisma.js' import { prisma } from '../prisma.js'
import { requireWriteAccess } from '../auth.js' import { requireWriteAccess } from '../auth.js'
import { toolJson, toolError, withToolErrors } from '../errors.js' import { toolJson, toolError, withToolErrors } from '../errors.js'
import { createWorktreeForJob } from '../git/worktree.js'
export async function resolveRepoRoot(productId: string): Promise<string | null> {
const envKey = `SCRUM4ME_REPO_ROOT_${productId}`
if (process.env[envKey]) return process.env[envKey]!
const configPath = path.join(os.homedir(), '.scrum4me-agent-config.json')
try {
const raw = await fs.readFile(configPath, 'utf-8')
const config = JSON.parse(raw) as { repoRoots?: Record<string, string> }
return config.repoRoots?.[productId] ?? null
} catch {
return null
}
}
export async function rollbackClaim(jobId: string): Promise<void> {
await prisma.$executeRaw`
UPDATE claude_jobs
SET status = 'QUEUED', claimed_by_token_id = NULL, claimed_at = NULL, plan_snapshot = NULL
WHERE id = ${jobId}
`
}
export async function attachWorktreeToJob(
productId: string,
jobId: string,
): Promise<{ worktree_path: string; branch_name: string } | { error: string }> {
const repoRoot = await resolveRepoRoot(productId)
if (!repoRoot) {
await rollbackClaim(jobId)
return {
error:
`No repo root configured for product ${productId}. ` +
`Set env var SCRUM4ME_REPO_ROOT_${productId} or add to ~/.scrum4me-agent-config.json.`,
}
}
const branchName = `feat/job-${jobId.slice(-8)}`
try {
const { worktreePath, branchName: actualBranch } = await createWorktreeForJob({
repoRoot,
jobId,
branchName,
})
return { worktree_path: worktreePath, branch_name: actualBranch }
} catch (err) {
await rollbackClaim(jobId)
return { error: `Worktree creation failed: ${(err as Error).message}` }
}
}
const MAX_WAIT_SECONDS = 600 const MAX_WAIT_SECONDS = 600
const POLL_INTERVAL_MS = 5_000 const POLL_INTERVAL_MS = 5_000
@ -19,14 +73,78 @@ const inputSchema = z.object({
wait_seconds: z.number().int().min(1).max(MAX_WAIT_SECONDS).default(300), wait_seconds: z.number().int().min(1).max(MAX_WAIT_SECONDS).default(300),
}) })
export async function resetStaleClaimedJobs(userId: string) { const STALE_ERROR_MSG = 'agent did not complete job within 2 attempts'
await prisma.$executeRaw`
export async function resetStaleClaimedJobs(userId: string): Promise<void> {
// Jobs that exceeded the retry limit → FAILED
const failedRows = await prisma.$queryRaw<
Array<{ id: string; task_id: string; product_id: string }>
>`
UPDATE claude_jobs UPDATE claude_jobs
SET status = 'QUEUED', claimed_by_token_id = NULL, claimed_at = NULL, plan_snapshot = NULL SET status = 'FAILED',
finished_at = NOW(),
error = ${STALE_ERROR_MSG}
WHERE user_id = ${userId} WHERE user_id = ${userId}
AND status = 'CLAIMED' AND status = 'CLAIMED'
AND claimed_at < NOW() - INTERVAL '30 minutes' AND claimed_at < NOW() - INTERVAL '30 minutes'
AND retry_count >= 2
RETURNING id, task_id, product_id
` `
// Jobs under the retry limit → back to QUEUED, increment retry_count
const requeuedRows = await prisma.$queryRaw<
Array<{ id: string; task_id: string; product_id: string; retry_count: number }>
>`
UPDATE claude_jobs
SET status = 'QUEUED',
claimed_by_token_id = NULL,
claimed_at = NULL,
plan_snapshot = NULL,
retry_count = retry_count + 1
WHERE user_id = ${userId}
AND status = 'CLAIMED'
AND claimed_at < NOW() - INTERVAL '30 minutes'
AND retry_count < 2
RETURNING id, task_id, product_id, retry_count
`
if (failedRows.length === 0 && requeuedRows.length === 0) return
// Notify UI via SSE for each transition (best-effort)
try {
const pg = new Client({ connectionString: process.env.DATABASE_URL })
await pg.connect()
for (const j of failedRows) {
await pg.query('SELECT pg_notify($1, $2)', [
'scrum4me_changes',
JSON.stringify({
type: 'claude_job_status',
job_id: j.id,
task_id: j.task_id,
user_id: userId,
product_id: j.product_id,
status: 'failed',
error: STALE_ERROR_MSG,
}),
])
}
for (const j of requeuedRows) {
await pg.query('SELECT pg_notify($1, $2)', [
'scrum4me_changes',
JSON.stringify({
type: 'claude_job_status',
job_id: j.id,
task_id: j.task_id,
user_id: userId,
product_id: j.product_id,
status: 'queued',
}),
])
}
await pg.end()
} catch {
// non-fatal — status transitions are already persisted
}
} }
export async function tryClaimJob( export async function tryClaimJob(
@ -162,6 +280,8 @@ export function registerWaitForJobTool(server: McpServer) {
description: description:
'Block until a QUEUED ClaudeJob is available for this user, then claim it atomically ' + 'Block until a QUEUED ClaudeJob is available for this user, then claim it atomically ' +
'and return full task context (implementation_plan, story, pbi, sprint, repo_url). ' + 'and return full task context (implementation_plan, story, pbi, sprint, repo_url). ' +
'Also creates a git worktree for the job and returns worktree_path and branch_name. ' +
'Work exclusively in worktree_path — do all file edits and commits there. ' +
'Registers worker presence so the Scrum4Me UI can show "Agent verbonden". ' + 'Registers worker presence so the Scrum4Me UI can show "Agent verbonden". ' +
'Resets stale CLAIMED jobs (>30min) back to QUEUED before scanning. ' + 'Resets stale CLAIMED jobs (>30min) back to QUEUED before scanning. ' +
'Pass optional product_id to scope to a specific product. ' + 'Pass optional product_id to scope to a specific product. ' +
@ -199,7 +319,9 @@ export function registerWaitForJobTool(server: McpServer) {
if (jobId) { if (jobId) {
const ctx = await getFullJobContext(jobId) const ctx = await getFullJobContext(jobId)
if (!ctx) return toolError('Job claimed but context fetch failed') if (!ctx) return toolError('Job claimed but context fetch failed')
return toolJson(ctx) const wt = await attachWorktreeToJob(ctx.product.id, jobId)
if ('error' in wt) return toolError(wt.error)
return toolJson({ ...ctx, worktree_path: wt.worktree_path, branch_name: wt.branch_name })
} }
// 3. No job available — LISTEN and poll until timeout // 3. No job available — LISTEN and poll until timeout
@ -243,7 +365,9 @@ export function registerWaitForJobTool(server: McpServer) {
if (jobId) { if (jobId) {
const ctx = await getFullJobContext(jobId) const ctx = await getFullJobContext(jobId)
if (!ctx) return toolError('Job claimed but context fetch failed') if (!ctx) return toolError('Job claimed but context fetch failed')
return toolJson(ctx) const wt = await attachWorktreeToJob(ctx.product.id, jobId)
if ('error' in wt) return toolError(wt.error)
return toolJson({ ...ctx, worktree_path: wt.worktree_path, branch_name: wt.branch_name })
} }
} }
} finally { } finally {

125
src/verify/classify.ts Normal file
View file

@ -0,0 +1,125 @@
export type VerifyResultValue = 'ALIGNED' | 'PARTIAL' | 'EMPTY' | 'DIVERGENT'
export interface ClassifyResult {
result: VerifyResultValue
reasoning: string
}
// Extract changed file paths from a unified diff ("+++ b/<path>" lines).
function extractDiffPaths(diff: string): string[] {
const paths = new Set<string>()
for (const line of diff.split('\n')) {
const m = line.match(/^\+\+\+ b\/(.+)$/)
if (m && m[1].trim() !== '/dev/null') paths.add(m[1].trim())
}
return [...paths]
}
// Extract file paths mentioned in a plan (backtick-quoted, parenthesised, or bullet-list headings).
function extractPlanPaths(plan: string): string[] {
const paths = new Set<string>()
const backtickRe = /`([^`\s][^`]*[^`\s]|[^`\s])`/g
let m: RegExpExecArray | null
while ((m = backtickRe.exec(plan)) !== null) {
const p = m[1].trim()
if ((p.includes('/') || p.includes('.')) && !p.includes(' ') && p.length > 3) paths.add(p)
}
const bulletRe = /^[-*]\s+\*{0,2}([^\s*][^\s]*)\.([a-zA-Z]{1,6})\*{0,2}\s*[:\n]/gm
while ((m = bulletRe.exec(plan)) !== null) {
paths.add(`${m[1]}.${m[2]}`)
}
return [...paths]
}
// Path match: exact or suffix match so "classify.ts" matches "src/verify/classify.ts".
function pathMatches(planPath: string, diffPaths: string[]): boolean {
const norm = planPath.replace(/\\/g, '/')
return diffPaths.some((dp) => {
const ndp = dp.replace(/\\/g, '/')
return ndp === norm || ndp.endsWith(`/${norm}`) || norm.endsWith(`/${ndp}`)
})
}
/**
* Classify a unified git diff against an implementation plan.
* Returns a VerifyResult (ALIGNED|PARTIAL|EMPTY|DIVERGENT) plus human-readable reasoning.
*
* v1 heuristic no LLM required:
* - No file changes in diff EMPTY
* - Plan empty, diff 50 changed lines ALIGNED (targeted fix)
* - Plan empty, diff >50 changed lines DIVERGENT (too large to trust)
* - Plan paths < 100% covered in diff PARTIAL
* - Plan paths 100% covered, diff 3× more paths DIVERGENT (scope creep)
* - Plan paths 100% covered, diff <3× more paths ALIGNED
*/
export function classifyDiffAgainstPlan(opts: {
diff: string
plan: string | null
}): ClassifyResult {
const { diff, plan } = opts
const diffPaths = extractDiffPaths(diff)
if (diffPaths.length === 0) {
return { result: 'EMPTY', reasoning: 'Geen bestandswijzigingen in de diff.' }
}
const changedLines = diff.split('\n').filter((l) => l.startsWith('+') || l.startsWith('-')).length
if (!plan || plan.trim().length === 0) {
if (changedLines > 50) {
return {
result: 'DIVERGENT',
reasoning: `Geen plan-baseline aanwezig; ${changedLines} gewijzigde regels — te groot om als aligned te bestempelen.`,
}
}
return {
result: 'ALIGNED',
reasoning: `Geen plan-baseline; ${diffPaths.length} bestand(en) gewijzigd — kleine gerichte wijziging.`,
}
}
const planPaths = extractPlanPaths(plan)
if (planPaths.length === 0) {
if (changedLines > 50) {
return {
result: 'DIVERGENT',
reasoning: `Plan vermeldt geen specifieke paden; ${changedLines} gewijzigde regels — te groot om als aligned te bestempelen.`,
}
}
return {
result: 'ALIGNED',
reasoning: `Plan vermeldt geen specifieke paden; ${diffPaths.length} bestand(en) gewijzigd.`,
}
}
const covered = planPaths.filter((pp) => pathMatches(pp, diffPaths))
const coverage = covered.length / planPaths.length
const ratio = diffPaths.length / planPaths.length
if (coverage < 1) {
const missing = planPaths.filter((pp) => !pathMatches(pp, diffPaths))
const missingStr = missing.slice(0, 3).join(', ') + (missing.length > 3 ? ` + ${missing.length - 3} meer` : '')
return {
result: 'PARTIAL',
reasoning: `${covered.length}/${planPaths.length} plan-paden aanwezig in diff. Ontbrekend: ${missingStr}.`,
}
}
if (ratio >= 3) {
const extra = diffPaths.filter((dp) => !planPaths.some((pp) => pathMatches(pp, [dp])))
const extraStr = extra.slice(0, 3).join(', ') + (extra.length > 3 ? ` + ${extra.length - 3} meer` : '')
return {
result: 'DIVERGENT',
reasoning: `Alle ${planPaths.length} plan-paden aanwezig, maar diff bevat ${diffPaths.length} paden (${ratio.toFixed(1)}x). Extra: ${extraStr}.`,
}
}
return {
result: 'ALIGNED',
reasoning: `Alle ${planPaths.length} plan-paden aanwezig in diff (${diffPaths.length} totaal; ${ratio.toFixed(1)}x).`,
}
}