Compare commits

..

1 commit

Author SHA1 Message Date
a6e112cbae docs: branch-per-story flow + heartbeat self-heal in CLAUDE.md
- Worktree-flow section now describes resolveBranchForJob (sibling
  reuse), feat/story-<id> naming, deferred cleanup while siblings are
  active, and the 1-PR-per-story result.
- File table corrects the heartbeat description (PR #14 made it
  self-healing instead of self-terminating).

Closes the docs task in story 'Voorkom doublure-PRs' under PBI 'Veilige
Claude-agent-workflow'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:25:08 +02:00
103 changed files with 624 additions and 10916 deletions

View file

@ -1,15 +0,0 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "mcp__scrum4me__update_job_status",
"hooks": [
{
"type": "command",
"command": "tsx \"${SCRUM4ME_MCP_DIR:-$CLAUDE_PROJECT_DIR}/scripts/persist-job-usage.ts\""
}
]
}
]
}
}

View file

@ -3,9 +3,3 @@ DATABASE_URL="postgresql://user:pass@host:5432/dbname"
# API token from Scrum4Me → /settings/tokens
SCRUM4ME_TOKEN=""
# Internal push endpoint (main-app) for web-push notifications
# Set to the main-app /api/internal/push/send URL; leave empty to disable push (feature-gated).
INTERNAL_PUSH_URL="https://scrum4me.example.com/api/internal/push/send"
# Shared secret (≥32 chars) — must match INTERNAL_PUSH_SECRET in the main-app env.
INTERNAL_PUSH_SECRET="<generate-with: openssl rand -hex 32>"

3
.gitignore vendored
View file

@ -12,6 +12,3 @@ prisma/generated
# Editor
.vscode
.idea
# Claude Code worktrees (per-session, never tracked)
.claude/worktrees/

View file

@ -1,35 +0,0 @@
# Changelog
All notable changes to scrum4me-mcp.
## [0.6.0] — 2026-05-04
Adds support for Scrum4Me M12 (Idea entity + Grill/Plan jobs).
### Added
- **`get_idea_context(idea_id)`** — fetch full idea + product + recent logs + open questions for agent context.
- **`update_idea_grill_md(idea_id, markdown)`** — save grill-result + transition to GRILLED + IdeaLog{GRILL_RESULT}.
- **`update_idea_plan_md(idea_id, markdown)`** — save plan with server-side yaml-frontmatter validation; ok → PLAN_READY, parse-fail → PLAN_FAILED + IdeaLog{JOB_EVENT, errors}.
- **`log_idea_decision(idea_id, type, content, metadata?)`** — DECISION/NOTE entries on the idea timeline.
### Changed
- **`ask_user_question`** — now accepts exact one of `story_id` OR `idea_id` (zod xor refine). Idea-questions are user-private (owner-scoped, no productAccessFilter).
- **`wait_for_job`** — response now includes `kind: 'TASK_IMPLEMENTATION' | 'IDEA_GRILL' | 'IDEA_MAKE_PLAN'`. For idea-jobs the payload returns `idea`, `product`, `repo_url`, `prompt_text` (embedded prompt from `src/prompts/idea/`) and **no worktree** (agent works in user's existing repo).
- **`update_job_status`** — for `failed` on `IDEA_GRILL` / `IDEA_MAKE_PLAN`: idea status auto-transitions to `GRILL_FAILED` / `PLAN_FAILED` + IdeaLog{JOB_EVENT}. Auto-PR + worktree-cleanup skipped for idea-jobs.
- **Health version** — now read dynamically from `package.json` at module load (was hardcoded; resolved sync-issues at deploy time).
### Schema
- Vendored `prisma/schema.prisma` synced with Scrum4Me M12 (Idea + IdeaLog models, IdeaStatus + ClaudeJobKind + IdeaLogType enums, ClaudeJob.task_id nullable + idea_id + kind, ClaudeQuestion.story_id nullable + idea_id, check-constraints, pg_notify-trigger update).
- Pinned to scrum4me commit on branch `feat/m12-ideas` until merged to main.
### Migration notes
- Requires Scrum4Me database to have M12 migration applied (`20260504172747_add_ideas_and_grill_jobs`).
- Worker runtime: see `vendor/scrum4me/docs/runbooks/mcp-integration.md` — batch-loop now switches on `kind` discriminator.
## [0.5.0] — earlier
Version bump (no changelog entry).

View file

@ -26,16 +26,6 @@ MCP server that exposes the Scrum4Me dev-flow as native tools for Claude Code.
A story with 3 sub-tasks lands as **1 branch** with 3 commits and **1 PR** (assuming `auto_pr=true`). Sibling sub-tasks share the same `pr_url``maybeCreateAutoPr` reuses an existing PR from a sibling job instead of opening duplicates. Story-level PR title (`<story-code>: <story-title>`) so the GitHub view reads as one logical change rather than per-task fragments.
### PBI fail-cascade
When a `TASK_IMPLEMENTATION` job ends in `FAILED`, `cancelPbiOnFailure` (`src/cancel/pbi-cascade.ts`) cancels every queued/claimed/running sibling under the **same PBI** (across all stories) and undoes already-pushed commits:
- **Open PR**`gh pr close --delete-branch` with a cascade-comment.
- **Merged PR** → revert-PR opened against the base branch via `git revert -m 1 <mergeSha>`. **No** auto-merge on the revert PR — review by hand.
- **Branch without PR** → best-effort `git push origin --delete <branch>`.
A trace (cancelled job count, closed/reverted PRs, deleted branches) is written to the original failed job's `error` column. Race-protection: if a parallel worker tries to `update_job_status` on a job that the cascade already set to `CANCELLED`, the call is rejected with a `JOB_CANCELLED` error so the agent discards local work and calls `wait_for_job` again. The cascade is idempotent and never throws — failures become warnings on the failed-job's trace.
### Required configuration
Set env var per product:
@ -56,35 +46,18 @@ Or add to `~/.scrum4me-agent-config.json`:
If no repo root is found, `wait_for_job` rolls the claim back to QUEUED and returns an error.
## Token-usage capture (PostToolUse hook)
`update_job_status` accepts optional fields `model_id`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`. The agent never has to pass them — `scripts/persist-job-usage.ts` runs as a PostToolUse hook, reads the local Claude Code transcript JSONL (no Anthropic API needed), sums per-job usage, and writes directly to `claude_jobs` via Prisma. Window detection: from the most-recent `wait_for_job` tool_use to EOF.
The hook is registered in `.claude/settings.json` of this repo. **For agent-worker mode** (Claude Code running with cwd inside a product worktree, not scrum4me-mcp), copy the same hook block into your user settings (`~/.claude/settings.json`) and set `SCRUM4ME_MCP_DIR` so the script resolves regardless of cwd:
```bash
export SCRUM4ME_MCP_DIR=/absolute/path/to/scrum4me-mcp
```
Pricing rows (`model_prices`) are seeded by Scrum4Me's `prisma/seed.ts`. Unknown `model_id`s leave `cost_usd = NULL` in Insights queries — add a row and re-run `npm run seed` to fill them in.
Robustness notes:
- Subagent (`isSidechain: true`) lines in the main JSONL are skipped to avoid double-counting against `subagents/`-subdirectory transcripts.
- Lines are deduplicated on `uuid` because branching/resumption can rewrite the same message into multiple JSONLs.
- Known Claude Code bug: auto-updates can silently delete files under `~/.claude/projects/`. If you depend on these numbers for billing/reporting, persist `claude_jobs.input_tokens` etc. immediately on `update_job_status` (already what this hook does) and consider an external backup of `~/.claude/projects/` if you want to retain historical detail.
## 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 }`.
## Worker presence
Server-startup registers a `ClaudeWorker` record + starts a 10 s heartbeat; SIGTERM/SIGINT cleans it up. The Scrum4Me NavBar counts active workers via `last_seen_at < now() - 15s` — at 10 s interval one missed tick + jitter can flicker the indicator; bump that threshold in Scrum4Me to ≥ 25 s if needed.
Server-startup registers a `ClaudeWorker` record + starts a 5 s heartbeat; SIGTERM/SIGINT cleans it up. The Scrum4Me NavBar counts active workers via `last_seen_at < now() - 15s`.
| File | Purpose |
|---|---|
| `src/presence/worker.ts` | `registerWorker` (upsert + pg_notify worker_connected) + `unregisterWorker` |
| `src/presence/heartbeat.ts` | `startHeartbeat`10 s interval, self-heals by re-registering when record disappears |
| `src/presence/heartbeat.ts` | `startHeartbeat` — 5 s interval, self-heals by re-registering when record disappears |
| `src/presence/shutdown.ts` | `registerShutdownHandlers` — SIGTERM/SIGINT → stop heartbeat + unregister |
| `src/index.ts` | Bootstrap: calls `getAuth``registerWorker``startHeartbeat``registerShutdownHandlers` |

145
README.md
View file

@ -28,13 +28,6 @@ activity and create todos via native tool calls instead of curl.
| `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; 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) |
| `cleanup_my_worktrees` | Remove stale git worktrees left by crashed or cancelled agent runs | no |
| `check_queue_empty` | Synchronous, non-blocking count of active jobs (QUEUED/CLAIMED/RUNNING); optional `product_id` scope | no |
| `set_pbi_pr` | Write `pr_url` on a PBI and clear `pr_merged_at`. Idempotent: re-calling overwrites `pr_url` and resets `pr_merged_at` to null | no |
| `mark_pbi_pr_merged` | Set `pr_merged_at = now()` on a PBI. Requires `pr_url` to already be set. Idempotent: re-calling overwrites the timestamp | no |
| `verify_sprint_task` | SPRINT_IMPLEMENTATION-flow: compare a `SprintTaskExecution`'s frozen `plan_snapshot` against `git diff <base_sha>...HEAD`. Returns `verify_result` + `allowed_for_done`. For `task[1..N]` zonder base_sha vult de tool die in op basis van de head_sha van de vorige DONE-execution | yes (read-only) |
| `update_task_execution` | SPRINT_IMPLEMENTATION-flow: mutate `SprintTaskExecution.status` (PENDING/RUNNING/DONE/FAILED/SKIPPED). Token must own the parent SPRINT-job. Idempotent | no |
| `job_heartbeat` | Extend `claude_jobs.lease_until` by 5 min. For SPRINT-jobs: response includes `sprint_run_status` + `sprint_run_pause_reason` so the worker can break its task-loop on UI-side cancel/pause | no |
Demo accounts may read but writes return `PERMISSION_DENIED`.
@ -78,110 +71,6 @@ Compares the immutable snapshot captured at claim time against the current state
- Plan_snapshot is NULL voor jobs die zijn geclaimed vóór versie met snapshot-feature — rapport meldt "no baseline"
- Gebruik het rapport als startpunt, niet als definitief oordeel; PR-review blijft leidend
### set_pbi_pr
Links a GitHub Pull Request to a PBI and clears any previous merge timestamp. Safe to call multiple times — idempotent.
**Input**
```json
{ "pbi_id": "cmoprewcf000q...", "pr_url": "https://github.com/owner/repo/pull/42" }
```
`pr_url` must match `^https://github\.com/[^/]+/[^/]+/pull/\d+$`. Any other format is rejected with a schema error.
**Output**
```json
{ "ok": true, "pbi_id": "cmoprewcf000q...", "pr_url": "https://github.com/owner/repo/pull/42" }
```
**Errors**
| Condition | Message |
|---|---|
| PBI not found or inaccessible | `PBI <id> not found or not accessible` |
| Demo account | `PERMISSION_DENIED: Demo accounts cannot perform write operations` |
| Invalid URL format | `VALIDATION_ERROR: pr_url: Invalid` |
### mark_pbi_pr_merged
Records that the linked PR has been merged by setting `pr_merged_at = now()`. Requires `set_pbi_pr` to have been called first. Idempotent: re-calling overwrites the timestamp.
**Input**
```json
{ "pbi_id": "cmoprewcf000q..." }
```
**Output**
```json
{
"ok": true,
"pbi_id": "cmoprewcf000q...",
"pr_url": "https://github.com/owner/repo/pull/42",
"pr_merged_at": "2026-05-03T12:00:00.000Z"
}
```
**Errors**
| Condition | Message |
|---|---|
| PBI not found or inaccessible | `PBI <id> not found or not accessible` |
| `pr_url` not set | `PBI <id> heeft geen gekoppelde PR` |
| Demo account | `PERMISSION_DENIED: Demo accounts cannot perform write operations` |
### check_queue_empty
Synchronous, non-blocking poll that returns how many ClaudeJobs are still active (`QUEUED`, `CLAIMED`, `RUNNING`). No blocking — returns immediately. Use it after the last `update_job_status('done')` in a batch to decide whether to stay in the loop or finalise.
**Input**
```json
{ "product_id": "cmoprewcf000q..." } // optional — omit to aggregate all products
```
**Output — empty queue**
```json
{ "empty": true, "remaining": 0, "by_product": {} }
```
**Output — with product_id (non-empty)**
```json
{ "empty": false, "remaining": 2 }
```
**Output — without product_id (per-product split)**
```json
{
"empty": false,
"remaining": 3,
"by_product": {
"cmoprewcf000q...": 2,
"cmohry5yj0001...": 1
}
}
```
**Agent decision rule**
| `empty` | Action |
|---|---|
| `false` | Stay in loop — call `wait_for_job` again immediately |
| `true` | Finalise — push branch, open PR (if `auto_pr`), recap, exit |
**Errors**
| Condition | Message |
|---|---|
| `product_id` provided but not accessible | `Product <id> not found or not accessible` |
| Demo account | `PERMISSION_DENIED: Demo accounts cannot perform write operations` |
## Prompts
- `implement_next_story` — full workflow: fetch context, log plan, walk
@ -296,10 +185,6 @@ Minimale agent-prompt (geen CLAUDE.md-context nodig):
> *Pak de volgende job uit de Scrum4Me-queue.*
## Web-push integration
When `INTERNAL_PUSH_URL` and `INTERNAL_PUSH_SECRET` are set, the MCP server fires a fire-and-forget push notification to the main-app's internal endpoint (`/api/internal/push/send`) on two events: when `ask_user_question` creates a new question (tag `claude-q-<id>`), and when `update_job_status` transitions a job to `done` or `failed` (tag `job-<id>`). Both calls are wrapped in a 5 s `AbortController` timeout and a `try/catch` so a push failure never interrupts the tool response. Omitting the env vars disables the feature entirely. The `INTERNAL_PUSH_SECRET` value must match the one configured in the main-app; generate a fresh secret with `openssl rand -hex 32`.
## Schema sync
The Prisma schema is the source of truth in the upstream Scrum4Me
@ -342,33 +227,3 @@ npx @modelcontextprotocol/inspector node dist/index.js
- **Production database** — verify against a preview database before
running against prod. The token check enforces user scope but does
not gate reads of unrelated products you happen to be a member of.
## Worktrees
Scrum4Me-mcp uses git worktrees rooted at `~/.scrum4me-agent-worktrees/` (override via `SCRUM4ME_AGENT_WORKTREE_DIR`).
### Two kinds of worktrees
- **Per-job task-worktrees** (`<jobId>/`) — one per `TASK_IMPLEMENTATION` job. Created at claim, cleaned up on `DONE`/`FAILED`/`CANCELLED` via `cleanup_my_worktrees`.
- **Persistent product-worktrees** (`_products/<productId>/`) — one per product with `repo_url`, used by `IDEA_GRILL` and `IDEA_MAKE_PLAN`. **Detached HEAD on `origin/main`**, hard-reset at every job start. `.scratch/` holds throw-away work and is wiped on each claim.
### Concurrency: file-locks
Product-worktrees are serialised via `proper-lockfile` on `_products/<productId>.lock`. Two parallel idea-jobs on the same product wait for each other. For multi-product idea-jobs, locks are acquired in alphabetical order to prevent deadlocks.
### Single-host invariant
`proper-lockfile` only works when all MCP-server processes run on the same host. Migrate to Postgres `pg_advisory_lock` when:
- multiple MCP instances on different machines serve workers, or
- the worktree directory is shared over NFS/CIFS.
Migration path: replace `acquireFileLock` in `src/git/file-lock.ts` with a `pg_try_advisory_lock(hashtext(path)::bigint)` wrapper via the existing Prisma connection. The API stays identical.
### Manual cleanup
`cleanup_my_worktrees` skips `_products/` and `*.lock` automatically. To clean up a product-worktree manually (after archive or repo-rename):
```bash
git worktree remove --force ~/.scrum4me-agent-worktrees/_products/<productId>
rm ~/.scrum4me-agent-worktrees/_products/<productId>.lock # if still present
```

View file

@ -1,350 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../src/prisma.js', () => ({
prisma: {
claudeJob: {
findUnique: vi.fn(),
findMany: vi.fn(),
updateMany: vi.fn(),
update: 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('../src/git/worktree.js', () => ({
removeWorktreeForJob: vi.fn(),
}))
vi.mock('../src/git/pr.js', () => ({
closePullRequest: vi.fn(),
getPullRequestState: vi.fn(),
createRevertPullRequest: vi.fn(),
}))
vi.mock('../src/git/push.js', () => ({
deleteRemoteBranch: vi.fn(),
}))
import { prisma } from '../src/prisma.js'
import { resolveRepoRoot } from '../src/tools/wait-for-job.js'
import { removeWorktreeForJob } from '../src/git/worktree.js'
import {
closePullRequest,
getPullRequestState,
createRevertPullRequest,
} from '../src/git/pr.js'
import { deleteRemoteBranch } from '../src/git/push.js'
import { cancelPbiOnFailure } from '../src/cancel/pbi-cascade.js'
const mockPrisma = prisma as unknown as {
claudeJob: {
findUnique: ReturnType<typeof vi.fn>
findMany: ReturnType<typeof vi.fn>
updateMany: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
}
const mockResolveRepoRoot = resolveRepoRoot as ReturnType<typeof vi.fn>
const mockRemoveWorktree = removeWorktreeForJob as ReturnType<typeof vi.fn>
const mockClosePr = closePullRequest as ReturnType<typeof vi.fn>
const mockGetPrState = getPullRequestState as ReturnType<typeof vi.fn>
const mockCreateRevertPr = createRevertPullRequest as ReturnType<typeof vi.fn>
const mockDeleteBranch = deleteRemoteBranch as ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
mockPrisma.claudeJob.update.mockResolvedValue({})
mockPrisma.claudeJob.updateMany.mockResolvedValue({ count: 0 })
mockResolveRepoRoot.mockResolvedValue('/repos/proj')
mockRemoveWorktree.mockResolvedValue(undefined)
// Sensible defaults so an un-stubbed branch in a test doesn't throw on
// `result.deleted` / `result.ok` access. Tests that care override these.
mockDeleteBranch.mockResolvedValue({ deleted: true })
mockClosePr.mockResolvedValue({ ok: true })
})
const FAILED_JOB = {
id: 'job-failed',
kind: 'TASK_IMPLEMENTATION',
product_id: 'prod-1',
task_id: 'task-failed',
branch: 'feat/story-aaaabbbb',
pr_url: null,
task: { story: { pbi: { id: 'pbi-1', code: 'PBI-7' } } },
}
describe('cancelPbiOnFailure', () => {
it('no-ops for non-TASK_IMPLEMENTATION jobs', async () => {
mockPrisma.claudeJob.findUnique.mockResolvedValue({ ...FAILED_JOB, kind: 'IDEA_GRILL' })
const out = await cancelPbiOnFailure('job-failed')
expect(out.cancelled_job_ids).toEqual([])
expect(mockPrisma.claudeJob.findMany).not.toHaveBeenCalled()
expect(mockPrisma.claudeJob.updateMany).not.toHaveBeenCalled()
})
it('no-ops when failed job has no PBI parent', async () => {
mockPrisma.claudeJob.findUnique.mockResolvedValue({
...FAILED_JOB,
task: null,
})
const out = await cancelPbiOnFailure('job-failed')
expect(out).toEqual({
cancelled_job_ids: [],
closed_prs: [],
reverted_prs: [],
deleted_branches: [],
warnings: [],
})
})
it('cancels eligible siblings and writes a trace to the failed job', async () => {
mockPrisma.claudeJob.findUnique.mockResolvedValue(FAILED_JOB)
mockPrisma.claudeJob.findMany.mockResolvedValue([
{ id: 'job-sib1', branch: 'feat/story-aaaabbbb', pr_url: null, status: 'QUEUED', task_id: 't2' },
{ id: 'job-sib2', branch: 'feat/story-ccccdddd', pr_url: null, status: 'CLAIMED', task_id: 't3' },
])
const out = await cancelPbiOnFailure('job-failed')
expect(mockPrisma.claudeJob.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: { in: ['job-sib1', 'job-sib2'] } },
data: expect.objectContaining({
status: 'CANCELLED',
error: 'cancelled_by_pbi_failure',
}),
}),
)
expect(out.cancelled_job_ids).toEqual(['job-sib1', 'job-sib2'])
expect(mockPrisma.claudeJob.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'job-failed' },
data: expect.objectContaining({ error: expect.stringContaining('cancelled_by_self') }),
}),
)
})
it('idempotent: empty eligible set means no updateMany call', async () => {
mockPrisma.claudeJob.findUnique.mockResolvedValue(FAILED_JOB)
mockPrisma.claudeJob.findMany.mockResolvedValue([])
await cancelPbiOnFailure('job-failed')
expect(mockPrisma.claudeJob.updateMany).not.toHaveBeenCalled()
})
it('closes an open PR with the cascade comment', async () => {
mockPrisma.claudeJob.findUnique.mockResolvedValue({
...FAILED_JOB,
pr_url: 'https://github.com/o/r/pull/1',
})
mockPrisma.claudeJob.findMany.mockResolvedValue([])
mockGetPrState.mockResolvedValue({
state: 'OPEN',
mergeCommit: null,
baseRefName: 'main',
title: 'feat: x',
})
mockClosePr.mockResolvedValue({ ok: true })
const out = await cancelPbiOnFailure('job-failed')
expect(mockClosePr).toHaveBeenCalledWith(
expect.objectContaining({
prUrl: 'https://github.com/o/r/pull/1',
comment: expect.stringContaining('PBI PBI-7'),
}),
)
expect(out.closed_prs).toEqual(['https://github.com/o/r/pull/1'])
})
it('creates a revert-PR when an affected PR is already merged', async () => {
mockPrisma.claudeJob.findUnique.mockResolvedValue({
...FAILED_JOB,
pr_url: 'https://github.com/o/r/pull/9',
})
mockPrisma.claudeJob.findMany.mockResolvedValue([])
mockGetPrState.mockResolvedValue({
state: 'MERGED',
mergeCommit: 'abc123def',
baseRefName: 'main',
title: 'feat: shipped',
})
mockCreateRevertPr.mockResolvedValue({ url: 'https://github.com/o/r/pull/10' })
const out = await cancelPbiOnFailure('job-failed')
expect(mockCreateRevertPr).toHaveBeenCalledWith(
expect.objectContaining({
repoRoot: '/repos/proj',
mergeSha: 'abc123def',
baseRef: 'main',
originalTitle: 'feat: shipped',
originalBranch: 'feat/story-aaaabbbb',
jobId: 'job-failed',
pbiCode: 'PBI-7',
}),
)
expect(out.reverted_prs).toEqual([
{ original: 'https://github.com/o/r/pull/9', revertPr: 'https://github.com/o/r/pull/10' },
])
expect(mockClosePr).not.toHaveBeenCalled()
})
it('skips revert when no repo root is configured + emits a warning', async () => {
mockResolveRepoRoot.mockResolvedValue(null)
mockPrisma.claudeJob.findUnique.mockResolvedValue({
...FAILED_JOB,
pr_url: 'https://github.com/o/r/pull/9',
})
mockPrisma.claudeJob.findMany.mockResolvedValue([])
mockGetPrState.mockResolvedValue({
state: 'MERGED',
mergeCommit: 'abc',
baseRefName: 'main',
title: 'x',
})
const out = await cancelPbiOnFailure('job-failed')
expect(mockCreateRevertPr).not.toHaveBeenCalled()
expect(out.warnings.some((w) => /no repo root/i.test(w))).toBe(true)
})
it('deletes a remote branch when there is no PR for it', async () => {
mockPrisma.claudeJob.findUnique.mockResolvedValue({
...FAILED_JOB,
pr_url: null,
})
mockPrisma.claudeJob.findMany.mockResolvedValue([])
mockDeleteBranch.mockResolvedValue({ deleted: true })
const out = await cancelPbiOnFailure('job-failed')
expect(mockDeleteBranch).toHaveBeenCalledWith({
repoRoot: '/repos/proj',
branch: 'feat/story-aaaabbbb',
})
expect(out.deleted_branches).toEqual(['feat/story-aaaabbbb'])
})
it('groups siblings sharing a branch so the PR is only closed once', async () => {
mockPrisma.claudeJob.findUnique.mockResolvedValue({
...FAILED_JOB,
branch: 'feat/story-shared',
pr_url: 'https://github.com/o/r/pull/1',
})
mockPrisma.claudeJob.findMany.mockResolvedValue([
{
id: 'job-sib',
branch: 'feat/story-shared',
pr_url: 'https://github.com/o/r/pull/1',
status: 'QUEUED',
task_id: 't2',
},
])
mockGetPrState.mockResolvedValue({
state: 'OPEN',
mergeCommit: null,
baseRefName: 'main',
title: 't',
})
mockClosePr.mockResolvedValue({ ok: true })
await cancelPbiOnFailure('job-failed')
expect(mockClosePr).toHaveBeenCalledTimes(1)
})
it('removes worktrees of cancelled siblings', async () => {
mockPrisma.claudeJob.findUnique.mockResolvedValue(FAILED_JOB)
mockPrisma.claudeJob.findMany.mockResolvedValue([
{ id: 'job-sib1', branch: null, pr_url: null, status: 'QUEUED', task_id: 't2' },
])
await cancelPbiOnFailure('job-failed')
expect(mockRemoveWorktree).toHaveBeenCalledWith({
repoRoot: '/repos/proj',
jobId: 'job-sib1',
keepBranch: false,
})
})
it('never throws — wraps unexpected errors into warnings', async () => {
mockPrisma.claudeJob.findUnique.mockRejectedValue(new Error('boom'))
const out = await cancelPbiOnFailure('job-failed')
expect(out.warnings.some((w) => w.includes('boom'))).toBe(true)
})
it('no-ops when failed job has status SKIPPED (no-op exit, niet een echte fail)', async () => {
mockPrisma.claudeJob.findUnique.mockResolvedValue({ ...FAILED_JOB, status: 'SKIPPED' })
const out = await cancelPbiOnFailure('job-failed')
expect(out.cancelled_job_ids).toEqual([])
expect(mockPrisma.claudeJob.findMany).not.toHaveBeenCalled()
expect(mockPrisma.claudeJob.updateMany).not.toHaveBeenCalled()
expect(mockPrisma.claudeJob.update).not.toHaveBeenCalled()
})
it('appends the cascade trace to an existing error (preserves original cause)', async () => {
// findUnique wordt twee keer aangeroepen: eerst voor failedJob (status FAILED + originele error),
// daarna door de append-trace om de huidige error te lezen vóór update.
mockPrisma.claudeJob.findUnique
.mockResolvedValueOnce({ ...FAILED_JOB, status: 'FAILED' })
.mockResolvedValueOnce({ error: 'timeout: agent died after 5min' })
mockPrisma.claudeJob.findMany.mockResolvedValue([])
await cancelPbiOnFailure('job-failed')
expect(mockPrisma.claudeJob.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'job-failed' },
data: expect.objectContaining({
error: expect.stringMatching(/timeout: agent died after 5min[\s\S]*---[\s\S]*cancelled_by_self/),
}),
}),
)
})
it('falls back to trace-only when there is no existing error', async () => {
mockPrisma.claudeJob.findUnique
.mockResolvedValueOnce({ ...FAILED_JOB, status: 'FAILED' })
.mockResolvedValueOnce({ error: null })
mockPrisma.claudeJob.findMany.mockResolvedValue([])
await cancelPbiOnFailure('job-failed')
const updateCall = mockPrisma.claudeJob.update.mock.calls[0]?.[0] as
| { data: { error: string } }
| undefined
expect(updateCall?.data.error).toMatch(/^cancelled_by_self/)
expect(updateCall?.data.error).not.toContain('---')
})
it('truncates the merged error at 1900 chars while preserving the head of the original', async () => {
const longOriginal = 'X'.repeat(1800)
mockPrisma.claudeJob.findUnique
.mockResolvedValueOnce({ ...FAILED_JOB, status: 'FAILED' })
.mockResolvedValueOnce({ error: longOriginal })
mockPrisma.claudeJob.findMany.mockResolvedValue([])
await cancelPbiOnFailure('job-failed')
const updateCall = mockPrisma.claudeJob.update.mock.calls[0]?.[0] as
| { data: { error: string } }
| undefined
expect(updateCall?.data.error.length).toBeLessThanOrEqual(1900)
expect(updateCall?.data.error.startsWith('X')).toBe(true)
})
})

View file

@ -1,144 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../src/prisma.js', () => ({
prisma: {
claudeJob: {
count: vi.fn(),
groupBy: vi.fn(),
},
},
}))
vi.mock('../src/auth.js', async (importOriginal) => {
const original = await importOriginal<typeof import('../src/auth.js')>()
return { ...original, requireWriteAccess: vi.fn() }
})
vi.mock('../src/access.js', () => ({
userCanAccessProduct: vi.fn(),
}))
import { prisma } from '../src/prisma.js'
import { requireWriteAccess, PermissionDeniedError } from '../src/auth.js'
import { userCanAccessProduct } from '../src/access.js'
import { registerCheckQueueEmptyTool } from '../src/tools/check-queue-empty.js'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
const mockPrisma = prisma as unknown as {
claudeJob: {
count: ReturnType<typeof vi.fn>
groupBy: ReturnType<typeof vi.fn>
}
}
const mockRequireWriteAccess = requireWriteAccess as ReturnType<typeof vi.fn>
const mockUserCanAccessProduct = userCanAccessProduct as ReturnType<typeof vi.fn>
const USER_ID = 'user-abc'
const PRODUCT_A = 'product-aaa'
const PRODUCT_B = 'product-bbb'
function makeServer() {
let handler: (args: Record<string, unknown>) => Promise<unknown>
const server = {
registerTool: vi.fn((_name: string, _meta: unknown, fn: typeof handler) => {
handler = fn
}),
call: (args: Record<string, unknown>) => handler(args),
}
registerCheckQueueEmptyTool(server as unknown as McpServer)
return server
}
beforeEach(() => {
vi.clearAllMocks()
mockRequireWriteAccess.mockResolvedValue({ userId: USER_ID, tokenId: 'tok-1', username: 'agent', isDemo: false })
mockUserCanAccessProduct.mockResolvedValue(true)
})
describe('check_queue_empty — no product_id', () => {
it('returns empty:true when no active jobs exist', async () => {
mockPrisma.claudeJob.groupBy.mockResolvedValue([])
const server = makeServer()
const result = await server.call({}) as { content: { text: string }[] }
const body = JSON.parse(result.content[0].text)
expect(body).toEqual({ empty: true, remaining: 0, by_product: {} })
})
it('returns correct counts for one product with active jobs', async () => {
mockPrisma.claudeJob.groupBy.mockResolvedValue([{ product_id: PRODUCT_A, _count: 3 }])
const server = makeServer()
const result = await server.call({}) as { content: { text: string }[] }
const body = JSON.parse(result.content[0].text)
expect(body).toEqual({ empty: false, remaining: 3, by_product: { [PRODUCT_A]: 3 } })
})
it('aggregates across two products', async () => {
mockPrisma.claudeJob.groupBy.mockResolvedValue([
{ product_id: PRODUCT_A, _count: 2 },
{ product_id: PRODUCT_B, _count: 1 },
])
const server = makeServer()
const result = await server.call({}) as { content: { text: string }[] }
const body = JSON.parse(result.content[0].text)
expect(body).toEqual({
empty: false,
remaining: 3,
by_product: { [PRODUCT_A]: 2, [PRODUCT_B]: 1 },
})
})
it('passes correct where clause to groupBy', async () => {
mockPrisma.claudeJob.groupBy.mockResolvedValue([])
const server = makeServer()
await server.call({})
expect(mockPrisma.claudeJob.groupBy).toHaveBeenCalledWith(
expect.objectContaining({
by: ['product_id'],
where: expect.objectContaining({
user_id: USER_ID,
status: { in: expect.arrayContaining(['QUEUED', 'CLAIMED', 'RUNNING']) },
product: expect.objectContaining({ OR: expect.any(Array) }),
}),
_count: true,
}),
)
})
})
describe('check_queue_empty — with product_id', () => {
it('returns empty:true when product queue is empty', async () => {
mockPrisma.claudeJob.count.mockResolvedValue(0)
const server = makeServer()
const result = await server.call({ product_id: PRODUCT_A }) as { content: { text: string }[] }
const body = JSON.parse(result.content[0].text)
expect(body).toEqual({ empty: true, remaining: 0 })
expect(body.by_product).toBeUndefined()
})
it('returns correct remaining count for a product with jobs', async () => {
mockPrisma.claudeJob.count.mockResolvedValue(2)
const server = makeServer()
const result = await server.call({ product_id: PRODUCT_A }) as { content: { text: string }[] }
const body = JSON.parse(result.content[0].text)
expect(body).toEqual({ empty: false, remaining: 2 })
})
it('returns error when user has no access to the product', async () => {
mockUserCanAccessProduct.mockResolvedValue(false)
const server = makeServer()
const result = await server.call({ product_id: PRODUCT_A }) as { content: { text: string }[]; isError: boolean }
expect(result.isError).toBe(true)
expect(result.content[0].text).toContain('not found or not accessible')
expect(mockPrisma.claudeJob.count).not.toHaveBeenCalled()
})
})
describe('check_queue_empty — demo user', () => {
it('returns PERMISSION_DENIED error for demo accounts', async () => {
mockRequireWriteAccess.mockRejectedValue(new PermissionDeniedError())
const server = makeServer()
const result = await server.call({}) as { content: { text: string }[]; isError: boolean }
expect(result.isError).toBe(true)
expect(result.content[0].text).toContain('PERMISSION_DENIED')
})
})

View file

@ -73,17 +73,6 @@ describe('listWorktreeJobIds', () => {
mockReaddir.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
expect(await listWorktreeJobIds(WORKTREE_PARENT)).toEqual([])
})
it('skips _products/ system dir and *.lock files (PBI-9)', async () => {
mockReaddir.mockResolvedValue([
makeDirent('job-aaa'),
makeDirent('_products'),
makeDirent('product-abc.lock'),
makeDirent('job-bbb'),
])
const ids = await listWorktreeJobIds(WORKTREE_PARENT)
expect(ids).toEqual(['job-aaa', 'job-bbb'])
})
})
describe('cleanupWorktrees', () => {

View file

@ -1,165 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { Prisma } from '@prisma/client'
vi.mock('../src/prisma.js', () => ({
prisma: {
sprint: {
findMany: vi.fn(),
create: vi.fn(),
},
},
}))
vi.mock('../src/auth.js', () => ({
requireWriteAccess: vi.fn(),
PermissionDeniedError: class PermissionDeniedError extends Error {
constructor(message = 'Demo accounts cannot perform write operations') {
super(message)
this.name = 'PermissionDeniedError'
}
},
}))
vi.mock('../src/access.js', () => ({
userCanAccessProduct: vi.fn(),
}))
import { prisma } from '../src/prisma.js'
import { requireWriteAccess } from '../src/auth.js'
import { userCanAccessProduct } from '../src/access.js'
import { handleCreateSprint } from '../src/tools/create-sprint.js'
const mockPrisma = prisma as unknown as {
sprint: {
findMany: ReturnType<typeof vi.fn>
create: ReturnType<typeof vi.fn>
}
}
const mockRequireWriteAccess = requireWriteAccess as ReturnType<typeof vi.fn>
const mockUserCanAccessProduct = userCanAccessProduct as ReturnType<typeof vi.fn>
const PRODUCT_ID = 'prod-1'
const USER_ID = 'user-1'
beforeEach(() => {
vi.clearAllMocks()
mockRequireWriteAccess.mockResolvedValue({ userId: USER_ID, tokenId: 'tok-1', username: 'alice', isDemo: false })
mockUserCanAccessProduct.mockResolvedValue(true)
mockPrisma.sprint.findMany.mockResolvedValue([])
})
function parseResult(result: Awaited<ReturnType<typeof handleCreateSprint>>) {
const text = result.content?.[0]?.type === 'text' ? result.content[0].text : ''
try { return JSON.parse(text) } catch { return text }
}
describe('handleCreateSprint', () => {
it('happy path: creates sprint with auto-generated code', async () => {
mockPrisma.sprint.create.mockResolvedValue({
id: 'spr-1',
code: 'S-2026-05-11-1',
sprint_goal: 'My goal',
status: 'OPEN',
start_date: new Date('2026-05-11'),
created_at: new Date('2026-05-11T10:00:00Z'),
})
const result = await handleCreateSprint({
product_id: PRODUCT_ID,
sprint_goal: 'My goal',
})
expect(mockPrisma.sprint.create).toHaveBeenCalledTimes(1)
const callArgs = mockPrisma.sprint.create.mock.calls[0][0]
expect(callArgs.data.product_id).toBe(PRODUCT_ID)
expect(callArgs.data.status).toBe('OPEN')
expect(callArgs.data.sprint_goal).toBe('My goal')
expect(callArgs.data.code).toMatch(/^S-\d{4}-\d{2}-\d{2}-1$/)
expect(callArgs.data.start_date).toBeInstanceOf(Date)
const parsed = parseResult(result)
expect(parsed.id).toBe('spr-1')
expect(parsed.status).toBe('OPEN')
})
it('uses user-provided code when given', async () => {
mockPrisma.sprint.create.mockResolvedValue({
id: 'spr-2',
code: 'CUSTOM-CODE',
sprint_goal: 'g',
status: 'OPEN',
start_date: new Date(),
created_at: new Date(),
})
await handleCreateSprint({
product_id: PRODUCT_ID,
code: 'CUSTOM-CODE',
sprint_goal: 'g',
})
expect(mockPrisma.sprint.create).toHaveBeenCalledTimes(1)
expect(mockPrisma.sprint.findMany).not.toHaveBeenCalled()
expect(mockPrisma.sprint.create.mock.calls[0][0].data.code).toBe('CUSTOM-CODE')
})
it('auto-code increments past existing same-day sprints', async () => {
// Codes moeten relatief aan "vandaag" zijn: generateNextSprintCode telt
// alleen same-day sprints. Hardcoded datums maakten deze test datum-flaky.
const today = new Date().toISOString().slice(0, 10)
mockPrisma.sprint.findMany.mockResolvedValue([
{ code: `S-${today}-1` },
{ code: `S-${today}-3` },
{ code: 'S-2020-01-01-7' },
])
mockPrisma.sprint.create.mockResolvedValue({
id: 'spr-3', code: 'X', sprint_goal: 'g', status: 'OPEN', start_date: new Date(), created_at: new Date(),
})
await handleCreateSprint({ product_id: PRODUCT_ID, sprint_goal: 'g' })
expect(mockPrisma.sprint.create.mock.calls[0][0].data.code).toBe(`S-${today}-4`)
})
it('retries on P2002 unique conflict', async () => {
const conflict = new Prisma.PrismaClientKnownRequestError('unique', {
code: 'P2002', clientVersion: 'x', meta: { target: ['product_id', 'code'] },
})
mockPrisma.sprint.create
.mockRejectedValueOnce(conflict)
.mockResolvedValueOnce({
id: 'spr-r', code: 'S-2026-05-11-2', sprint_goal: 'g', status: 'OPEN',
start_date: new Date(), created_at: new Date(),
})
const result = await handleCreateSprint({ product_id: PRODUCT_ID, sprint_goal: 'g' })
expect(mockPrisma.sprint.create).toHaveBeenCalledTimes(2)
expect(parseResult(result).id).toBe('spr-r')
})
it('returns error when user cannot access product', async () => {
mockUserCanAccessProduct.mockResolvedValue(false)
const result = await handleCreateSprint({ product_id: PRODUCT_ID, sprint_goal: 'g' })
expect(mockPrisma.sprint.create).not.toHaveBeenCalled()
const text = result.content?.[0]?.type === 'text' ? result.content[0].text : ''
expect(text).toMatch(/not found or not accessible/)
})
it('uses provided start_date when given', async () => {
mockPrisma.sprint.create.mockResolvedValue({
id: 'spr-d', code: 'X', sprint_goal: 'g', status: 'OPEN',
start_date: new Date('2026-01-01'), created_at: new Date(),
})
await handleCreateSprint({
product_id: PRODUCT_ID,
sprint_goal: 'g',
start_date: '2026-01-01',
})
const callArgs = mockPrisma.sprint.create.mock.calls[0][0]
expect(callArgs.data.start_date.toISOString().slice(0, 10)).toBe('2026-01-01')
})
})

View file

@ -1,141 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../src/prisma.js', () => ({
prisma: {
pbi: { findUnique: vi.fn() },
sprint: { findUnique: vi.fn() },
story: {
findFirst: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
},
},
}))
vi.mock('../src/auth.js', () => ({
requireWriteAccess: vi.fn(),
PermissionDeniedError: class PermissionDeniedError extends Error {
constructor(message = 'Demo accounts cannot perform write operations') {
super(message)
this.name = 'PermissionDeniedError'
}
},
}))
vi.mock('../src/access.js', () => ({
userCanAccessProduct: vi.fn(),
}))
import { prisma } from '../src/prisma.js'
import { requireWriteAccess } from '../src/auth.js'
import { userCanAccessProduct } from '../src/access.js'
import { handleCreateStory } from '../src/tools/create-story.js'
const mockPrisma = prisma as unknown as {
pbi: { findUnique: ReturnType<typeof vi.fn> }
sprint: { findUnique: ReturnType<typeof vi.fn> }
story: {
findFirst: ReturnType<typeof vi.fn>
findMany: ReturnType<typeof vi.fn>
create: ReturnType<typeof vi.fn>
}
}
const mockRequireWriteAccess = requireWriteAccess as ReturnType<typeof vi.fn>
const mockUserCanAccessProduct = userCanAccessProduct as ReturnType<typeof vi.fn>
const PRODUCT_ID = 'prod-1'
const PBI_ID = 'pbi-1'
const SPRINT_ID = 'spr-1'
const USER_ID = 'user-1'
beforeEach(() => {
vi.clearAllMocks()
mockRequireWriteAccess.mockResolvedValue({ userId: USER_ID, tokenId: 'tok-1', username: 'alice', isDemo: false })
mockUserCanAccessProduct.mockResolvedValue(true)
mockPrisma.pbi.findUnique.mockResolvedValue({ product_id: PRODUCT_ID })
mockPrisma.story.findMany.mockResolvedValue([])
mockPrisma.story.findFirst.mockResolvedValue(null)
mockPrisma.story.create.mockImplementation((args: { data: Record<string, unknown> }) =>
Promise.resolve({ id: 'story-1', created_at: new Date('2026-05-14T10:00:00Z'), ...args.data }),
)
})
function parseResult(result: Awaited<ReturnType<typeof handleCreateStory>>) {
const text = result.content?.[0]?.type === 'text' ? result.content[0].text : ''
try { return JSON.parse(text) } catch { return text }
}
function errorText(result: Awaited<ReturnType<typeof handleCreateStory>>): string {
return result.content?.[0]?.type === 'text' ? result.content[0].text : ''
}
describe('handleCreateStory', () => {
it('without sprint_id: creates story with status OPEN and no sprint', async () => {
const result = await handleCreateStory({ pbi_id: PBI_ID, title: 'A story', priority: 2 })
expect(mockPrisma.sprint.findUnique).not.toHaveBeenCalled()
const data = mockPrisma.story.create.mock.calls[0][0].data
expect(data.status).toBe('OPEN')
expect(data.sprint_id).toBeNull()
expect(data.product_id).toBe(PRODUCT_ID)
expect(parseResult(result).status).toBe('OPEN')
})
it('with valid sprint_id: links story to sprint with status IN_SPRINT', async () => {
mockPrisma.sprint.findUnique.mockResolvedValue({ product_id: PRODUCT_ID })
const result = await handleCreateStory({
pbi_id: PBI_ID,
title: 'A story',
priority: 2,
sprint_id: SPRINT_ID,
})
expect(mockPrisma.sprint.findUnique).toHaveBeenCalledWith({
where: { id: SPRINT_ID },
select: { product_id: true },
})
const data = mockPrisma.story.create.mock.calls[0][0].data
expect(data.status).toBe('IN_SPRINT')
expect(data.sprint_id).toBe(SPRINT_ID)
expect(parseResult(result).sprint_id).toBe(SPRINT_ID)
})
it('rejects a non-existent sprint_id', async () => {
mockPrisma.sprint.findUnique.mockResolvedValue(null)
const result = await handleCreateStory({
pbi_id: PBI_ID,
title: 'A story',
priority: 2,
sprint_id: 'missing',
})
expect(mockPrisma.story.create).not.toHaveBeenCalled()
expect(errorText(result)).toMatch(/Sprint missing not found/)
})
it('rejects a sprint from a different product', async () => {
mockPrisma.sprint.findUnique.mockResolvedValue({ product_id: 'other-product' })
const result = await handleCreateStory({
pbi_id: PBI_ID,
title: 'A story',
priority: 2,
sprint_id: SPRINT_ID,
})
expect(mockPrisma.story.create).not.toHaveBeenCalled()
expect(errorText(result)).toMatch(/different product/)
})
it('returns error when PBI not found', async () => {
mockPrisma.pbi.findUnique.mockResolvedValue(null)
const result = await handleCreateStory({ pbi_id: 'missing', title: 'A story', priority: 2 })
expect(mockPrisma.sprint.findUnique).not.toHaveBeenCalled()
expect(mockPrisma.story.create).not.toHaveBeenCalled()
expect(errorText(result)).toMatch(/PBI missing not found/)
})
})

View file

@ -1,22 +0,0 @@
import { describe, it, expect } from 'vitest'
import { executeEffects } from '../../src/flow/effects.js'
describe('effects executor', () => {
it('RELEASE_WORKTREE_LOCKS for unknown jobId is a no-op (no throw)', async () => {
const out = await executeEffects([{ type: 'RELEASE_WORKTREE_LOCKS', jobId: 'no-such-job' }])
expect(out).toEqual([])
})
it('multiple effects execute in order; failure in one is logged but does not abort', async () => {
const out = await executeEffects([
{ type: 'RELEASE_WORKTREE_LOCKS', jobId: 'a' },
{ type: 'RELEASE_WORKTREE_LOCKS', jobId: 'b' },
])
expect(out).toEqual([])
})
it('empty effects array returns empty outcomes', async () => {
const out = await executeEffects([])
expect(out).toEqual([])
})
})

View file

@ -1,78 +0,0 @@
import { describe, it, expect } from 'vitest'
import { transition, type PrFlowState } from '../../src/flow/pr-flow.js'
describe('pr-flow STORY-mode 3-tasks scenario', () => {
it('opens PR early; auto-merge only fires on the last task', () => {
let state: PrFlowState = { kind: 'none', strategy: 'STORY' }
const allEffects: Array<Record<string, unknown>> = []
// Task 1 DONE → PR_CREATED
let r = transition(state, { type: 'PR_CREATED', prUrl: 'https://github.com/o/r/pull/1' })
state = r.nextState
allEffects.push(...r.effects)
expect(state.kind).toBe('pr_opened')
expect(allEffects.filter((e) => e.type === 'ENABLE_AUTO_MERGE')).toHaveLength(0)
// Task 2 DONE → no STORY_COMPLETED yet, no transition emitted
r = transition(state, { type: 'TASK_DONE', taskId: 't2', headSha: 'abc123' })
state = r.nextState
allEffects.push(...r.effects)
expect(state.kind).toBe('pr_opened')
expect(allEffects.filter((e) => e.type === 'ENABLE_AUTO_MERGE')).toHaveLength(0)
// Task 3 DONE = STORY_COMPLETED → ENABLE_AUTO_MERGE with head guard
r = transition(state, { type: 'STORY_COMPLETED', storyId: 's1', headSha: 'def456' })
state = r.nextState
allEffects.push(...r.effects)
expect(state.kind).toBe('waiting_for_checks')
const enableEffects = allEffects.filter((e) => e.type === 'ENABLE_AUTO_MERGE')
expect(enableEffects).toHaveLength(1)
expect(enableEffects[0]).toMatchObject({ expectedHeadSha: 'def456' })
// CI green + merge OK
r = transition(state, { type: 'MERGE_RESULT' })
state = r.nextState
expect(state.kind).toBe('auto_merge_enabled')
})
it('CHECKS_FAILED → checks_failed (no pause)', () => {
const state: PrFlowState = {
kind: 'waiting_for_checks',
strategy: 'STORY',
prUrl: 'x',
headSha: 'y',
}
const r = transition(state, { type: 'MERGE_RESULT', reason: 'CHECKS_FAILED' })
expect(r.nextState.kind).toBe('checks_failed')
})
it('MERGE_CONFLICT → merge_conflict_paused', () => {
const state: PrFlowState = {
kind: 'waiting_for_checks',
strategy: 'STORY',
prUrl: 'x',
headSha: 'y',
}
const r = transition(state, { type: 'MERGE_RESULT', reason: 'MERGE_CONFLICT' })
expect(r.nextState.kind).toBe('merge_conflict_paused')
})
})
describe('pr-flow SPRINT-mode', () => {
it('draft stays draft until SPRINT_COMPLETED → MARK_PR_READY effect', () => {
let state: PrFlowState = { kind: 'none', strategy: 'SPRINT' }
let r = transition(state, { type: 'PR_CREATED', prUrl: 'x' })
expect(r.nextState.kind).toBe('draft_opened')
expect(r.effects).toHaveLength(0)
state = r.nextState
r = transition(state, { type: 'TASK_DONE', taskId: 't1', headSha: 'a' })
expect(r.nextState.kind).toBe('draft_opened')
expect(r.effects).toHaveLength(0)
state = r.nextState
r = transition(state, { type: 'SPRINT_COMPLETED', sprintRunId: 'sr1' })
expect(r.nextState.kind).toBe('ready_for_review')
expect(r.effects.filter((e) => e.type === 'MARK_PR_READY')).toHaveLength(1)
})
})

View file

@ -1,82 +0,0 @@
import { describe, it, expect } from 'vitest'
import { transition, type SprintRunState } from '../../src/flow/sprint-run.js'
describe('sprint-run pure transitions', () => {
it('queued + CLAIM_FIRST_JOB → running with SET_SPRINT_RUN_STATUS effect', () => {
const state: SprintRunState = { kind: 'queued', sprintRunId: 'sr1' }
const r = transition(state, { type: 'CLAIM_FIRST_JOB' })
expect(r.nextState.kind).toBe('running')
expect(r.effects).toEqual([
{ type: 'SET_SPRINT_RUN_STATUS', sprintRunId: 'sr1', status: 'RUNNING' },
])
})
it('running + MERGE_CONFLICT → paused_merge_conflict + 2 effects in order', () => {
const state: SprintRunState = { kind: 'running', sprintRunId: 'sr1' }
const r = transition(state, {
type: 'MERGE_CONFLICT',
prUrl: 'https://github.com/o/r/pull/1',
prHeadSha: 'abc123',
conflictFiles: ['a.ts', 'b.ts'],
resumeInstructions: 'Resolve and push',
})
expect(r.nextState.kind).toBe('paused_merge_conflict')
expect(r.effects).toHaveLength(2)
expect(r.effects[0].type).toBe('CREATE_CLAUDE_QUESTION')
expect(r.effects[1].type).toBe('SET_SPRINT_RUN_STATUS')
if (r.effects[1].type === 'SET_SPRINT_RUN_STATUS') {
expect(r.effects[1].status).toBe('PAUSED')
expect(r.effects[1].pauseContextDraft).toMatchObject({
pause_reason: 'MERGE_CONFLICT',
pr_url: 'https://github.com/o/r/pull/1',
pr_head_sha: 'abc123',
conflict_files: ['a.ts', 'b.ts'],
})
}
})
it('paused + USER_RESUMED → running + CLOSE_CLAUDE_QUESTION + clear pause_context', () => {
const state: SprintRunState = {
kind: 'paused_merge_conflict',
sprintRunId: 'sr1',
pauseContext: {
pause_reason: 'MERGE_CONFLICT',
pr_url: 'x',
pr_head_sha: 'y',
conflict_files: [],
claude_question_id: 'q1',
resume_instructions: 'r',
paused_at: new Date().toISOString(),
},
}
const r = transition(state, { type: 'USER_RESUMED' })
expect(r.nextState.kind).toBe('running')
expect(r.effects[0]).toEqual({ type: 'CLOSE_CLAUDE_QUESTION', questionId: 'q1' })
expect(r.effects[1]).toMatchObject({
type: 'SET_SPRINT_RUN_STATUS',
status: 'RUNNING',
clearPauseContext: true,
})
})
it('running + TASK_FAILED → failed (no PAUSE)', () => {
const state: SprintRunState = { kind: 'running', sprintRunId: 'sr1' }
const r = transition(state, { type: 'TASK_FAILED', taskId: 't1', error: 'CI red' })
expect(r.nextState.kind).toBe('failed')
expect(r.effects[0]).toMatchObject({ status: 'FAILED' })
})
it('running + ALL_DONE → done + SET_SPRINT_RUN_STATUS DONE', () => {
const state: SprintRunState = { kind: 'running', sprintRunId: 'sr1' }
const r = transition(state, { type: 'ALL_DONE' })
expect(r.nextState.kind).toBe('done')
expect(r.effects[0]).toMatchObject({ status: 'DONE' })
})
it('forbidden transition (running + CLAIM_FIRST_JOB) keeps state and emits no effects', () => {
const state: SprintRunState = { kind: 'running', sprintRunId: 'sr1' }
const r = transition(state, { type: 'CLAIM_FIRST_JOB' })
expect(r.nextState).toEqual(state)
expect(r.effects).toEqual([])
})
})

View file

@ -1,82 +0,0 @@
import { describe, it, expect } from 'vitest'
import { transition, type WorktreeLeaseState } from '../../src/flow/worktree-lease.js'
describe('worktree-lease pure transitions', () => {
it('idle + JOB_CLAIMED → acquiring_lock, no effects', () => {
const r = transition({ kind: 'idle' }, { type: 'JOB_CLAIMED', jobId: 'j1', productIds: ['p1'] })
expect(r.nextState.kind).toBe('acquiring_lock')
expect(r.effects).toEqual([])
})
it('acquiring_lock + LOCK_ACQUIRED → creating_or_reusing', () => {
const state: WorktreeLeaseState = {
kind: 'acquiring_lock',
jobId: 'j1',
productIds: ['p1'],
}
const r = transition(state, { type: 'LOCK_ACQUIRED' })
expect(r.nextState.kind).toBe('creating_or_reusing')
expect(r.effects).toEqual([])
})
it('acquiring_lock + LOCK_TIMEOUT → lock_timeout', () => {
const state: WorktreeLeaseState = {
kind: 'acquiring_lock',
jobId: 'j1',
productIds: ['p1'],
}
const r = transition(state, { type: 'LOCK_TIMEOUT' })
expect(r.nextState.kind).toBe('lock_timeout')
})
it('creating_or_reusing + WORKTREE_READY → syncing', () => {
const r = transition(
{ kind: 'creating_or_reusing', jobId: 'j1', productIds: ['p1'] },
{ type: 'WORKTREE_READY' },
)
expect(r.nextState.kind).toBe('syncing')
})
it('syncing + SYNC_DONE → ready (no release effect yet)', () => {
const r = transition(
{ kind: 'syncing', jobId: 'j1', productIds: ['p1'] },
{ type: 'SYNC_DONE' },
)
expect(r.nextState.kind).toBe('ready')
expect(r.effects).toEqual([])
})
it('syncing + SYNC_FAILED → sync_failed + RELEASE_WORKTREE_LOCKS effect', () => {
const r = transition(
{ kind: 'syncing', jobId: 'j1', productIds: ['p1'] },
{ type: 'SYNC_FAILED', error: 'boom' },
)
expect(r.nextState.kind).toBe('sync_failed')
expect(r.effects).toEqual([{ type: 'RELEASE_WORKTREE_LOCKS', jobId: 'j1' }])
})
it('ready + JOB_TERMINAL → releasing + RELEASE_WORKTREE_LOCKS effect', () => {
const r = transition(
{ kind: 'ready', jobId: 'j1', productIds: ['p1'] },
{ type: 'JOB_TERMINAL', jobId: 'j1' },
)
expect(r.nextState.kind).toBe('releasing')
expect(r.effects).toEqual([{ type: 'RELEASE_WORKTREE_LOCKS', jobId: 'j1' }])
})
it('ready + STALE_RESET → stale_released + RELEASE_WORKTREE_LOCKS effect', () => {
const r = transition(
{ kind: 'ready', jobId: 'j1', productIds: ['p1'] },
{ type: 'STALE_RESET', jobId: 'j1' },
)
expect(r.nextState.kind).toBe('stale_released')
expect(r.effects).toEqual([{ type: 'RELEASE_WORKTREE_LOCKS', jobId: 'j1' }])
})
it('forbidden transition (idle + LOCK_ACQUIRED) keeps state, no effects', () => {
const state: WorktreeLeaseState = { kind: 'idle' }
const r = transition(state, { type: 'LOCK_ACQUIRED' })
expect(r.nextState).toEqual(state)
expect(r.effects).toEqual([])
})
})

View file

@ -4,12 +4,12 @@ const {
mockProductFindFirst,
mockSprintFindFirst,
mockStoryFindFirst,
mockIdeaFindMany,
mockTodoFindMany,
} = vi.hoisted(() => ({
mockProductFindFirst: vi.fn(),
mockSprintFindFirst: vi.fn(),
mockStoryFindFirst: vi.fn(),
mockIdeaFindMany: vi.fn(),
mockTodoFindMany: vi.fn(),
}))
vi.mock('../src/auth.js', () => ({
@ -21,7 +21,7 @@ vi.mock('../src/prisma.js', () => ({
product: { findFirst: mockProductFindFirst },
sprint: { findFirst: mockSprintFindFirst },
story: { findFirst: mockStoryFindFirst },
idea: { findMany: mockIdeaFindMany },
todo: { findMany: mockTodoFindMany },
},
}))
@ -55,7 +55,7 @@ beforeEach(() => {
})
mockSprintFindFirst.mockResolvedValue({ id: 'sprint-1', sprint_goal: 'Goal', status: 'ACTIVE' })
mockStoryFindFirst.mockResolvedValue(null)
mockIdeaFindMany.mockResolvedValue([])
mockTodoFindMany.mockResolvedValue([])
})
describe('get_claude_context safety-net filter', () => {

View file

@ -1,96 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import { acquireFileLock, acquireFileLocksOrdered } from '../../src/git/file-lock.js'
describe('file-lock', () => {
let tmpDir: string
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'file-lock-'))
})
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true })
})
it('acquires and releases a lock; lockfile is gone after release', async () => {
const lockPath = path.join(tmpDir, 'a.lock')
const release = await acquireFileLock(lockPath)
// proper-lockfile creates a directory at <lockPath>.lock for the actual lock
const stat = await fs.stat(`${lockPath}.lock`).catch(() => null)
expect(stat).not.toBeNull()
await release()
// After release, the .lock dir should be gone
const after = await fs.stat(`${lockPath}.lock`).catch(() => null)
expect(after).toBeNull()
})
it('release is idempotent (second call is no-op)', async () => {
const lockPath = path.join(tmpDir, 'b.lock')
const release = await acquireFileLock(lockPath)
await release()
await expect(release()).resolves.toBeUndefined()
})
it('second acquire blocks until first release', async () => {
const lockPath = path.join(tmpDir, 'c.lock')
const release1 = await acquireFileLock(lockPath)
let secondAcquired = false
const second = acquireFileLock(lockPath).then((r) => {
secondAcquired = true
return r
})
// Give the second acquire a moment to attempt
await new Promise((r) => setTimeout(r, 200))
expect(secondAcquired).toBe(false)
await release1()
const release2 = await second
expect(secondAcquired).toBe(true)
await release2()
}, 10_000)
it('acquireFileLocksOrdered sorts paths alphabetically (deadlock-free for crossed sets)', async () => {
const a = path.join(tmpDir, 'A.lock')
const b = path.join(tmpDir, 'B.lock')
// Two concurrent multi-locks with crossed orders both sort to [A, B]
const r1Promise = acquireFileLocksOrdered([b, a])
// First should grab both since paths sort the same
const r1 = await r1Promise
let secondAcquired = false
const r2Promise = acquireFileLocksOrdered([a, b]).then((r) => {
secondAcquired = true
return r
})
await new Promise((r) => setTimeout(r, 200))
expect(secondAcquired).toBe(false)
await r1()
const r2 = await r2Promise
expect(secondAcquired).toBe(true)
await r2()
}, 15_000)
it('partial failure releases held locks', async () => {
// Force the second acquire to fail by writing a regular file at the lockfile
// location proper-lockfile wants to create as a directory.
const a = path.join(tmpDir, 'A.lock')
const bPath = path.join(tmpDir, 'B.lock')
// Create a regular file at `${bPath}.lock` so proper-lockfile's mkdir fails with EEXIST
await fs.writeFile(`${bPath}.lock`, 'blocked')
await expect(acquireFileLocksOrdered([a, bPath])).rejects.toThrow()
// After failure, A's lock should be released — re-acquire immediately
const r = await acquireFileLock(a)
await r()
}, 90_000)
})

View file

@ -1,136 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import {
registerJobLockReleases,
releaseLocksOnTerminal,
setupProductWorktrees,
_resetJobReleasesForTest,
} from '../../src/git/job-locks.js'
const exec = promisify(execFile)
describe('job-locks: registerJobLockReleases + releaseLocksOnTerminal', () => {
beforeEach(() => _resetJobReleasesForTest())
it('releaseLocksOnTerminal for unknown job is a no-op', async () => {
await expect(releaseLocksOnTerminal('nonexistent')).resolves.toBeUndefined()
})
it('runs registered releases and clears the entry', async () => {
const release = vi.fn().mockResolvedValue(undefined)
registerJobLockReleases('job-1', [release])
await releaseLocksOnTerminal('job-1')
expect(release).toHaveBeenCalledTimes(1)
// Second call → no-op (cleared)
await releaseLocksOnTerminal('job-1')
expect(release).toHaveBeenCalledTimes(1)
})
it('failures in one release do not abort others', async () => {
const r1 = vi.fn().mockRejectedValue(new Error('boom'))
const r2 = vi.fn().mockResolvedValue(undefined)
registerJobLockReleases('job-2', [r1, r2])
await expect(releaseLocksOnTerminal('job-2')).resolves.toBeUndefined()
expect(r1).toHaveBeenCalled()
expect(r2).toHaveBeenCalled()
})
it('append-mode: multiple registers accumulate', async () => {
const r1 = vi.fn().mockResolvedValue(undefined)
const r2 = vi.fn().mockResolvedValue(undefined)
registerJobLockReleases('job-3', [r1])
registerJobLockReleases('job-3', [r2])
await releaseLocksOnTerminal('job-3')
expect(r1).toHaveBeenCalledTimes(1)
expect(r2).toHaveBeenCalledTimes(1)
})
})
describe('job-locks: setupProductWorktrees', () => {
let tmpRoot: string
let originalEnv: string | undefined
let bareRepo: string
let originRepo: string
beforeEach(async () => {
_resetJobReleasesForTest()
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'job-locks-'))
originalEnv = process.env.SCRUM4ME_AGENT_WORKTREE_DIR
process.env.SCRUM4ME_AGENT_WORKTREE_DIR = path.join(tmpRoot, 'agent-worktrees')
// Set up a bare repo as origin and a clone with origin/main
bareRepo = path.join(tmpRoot, 'origin.git')
await exec('git', ['init', '--bare', '-b', 'main', bareRepo])
originRepo = path.join(tmpRoot, 'work')
await exec('git', ['init', '-b', 'main', originRepo])
await exec('git', ['config', 'user.email', 't@t.local'], { cwd: originRepo })
await exec('git', ['config', 'user.name', 'Test'], { cwd: originRepo })
await exec('git', ['remote', 'add', 'origin', bareRepo], { cwd: originRepo })
await fs.writeFile(path.join(originRepo, 'README.md'), '# init\n')
await exec('git', ['add', '-A'], { cwd: originRepo })
await exec('git', ['commit', '-m', 'init'], { cwd: originRepo })
await exec('git', ['push', '-u', 'origin', 'main'], { cwd: originRepo })
})
afterEach(async () => {
if (originalEnv) process.env.SCRUM4ME_AGENT_WORKTREE_DIR = originalEnv
else delete process.env.SCRUM4ME_AGENT_WORKTREE_DIR
await fs.rm(tmpRoot, { recursive: true, force: true })
})
it('returns empty when productIds is empty', async () => {
const result = await setupProductWorktrees('j1', [], async () => null)
expect(result).toEqual([])
})
it('creates a product-worktree, registers a lock-release, and releases it', async () => {
const result = await setupProductWorktrees('j2', ['prod-a'], async () => originRepo)
expect(result).toHaveLength(1)
expect(result[0].productId).toBe('prod-a')
expect(result[0].worktreePath).toContain('_products/prod-a')
// Worktree dir exists with detached HEAD on origin/main
const stat = await fs.stat(result[0].worktreePath)
expect(stat.isDirectory()).toBe(true)
// Lockfile is held during the job (proper-lockfile creates a .lock dir)
const lockDir = path.join(
process.env.SCRUM4ME_AGENT_WORKTREE_DIR!,
'_products',
'prod-a.lock.lock',
)
const lockStat = await fs.stat(lockDir).catch(() => null)
expect(lockStat).not.toBeNull()
await releaseLocksOnTerminal('j2')
const lockAfter = await fs.stat(lockDir).catch(() => null)
expect(lockAfter).toBeNull()
})
it('skips products where resolveRepoRoot returns null', async () => {
const result = await setupProductWorktrees('j3', ['no-repo'], async () => null)
expect(result).toEqual([])
// Lock was still acquired and registered — release cleans up
await releaseLocksOnTerminal('j3')
})
it('output preserves input order regardless of alphabetical lock-acquire order', async () => {
// 'z-primary' sorts AFTER 'a-secondary' alphabetically, but caller passes
// primary first → output[0] must be 'z-primary' so wait_for_job's
// primary_worktree_path = worktrees[0]?.worktreePath points at the right repo.
const result = await setupProductWorktrees(
'j4',
['z-primary', 'a-secondary'],
async () => originRepo,
)
expect(result).toHaveLength(2)
expect(result[0].productId).toBe('z-primary')
expect(result[1].productId).toBe('a-secondary')
await releaseLocksOnTerminal('j4')
})
})

View file

@ -1,75 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock node:child_process before importing the module under test
vi.mock('node:child_process', () => ({
execFile: vi.fn(),
}))
import { execFile } from 'node:child_process'
import { enableAutoMergeOnPr } from '../../src/git/pr.js'
const mockExecFile = vi.mocked(execFile) as unknown as ReturnType<typeof vi.fn>
function mockGhFailure(stderr: string) {
mockExecFile.mockImplementation(((_cmd: string, _args: string[], _opts: unknown, cb: any) => {
cb(Object.assign(new Error('gh exit'), { stderr }))
}) as never)
}
function mockGhSuccess() {
mockExecFile.mockImplementation(((_cmd: string, _args: string[], _opts: unknown, cb: any) => {
cb(null, { stdout: '', stderr: '' })
}) as never)
}
describe('enableAutoMergeOnPr — typed errors (PBI-47 C2 layer 1)', () => {
beforeEach(() => {
mockExecFile.mockReset()
})
it('returns ok:true on green merge', async () => {
mockGhSuccess()
const result = await enableAutoMergeOnPr({ prUrl: 'x', expectedHeadSha: 'sha1' })
expect(result.ok).toBe(true)
})
it('classifies GH_AUTH_ERROR for 401/403 / permission strings', async () => {
mockGhFailure('gh: HTTP 403: permission denied')
const result = await enableAutoMergeOnPr({ prUrl: 'x', expectedHeadSha: 'sha1' })
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toBe('GH_AUTH_ERROR')
})
it('classifies AUTO_MERGE_NOT_ALLOWED for repo-setting refusal', async () => {
mockGhFailure('auto-merge is not allowed for this repository')
const result = await enableAutoMergeOnPr({ prUrl: 'x', expectedHeadSha: 'sha1' })
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toBe('AUTO_MERGE_NOT_ALLOWED')
})
it('classifies MERGE_CONFLICT for dirty merge state', async () => {
mockGhFailure('pull request is not in a mergeable state (dirty)')
const result = await enableAutoMergeOnPr({ prUrl: 'x', expectedHeadSha: 'sha1' })
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toBe('MERGE_CONFLICT')
})
it('classifies UNKNOWN for unrecognised stderr', async () => {
mockGhFailure('unexpected gh error')
const result = await enableAutoMergeOnPr({ prUrl: 'x', expectedHeadSha: 'sha1' })
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toBe('UNKNOWN')
})
it('passes --match-head-commit when expectedHeadSha provided', async () => {
mockGhSuccess()
await enableAutoMergeOnPr({ prUrl: 'pr-url', expectedHeadSha: 'abc123' })
const callArgs = mockExecFile.mock.calls[0]
expect(callArgs[0]).toBe('gh')
const args = callArgs[1] as string[]
expect(args).toContain('--match-head-commit')
expect(args).toContain('abc123')
expect(args).toContain('--auto')
expect(args).toContain('--squash')
})
})

View file

@ -12,7 +12,7 @@ vi.mock('node:util', () => ({
),
}))
import { createPullRequest, markPullRequestReady } from '../../src/git/pr.js'
import { createPullRequest } from '../../src/git/pr.js'
beforeEach(() => {
vi.clearAllMocks()
@ -66,80 +66,4 @@ describe('createPullRequest', () => {
expect(result).toMatchObject({ error: expect.stringContaining('gh pr create failed') })
})
it('passes --draft when draft=true en slaat auto-merge over', async () => {
const calls: string[][] = []
mockExecFile.mockImplementation(
(
_cmd: string,
args: string[],
_opts: unknown,
cb: (err: null, res: { stdout: string; stderr: string }) => void,
) => {
calls.push(args)
cb(null, {
stdout: 'Creating draft pull request...\nhttps://github.com/org/repo/pull/100\n',
stderr: '',
})
},
)
const result = await createPullRequest({
worktreePath: '/wt/sprint-1',
branchName: 'feat/sprint-12345678',
title: 'Sprint: Cascade-flow live',
body: 'Sprint draft',
draft: true,
enableAutoMerge: false,
})
expect(result).toEqual({ url: 'https://github.com/org/repo/pull/100' })
expect(calls.some((a) => a.includes('--draft'))).toBe(true)
// gh pr merge --auto mag NIET gestart zijn voor draft + auto-merge=false
expect(calls.some((a) => a[0] === 'pr' && a[1] === 'merge')).toBe(false)
})
})
describe('markPullRequestReady', () => {
it('roept gh pr ready aan met de PR-URL', async () => {
const calls: string[][] = []
mockExecFile.mockImplementation(
(
_cmd: string,
args: string[],
_opts: unknown,
cb: (err: null, res: { stdout: string; stderr: string }) => void,
) => {
calls.push(args)
cb(null, { stdout: '', stderr: '' })
},
)
const result = await markPullRequestReady({ prUrl: 'https://github.com/org/repo/pull/100' })
expect(result).toEqual({ ok: true })
expect(calls[0]).toEqual(['pr', 'ready', 'https://github.com/org/repo/pull/100'])
})
it('behandelt "already ready" als success', async () => {
mockExecFile.mockImplementation(
(_cmd: string, _args: string[], _opts: unknown, cb: (err: Error) => void) =>
cb(Object.assign(new Error(''), { stderr: 'Pull request is not in draft state' })),
)
const result = await markPullRequestReady({ prUrl: 'https://github.com/org/repo/pull/100' })
expect(result).toEqual({ ok: true })
})
it('retourneert error op onverwachte gh-fout', async () => {
mockExecFile.mockImplementation(
(_cmd: string, _args: string[], _opts: unknown, cb: (err: Error) => void) =>
cb(new Error('rate limit exceeded')),
)
const result = await markPullRequestReady({ prUrl: 'https://github.com/org/repo/pull/100' })
expect(result).toMatchObject({ error: expect.stringContaining('gh pr ready failed') })
})
})

View file

@ -74,12 +74,11 @@ describe('createWorktreeForJob', () => {
expect(result.worktreePath).toBe(path.join(wtParent, 'job-001'))
})
it('removes orphan branch and reuses the predictable name when no worktree owns it', async () => {
it('suffixes branch name with timestamp when branch already exists', async () => {
const { repoDir, originDir } = await setupRepo()
tmpDirs.push(repoDir, originDir)
await makeWorktreeParent()
// Pre-create an orphan branch (no worktree attached)
await git(['branch', 'feat/job-002'], repoDir)
const result = await createWorktreeForJob({
@ -89,11 +88,10 @@ describe('createWorktreeForJob', () => {
baseRef: 'origin/main',
})
// Orphan was deleted → predictable name reused, no timestamp suffix
expect(result.branchName).toBe('feat/job-002')
expect(result.branchName).toMatch(/^feat\/job-002-\d+$/)
const { stdout } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], result.worktreePath)
expect(stdout.trim()).toBe('feat/job-002')
expect(stdout.trim()).toBe(result.branchName)
})
it('rejects when worktree path already exists', async () => {
@ -113,71 +111,6 @@ describe('createWorktreeForJob', () => {
}),
).rejects.toThrow('Worktree path already exists')
})
it('reuseBranch: reuses an existing local branch', async () => {
const { repoDir, originDir } = await setupRepo()
tmpDirs.push(repoDir, originDir)
await makeWorktreeParent()
// Sibling already created the branch locally.
await git(['branch', 'feat/sprint-abc', 'origin/main'], repoDir)
const result = await createWorktreeForJob({
repoRoot: repoDir,
jobId: 'job-reuse-local',
branchName: 'feat/sprint-abc',
baseRef: 'origin/main',
reuseBranch: true,
})
const { stdout } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], result.worktreePath)
expect(stdout.trim()).toBe('feat/sprint-abc')
expect(result.branchName).toBe('feat/sprint-abc')
})
it('reuseBranch: recreates a local branch from origin when only the remote has it', async () => {
const { repoDir, originDir } = await setupRepo()
tmpDirs.push(repoDir, originDir)
await makeWorktreeParent()
// Branch exists on origin (a sibling pushed it, or the container was
// recreated and the local clone is fresh) but not as a local branch.
await git(['branch', 'feat/sprint-xyz', 'origin/main'], repoDir)
await git(['push', 'origin', 'feat/sprint-xyz'], repoDir)
await git(['branch', '-D', 'feat/sprint-xyz'], repoDir)
const result = await createWorktreeForJob({
repoRoot: repoDir,
jobId: 'job-reuse-origin',
branchName: 'feat/sprint-xyz',
baseRef: 'origin/main',
reuseBranch: true,
})
const { stdout } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], result.worktreePath)
expect(stdout.trim()).toBe('feat/sprint-xyz')
})
it('reuseBranch: falls back to a fresh branch when it exists nowhere (cross-repo sprint)', async () => {
const { repoDir, originDir } = await setupRepo()
tmpDirs.push(repoDir, originDir)
await makeWorktreeParent()
// reuseBranch is decided sprint-wide; for the first job targeting THIS
// repo the branch exists neither locally nor on origin. Must not throw
// "invalid reference" — should create it fresh from baseRef.
const result = await createWorktreeForJob({
repoRoot: repoDir,
jobId: 'job-reuse-fresh',
branchName: 'feat/sprint-newrepo',
baseRef: 'origin/main',
reuseBranch: true,
})
const { stdout } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], result.worktreePath)
expect(stdout.trim()).toBe('feat/sprint-newrepo')
expect(result.branchName).toBe('feat/sprint-newrepo')
})
})
describe('removeWorktreeForJob', () => {

View file

@ -1,166 +0,0 @@
import { describe, it, expect } from 'vitest'
import { getKindDefault, resolveJobConfig, mapBudgetToEffort } from '../src/lib/job-config.js'
const KIND_EXPECTED = {
IDEA_GRILL: { model: 'claude-sonnet-4-6', thinking_budget: 12000, permission_mode: 'acceptEdits', max_turns: 15 },
IDEA_MAKE_PLAN: { model: 'claude-opus-4-7', thinking_budget: 24000, permission_mode: 'acceptEdits', max_turns: 20 },
PLAN_CHAT: { model: 'claude-sonnet-4-6', thinking_budget: 6000, permission_mode: 'acceptEdits', max_turns: 5 },
TASK_IMPLEMENTATION: { model: 'claude-sonnet-4-6', thinking_budget: 6000, permission_mode: 'bypassPermissions', max_turns: 50 },
SPRINT_IMPLEMENTATION: { model: 'claude-sonnet-4-6', thinking_budget: 6000, permission_mode: 'bypassPermissions', max_turns: null },
} as const
describe('getKindDefault', () => {
for (const [kind, expected] of Object.entries(KIND_EXPECTED)) {
it(`returnt de juiste defaults voor ${kind}`, () => {
const cfg = getKindDefault(kind)
expect(cfg.model).toBe(expected.model)
expect(cfg.thinking_budget).toBe(expected.thinking_budget)
expect(cfg.permission_mode).toBe(expected.permission_mode)
expect(cfg.max_turns).toBe(expected.max_turns)
})
}
it('valt terug op een veilige fallback voor onbekende kinds', () => {
const cfg = getKindDefault('SOMETHING_NEW')
expect(cfg.model).toBe('claude-sonnet-4-6')
expect(cfg.permission_mode).toBe('default')
})
})
describe('resolveJobConfig — geen overrides', () => {
for (const kind of Object.keys(KIND_EXPECTED)) {
it(`returnt kind-default voor ${kind} zonder overrides`, () => {
const cfg = resolveJobConfig({ kind }, {})
expect(cfg).toEqual(getKindDefault(kind))
})
}
})
describe('resolveJobConfig — cascade', () => {
it('product.preferred_model overrult kind-default', () => {
const cfg = resolveJobConfig({ kind: 'TASK_IMPLEMENTATION' }, { preferred_model: 'claude-haiku-4-5-20251001' })
expect(cfg.model).toBe('claude-haiku-4-5-20251001')
})
it('job.requested_model overrult product.preferred_model', () => {
const cfg = resolveJobConfig(
{ kind: 'TASK_IMPLEMENTATION', requested_model: 'claude-opus-4-7' },
{ preferred_model: 'claude-haiku-4-5-20251001' },
)
expect(cfg.model).toBe('claude-opus-4-7')
})
it('task.requires_opus overrult product.preferred_model', () => {
const cfg = resolveJobConfig(
{ kind: 'TASK_IMPLEMENTATION' },
{ preferred_model: 'claude-sonnet-4-6' },
{ requires_opus: true },
)
expect(cfg.model).toBe('claude-opus-4-7')
})
it('task.requires_opus overrult ook job.requested_model = haiku', () => {
const cfg = resolveJobConfig(
{ kind: 'TASK_IMPLEMENTATION', requested_model: 'claude-haiku-4-5-20251001' },
{},
{ requires_opus: true },
)
expect(cfg.model).toBe('claude-opus-4-7')
})
it('job.requested_thinking_budget overrult kind-default', () => {
const cfg = resolveJobConfig({ kind: 'PLAN_CHAT', requested_thinking_budget: 1024 }, {})
expect(cfg.thinking_budget).toBe(1024)
})
it('product.thinking_budget_default overrult kind-default', () => {
const cfg = resolveJobConfig({ kind: 'IDEA_GRILL' }, { thinking_budget_default: 0 })
expect(cfg.thinking_budget).toBe(0)
})
it('product.preferred_permission_mode = acceptEdits overrult bypassPermissions voor TASK_IMPLEMENTATION', () => {
const cfg = resolveJobConfig(
{ kind: 'TASK_IMPLEMENTATION' },
{ preferred_permission_mode: 'acceptEdits' },
)
expect(cfg.permission_mode).toBe('acceptEdits')
})
it('max_turns blijft kind-default ook met product- en job-overrides (geen V1-cascade)', () => {
const cfg = resolveJobConfig(
{ kind: 'IDEA_GRILL', requested_model: 'claude-haiku-4-5-20251001' },
{ preferred_model: 'claude-sonnet-4-6' },
)
expect(cfg.max_turns).toBe(15)
})
})
describe('KIND_DEFAULTS.allowed_tools', () => {
it('TASK_IMPLEMENTATION bevat geen claim-tools', () => {
const cfg = getKindDefault('TASK_IMPLEMENTATION')
expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__wait_for_job')
expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__check_queue_empty')
expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__get_idea_context')
})
it('TASK_IMPLEMENTATION bevat de essentiële task-tools', () => {
const cfg = getKindDefault('TASK_IMPLEMENTATION')
expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_task_status')
expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_job_status')
expect(cfg.allowed_tools).toContain('mcp__scrum4me__verify_task_against_plan')
expect(cfg.allowed_tools).toContain('Bash')
expect(cfg.allowed_tools).toContain('Edit')
expect(cfg.allowed_tools).toContain('Write')
})
it('SPRINT_IMPLEMENTATION bevat sprint-specifieke tools maar GEEN job_heartbeat (runner doet die)', () => {
const cfg = getKindDefault('SPRINT_IMPLEMENTATION')
expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_task_execution')
expect(cfg.allowed_tools).toContain('mcp__scrum4me__verify_sprint_task')
expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__job_heartbeat')
})
it('IDEA_GRILL bevat update_idea_grill_md en geen wait_for_job', () => {
const cfg = getKindDefault('IDEA_GRILL')
expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_idea_grill_md')
expect(cfg.allowed_tools).toContain('mcp__scrum4me__log_idea_decision')
expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_job_status')
expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__wait_for_job')
})
it('IDEA_MAKE_PLAN bevat update_idea_plan_md en geen wait_for_job', () => {
const cfg = getKindDefault('IDEA_MAKE_PLAN')
expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_idea_plan_md')
expect(cfg.allowed_tools).toContain('mcp__scrum4me__log_idea_decision')
expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__wait_for_job')
})
it('alle kinds hebben non-null allowed_tools', () => {
for (const kind of ['IDEA_GRILL', 'IDEA_MAKE_PLAN', 'PLAN_CHAT', 'TASK_IMPLEMENTATION', 'SPRINT_IMPLEMENTATION']) {
const cfg = getKindDefault(kind)
expect(cfg.allowed_tools).not.toBeNull()
expect(Array.isArray(cfg.allowed_tools)).toBe(true)
}
})
})
describe('mapBudgetToEffort', () => {
it.each([
[0, null],
[-1, null],
[1, 'medium'],
[3000, 'medium'],
[6000, 'medium'],
[6001, 'high'],
[9000, 'high'],
[12000, 'high'],
[12001, 'xhigh'],
[18000, 'xhigh'],
[24000, 'xhigh'],
[24001, 'max'],
[50000, 'max'],
[100000, 'max'],
])('budget %i → %s', (budget, expected) => {
expect(mapBudgetToEffort(budget)).toBe(expected)
})
})

View file

@ -1,137 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../src/prisma.js', () => ({
prisma: {
$queryRaw: vi.fn(),
sprintRun: { findUnique: vi.fn() },
},
}))
vi.mock('../src/auth.js', async (importOriginal) => {
const original = await importOriginal<typeof import('../src/auth.js')>()
return { ...original, requireWriteAccess: vi.fn() }
})
import { prisma } from '../src/prisma.js'
import { requireWriteAccess } from '../src/auth.js'
import { registerJobHeartbeatTool } from '../src/tools/job-heartbeat.js'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
const mockPrisma = prisma as unknown as {
$queryRaw: ReturnType<typeof vi.fn>
sprintRun: { findUnique: ReturnType<typeof vi.fn> }
}
const mockAuth = requireWriteAccess as ReturnType<typeof vi.fn>
const TOKEN_ID = 'tok-owner'
function makeServer() {
let handler: (args: Record<string, unknown>) => Promise<unknown>
const server = {
registerTool: vi.fn((_name: string, _meta: unknown, fn: typeof handler) => {
handler = fn
}),
call: (args: Record<string, unknown>) => handler(args),
}
registerJobHeartbeatTool(server as unknown as McpServer)
return server
}
beforeEach(() => {
vi.clearAllMocks()
mockAuth.mockResolvedValue({
userId: 'u-1',
tokenId: TOKEN_ID,
username: 'agent',
isDemo: false,
})
})
describe('job_heartbeat', () => {
it('returns 403-style error when no row matched (token mismatch / terminal)', async () => {
mockPrisma.$queryRaw.mockResolvedValue([])
const server = makeServer()
const result = (await server.call({ job_id: 'job-x' })) as {
content: { text: string }[]
isError?: boolean
}
expect(result.isError).toBe(true)
expect(result.content[0].text).toMatch(/not found|terminal|claimed_by/i)
})
it('non-SPRINT job returns ok + lease_until without sprint fields', async () => {
const lease = new Date()
mockPrisma.$queryRaw.mockResolvedValue([
{
id: 'job-1',
lease_until: lease,
kind: 'TASK_IMPLEMENTATION',
sprint_run_id: null,
},
])
const server = makeServer()
const result = (await server.call({ job_id: 'job-1' })) as {
content: { text: string }[]
}
const body = JSON.parse(result.content[0].text)
expect(body).toEqual({
ok: true,
job_id: 'job-1',
lease_until: lease.toISOString(),
sprint_run_status: null,
sprint_run_pause_reason: null,
})
expect(mockPrisma.sprintRun.findUnique).not.toHaveBeenCalled()
})
it('SPRINT job returns sprint_run_status from sibling lookup', async () => {
const lease = new Date()
mockPrisma.$queryRaw.mockResolvedValue([
{
id: 'job-2',
lease_until: lease,
kind: 'SPRINT_IMPLEMENTATION',
sprint_run_id: 'sr-1',
},
])
mockPrisma.sprintRun.findUnique.mockResolvedValue({
status: 'PAUSED',
pause_context: { pause_reason: 'QUOTA_DEPLETED' },
})
const server = makeServer()
const result = (await server.call({ job_id: 'job-2' })) as {
content: { text: string }[]
}
const body = JSON.parse(result.content[0].text)
expect(body).toMatchObject({
ok: true,
sprint_run_status: 'PAUSED',
sprint_run_pause_reason: 'QUOTA_DEPLETED',
})
})
it('SPRINT job tolerates missing pause_context', async () => {
const lease = new Date()
mockPrisma.$queryRaw.mockResolvedValue([
{
id: 'job-3',
lease_until: lease,
kind: 'SPRINT_IMPLEMENTATION',
sprint_run_id: 'sr-2',
},
])
mockPrisma.sprintRun.findUnique.mockResolvedValue({
status: 'RUNNING',
pause_context: null,
})
const server = makeServer()
const result = (await server.call({ job_id: 'job-3' })) as {
content: { text: string }[]
}
const body = JSON.parse(result.content[0].text)
expect(body.sprint_run_status).toBe('RUNNING')
expect(body.sprint_run_pause_reason).toBeNull()
})
})

View file

@ -1,64 +0,0 @@
import { describe, it, expect } from 'vitest'
import type { ClaudeJobKind } from '@prisma/client'
import { getKindPromptText, getIdeaPromptText } from '../src/lib/kind-prompts.js'
const KINDS: ClaudeJobKind[] = [
'IDEA_GRILL',
'IDEA_MAKE_PLAN',
'TASK_IMPLEMENTATION',
'SPRINT_IMPLEMENTATION',
'PLAN_CHAT',
]
describe('getKindPromptText', () => {
it.each(KINDS)('returnt non-empty content voor %s', (kind) => {
const text = getKindPromptText(kind)
expect(text.length).toBeGreaterThan(0)
})
it('TASK_IMPLEMENTATION-prompt verbiedt wait_for_job', () => {
const text = getKindPromptText('TASK_IMPLEMENTATION')
expect(text).toMatch(/GEEN.*wait_for_job/)
})
it('SPRINT_IMPLEMENTATION-prompt verbiedt job_heartbeat', () => {
const text = getKindPromptText('SPRINT_IMPLEMENTATION')
expect(text).toMatch(/GEEN.*job_heartbeat/)
})
it.each(KINDS)(
'%s-prompt noemt $PAYLOAD_PATH als variabele (alle kinds — runner doet substitution)',
(kind) => {
const text = getKindPromptText(kind)
expect(text).toContain('$PAYLOAD_PATH')
},
)
it.each(['IDEA_GRILL', 'IDEA_MAKE_PLAN'] as const)(
'%s-prompt verwijst niet meer naar wait_for_job (refactor: runner claimt)',
(kind) => {
const text = getKindPromptText(kind)
expect(text).not.toContain('wait_for_job')
},
)
it.each(['IDEA_GRILL', 'IDEA_MAKE_PLAN'] as const)(
'%s-prompt bevat geen onvervangen {idea_*} placeholders',
(kind) => {
const text = getKindPromptText(kind)
expect(text).not.toMatch(/\{idea_code\}|\{idea_title\}/)
},
)
})
describe('getIdeaPromptText (back-compat)', () => {
it('returnt content voor IDEA_GRILL', () => {
expect(getIdeaPromptText('IDEA_GRILL').length).toBeGreaterThan(0)
})
it('returnt content voor IDEA_MAKE_PLAN', () => {
expect(getIdeaPromptText('IDEA_MAKE_PLAN').length).toBeGreaterThan(0)
})
it('returnt empty string voor non-idea kind', () => {
expect(getIdeaPromptText('TASK_IMPLEMENTATION')).toBe('')
})
})

View file

@ -1,113 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../src/prisma.js', () => ({
prisma: {
pbi: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}))
vi.mock('../src/auth.js', () => ({
requireWriteAccess: vi.fn(),
PermissionDeniedError: class PermissionDeniedError extends Error {
constructor(message = 'Demo accounts cannot perform write operations') {
super(message)
this.name = 'PermissionDeniedError'
}
},
}))
vi.mock('../src/access.js', () => ({
userCanAccessProduct: vi.fn(),
}))
import { prisma } from '../src/prisma.js'
import { requireWriteAccess, PermissionDeniedError } from '../src/auth.js'
import { userCanAccessProduct } from '../src/access.js'
import { handleMarkPbiPrMerged } from '../src/tools/mark-pbi-pr-merged.js'
const mockPrisma = prisma as unknown as {
pbi: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
}
const mockRequireWriteAccess = requireWriteAccess as ReturnType<typeof vi.fn>
const mockUserCanAccessProduct = userCanAccessProduct as ReturnType<typeof vi.fn>
const PBI_ID = 'pbi-abc123'
const PR_URL = 'https://github.com/owner/repo/pull/42'
const MERGED_AT = new Date('2026-05-03T12:00:00Z')
beforeEach(() => {
vi.clearAllMocks()
mockRequireWriteAccess.mockResolvedValue({ userId: 'user-1', tokenId: 'tok-1', username: 'alice', isDemo: false })
mockPrisma.pbi.findUnique.mockResolvedValue({ product_id: 'prod-1', pr_url: PR_URL })
mockUserCanAccessProduct.mockResolvedValue(true)
mockPrisma.pbi.update.mockResolvedValue({ id: PBI_ID, pr_url: PR_URL, pr_merged_at: MERGED_AT })
})
describe('handleMarkPbiPrMerged', () => {
it('happy path: sets pr_merged_at and returns ok', async () => {
const result = await handleMarkPbiPrMerged({ pbi_id: PBI_ID })
expect(result.isError).toBeFalsy()
expect(mockPrisma.pbi.update).toHaveBeenCalledWith({
where: { id: PBI_ID },
data: { pr_merged_at: expect.any(Date) },
select: { id: true, pr_url: true, pr_merged_at: true },
})
const text = result.content[0].type === 'text' ? result.content[0].text : ''
const parsed = JSON.parse(text)
expect(parsed.ok).toBe(true)
expect(parsed.pbi_id).toBe(PBI_ID)
expect(parsed.pr_url).toBe(PR_URL)
})
it('returns error when PBI has no pr_url', async () => {
mockPrisma.pbi.findUnique.mockResolvedValue({ product_id: 'prod-1', pr_url: null })
const result = await handleMarkPbiPrMerged({ pbi_id: PBI_ID })
expect(result.isError).toBe(true)
const text = result.content[0].type === 'text' ? result.content[0].text : ''
expect(text).toMatch(/geen gekoppelde PR/)
expect(mockPrisma.pbi.update).not.toHaveBeenCalled()
})
it('idempotent: re-calling overwrites pr_merged_at timestamp', async () => {
await handleMarkPbiPrMerged({ pbi_id: PBI_ID })
await handleMarkPbiPrMerged({ pbi_id: PBI_ID })
expect(mockPrisma.pbi.update).toHaveBeenCalledTimes(2)
expect(mockPrisma.pbi.update.mock.calls[0][0].data.pr_merged_at).toBeInstanceOf(Date)
expect(mockPrisma.pbi.update.mock.calls[1][0].data.pr_merged_at).toBeInstanceOf(Date)
})
it('returns error when user has no access', async () => {
mockUserCanAccessProduct.mockResolvedValue(false)
const result = await handleMarkPbiPrMerged({ pbi_id: PBI_ID })
expect(result.isError).toBe(true)
expect(mockPrisma.pbi.update).not.toHaveBeenCalled()
})
it('returns error when PBI not found', async () => {
mockPrisma.pbi.findUnique.mockResolvedValue(null)
const result = await handleMarkPbiPrMerged({ pbi_id: PBI_ID })
expect(result.isError).toBe(true)
expect(mockPrisma.pbi.update).not.toHaveBeenCalled()
})
it('returns PERMISSION_DENIED for demo accounts', async () => {
mockRequireWriteAccess.mockRejectedValue(new PermissionDeniedError())
const result = await handleMarkPbiPrMerged({ pbi_id: PBI_ID })
expect(result.isError).toBe(true)
const text = result.content[0].type === 'text' ? result.content[0].text : ''
expect(text).toMatch(/PERMISSION_DENIED/)
})
})

View file

@ -1,287 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
vi.mock('../../src/prisma.js', () => ({
prisma: {
claudeJob: { update: vi.fn() },
},
}))
import { prisma } from '../../src/prisma.js'
import {
parseTranscript,
computeUsageFromTranscript,
normalizeModelId,
persistJobUsage,
} from '../../scripts/persist-job-usage.js'
const mockUpdate = (prisma as unknown as { claudeJob: { update: ReturnType<typeof vi.fn> } })
.claudeJob.update
beforeEach(() => {
mockUpdate.mockReset()
})
function assistantLine(opts: {
model?: string
usage?: {
input_tokens?: number
output_tokens?: number
cache_creation_input_tokens?: number
cache_read_input_tokens?: number
}
toolUseName?: string
isSidechain?: boolean
uuid?: string
}) {
const content: Array<{ type: string; name?: string }> = []
if (opts.toolUseName) content.push({ type: 'tool_use', name: opts.toolUseName })
return JSON.stringify({
type: 'assistant',
uuid: opts.uuid,
isSidechain: opts.isSidechain ?? false,
message: {
role: 'assistant',
model: opts.model ?? 'claude-sonnet-4-6',
content,
usage: opts.usage,
},
})
}
describe('normalizeModelId', () => {
it('strips bracket suffix', () => {
expect(normalizeModelId('claude-opus-4-7[1m]')).toBe('claude-opus-4-7-1m')
})
it('passes through plain ids', () => {
expect(normalizeModelId('claude-sonnet-4-6')).toBe('claude-sonnet-4-6')
})
})
describe('parseTranscript', () => {
it('skips malformed lines', () => {
const raw = `${assistantLine({})}\nnot-json\n${assistantLine({})}\n`
expect(parseTranscript(raw)).toHaveLength(2)
})
it('handles trailing newline + empty lines', () => {
expect(parseTranscript('\n\n')).toEqual([])
})
it('dedups on uuid (branching/resumption)', () => {
const a = assistantLine({ uuid: 'u1', usage: { input_tokens: 5, output_tokens: 5 } })
const b = assistantLine({ uuid: 'u1', usage: { input_tokens: 99, output_tokens: 99 } })
const c = assistantLine({ uuid: 'u2', usage: { input_tokens: 1, output_tokens: 1 } })
const lines = parseTranscript([a, b, c].join('\n'))
expect(lines).toHaveLength(2)
expect(lines[0].uuid).toBe('u1')
expect(lines[1].uuid).toBe('u2')
})
})
describe('computeUsageFromTranscript', () => {
it('sums assistant usage after wait_for_job marker', () => {
const lines = parseTranscript(
[
assistantLine({
toolUseName: 'mcp__scrum4me__wait_for_job',
usage: { input_tokens: 999, output_tokens: 999 },
}),
assistantLine({
usage: {
input_tokens: 10,
output_tokens: 20,
cache_creation_input_tokens: 30,
cache_read_input_tokens: 40,
},
}),
assistantLine({
usage: {
input_tokens: 1,
output_tokens: 2,
cache_creation_input_tokens: 3,
cache_read_input_tokens: 4,
},
}),
assistantLine({ toolUseName: 'mcp__scrum4me__update_job_status' }),
].join('\n'),
)
const usage = computeUsageFromTranscript(lines)
expect(usage.input_tokens).toBe(11)
expect(usage.output_tokens).toBe(22)
expect(usage.cache_write_tokens).toBe(33)
expect(usage.cache_read_tokens).toBe(44)
expect(usage.model_id).toBe('claude-sonnet-4-6')
})
it('sums whole session when no wait_for_job marker', () => {
const lines = parseTranscript(
[
assistantLine({ usage: { input_tokens: 5, output_tokens: 6 } }),
assistantLine({ usage: { input_tokens: 7, output_tokens: 8 } }),
].join('\n'),
)
const usage = computeUsageFromTranscript(lines)
expect(usage.input_tokens).toBe(12)
expect(usage.output_tokens).toBe(14)
})
it('ignores non-assistant lines', () => {
const userLine = JSON.stringify({
type: 'user',
message: { role: 'user', content: [] },
})
const lines = parseTranscript(
[
assistantLine({ toolUseName: 'mcp__scrum4me__wait_for_job' }),
userLine,
assistantLine({ usage: { input_tokens: 100, output_tokens: 200 } }),
].join('\n'),
)
const usage = computeUsageFromTranscript(lines)
expect(usage.input_tokens).toBe(100)
expect(usage.output_tokens).toBe(200)
})
it('returns last model_id and normalizes [1m]-suffix', () => {
const lines = parseTranscript(
[
assistantLine({ model: 'claude-sonnet-4-6', usage: { input_tokens: 1, output_tokens: 1 } }),
assistantLine({ model: 'claude-opus-4-7[1m]', usage: { input_tokens: 1, output_tokens: 1 } }),
].join('\n'),
)
const usage = computeUsageFromTranscript(lines)
expect(usage.model_id).toBe('claude-opus-4-7-1m')
})
it('returns null model_id when transcript is empty', () => {
expect(computeUsageFromTranscript([]).model_id).toBe(null)
})
it('skips sidechain (subagent) lines to avoid double-counting', () => {
const lines = parseTranscript(
[
assistantLine({
toolUseName: 'mcp__scrum4me__wait_for_job',
uuid: 'main-1',
}),
assistantLine({
isSidechain: true,
uuid: 'sub-1',
usage: { input_tokens: 9999, output_tokens: 9999 },
}),
assistantLine({
uuid: 'main-2',
usage: { input_tokens: 50, output_tokens: 60 },
}),
].join('\n'),
)
const usage = computeUsageFromTranscript(lines)
expect(usage.input_tokens).toBe(50)
expect(usage.output_tokens).toBe(60)
})
})
describe('persistJobUsage', () => {
let tmpDir: string
let transcriptPath: string
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'persist-job-usage-test-'))
transcriptPath = join(tmpDir, 'session.jsonl')
})
function cleanup() {
rmSync(tmpDir, { recursive: true, force: true })
}
it('skips when tool_name is not update_job_status', async () => {
const result = await persistJobUsage({
tool_name: 'mcp__scrum4me__create_task',
tool_input: { job_id: 'j1', status: 'done' },
transcript_path: transcriptPath,
})
expect(result).toBe('skipped')
expect(mockUpdate).not.toHaveBeenCalled()
cleanup()
})
it('skips on status=running', async () => {
const result = await persistJobUsage({
tool_name: 'mcp__scrum4me__update_job_status',
tool_input: { job_id: 'j1', status: 'running' },
transcript_path: transcriptPath,
})
expect(result).toBe('skipped')
expect(mockUpdate).not.toHaveBeenCalled()
cleanup()
})
it('skips when transcript missing', async () => {
const result = await persistJobUsage({
tool_name: 'mcp__scrum4me__update_job_status',
tool_input: { job_id: 'j1', status: 'done' },
transcript_path: '/no/such/file.jsonl',
})
expect(result).toBe('skipped')
expect(mockUpdate).not.toHaveBeenCalled()
})
it('writes computed usage on success', async () => {
writeFileSync(
transcriptPath,
[
assistantLine({ toolUseName: 'mcp__scrum4me__wait_for_job' }),
assistantLine({
model: 'claude-sonnet-4-6',
usage: {
input_tokens: 10,
output_tokens: 20,
cache_creation_input_tokens: 30,
cache_read_input_tokens: 40,
},
}),
assistantLine({ toolUseName: 'mcp__scrum4me__update_job_status' }),
].join('\n'),
)
mockUpdate.mockResolvedValue({})
const result = await persistJobUsage({
tool_name: 'mcp__scrum4me__update_job_status',
tool_input: { job_id: 'job-123', status: 'done' },
transcript_path: transcriptPath,
})
expect(result).toBe('written')
expect(mockUpdate).toHaveBeenCalledWith({
where: { id: 'job-123' },
data: {
model_id: 'claude-sonnet-4-6',
input_tokens: 10,
output_tokens: 20,
cache_read_tokens: 40,
cache_write_tokens: 30,
},
})
cleanup()
})
it('returns noop when transcript has no usage', async () => {
writeFileSync(transcriptPath, '')
const result = await persistJobUsage({
tool_name: 'mcp__scrum4me__update_job_status',
tool_input: { job_id: 'job-123', status: 'failed' },
transcript_path: transcriptPath,
})
expect(result).toBe('noop')
expect(mockUpdate).not.toHaveBeenCalled()
cleanup()
})
})

View file

@ -1,129 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../src/prisma.js', () => ({
prisma: {
pbi: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}))
vi.mock('../src/auth.js', () => ({
requireWriteAccess: vi.fn(),
PermissionDeniedError: class PermissionDeniedError extends Error {
constructor(message = 'Demo accounts cannot perform write operations') {
super(message)
this.name = 'PermissionDeniedError'
}
},
}))
vi.mock('../src/access.js', () => ({
userCanAccessProduct: vi.fn(),
}))
import { prisma } from '../src/prisma.js'
import { requireWriteAccess, PermissionDeniedError } from '../src/auth.js'
import { userCanAccessProduct } from '../src/access.js'
import { handleSetPbiPr, inputSchema } from '../src/tools/set-pbi-pr.js'
const mockPrisma = prisma as unknown as {
pbi: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
}
const mockRequireWriteAccess = requireWriteAccess as ReturnType<typeof vi.fn>
const mockUserCanAccessProduct = userCanAccessProduct as ReturnType<typeof vi.fn>
const VALID_PR_URL = 'https://github.com/owner/repo/pull/42'
const PBI_ID = 'pbi-abc123'
const USER_ID = 'user-1'
beforeEach(() => {
vi.clearAllMocks()
mockRequireWriteAccess.mockResolvedValue({ userId: USER_ID, tokenId: 'tok-1', username: 'alice', isDemo: false })
mockPrisma.pbi.findUnique.mockResolvedValue({ product_id: 'prod-1' })
mockUserCanAccessProduct.mockResolvedValue(true)
mockPrisma.pbi.update.mockResolvedValue({})
})
describe('handleSetPbiPr', () => {
it('happy path: updates pr_url and clears pr_merged_at', async () => {
const result = await handleSetPbiPr({ pbi_id: PBI_ID, pr_url: VALID_PR_URL })
expect(result.isError).toBeFalsy()
expect(mockPrisma.pbi.update).toHaveBeenCalledWith({
where: { id: PBI_ID },
data: { pr_url: VALID_PR_URL, pr_merged_at: null },
})
const text = result.content[0].type === 'text' ? result.content[0].text : ''
const parsed = JSON.parse(text)
expect(parsed).toEqual({ ok: true, pbi_id: PBI_ID, pr_url: VALID_PR_URL })
})
it('idempotent: second call with different url overwrites', async () => {
const newUrl = 'https://github.com/owner/repo/pull/99'
await handleSetPbiPr({ pbi_id: PBI_ID, pr_url: newUrl })
expect(mockPrisma.pbi.update).toHaveBeenCalledWith({
where: { id: PBI_ID },
data: { pr_url: newUrl, pr_merged_at: null },
})
})
it('returns error when PBI not found', async () => {
mockPrisma.pbi.findUnique.mockResolvedValue(null)
const result = await handleSetPbiPr({ pbi_id: PBI_ID, pr_url: VALID_PR_URL })
expect(result.isError).toBe(true)
const text = result.content[0].type === 'text' ? result.content[0].text : ''
expect(text).toMatch(PBI_ID)
expect(mockPrisma.pbi.update).not.toHaveBeenCalled()
})
it('returns error when user has no access to the product', async () => {
mockUserCanAccessProduct.mockResolvedValue(false)
const result = await handleSetPbiPr({ pbi_id: PBI_ID, pr_url: VALID_PR_URL })
expect(result.isError).toBe(true)
expect(mockPrisma.pbi.update).not.toHaveBeenCalled()
})
it('returns PERMISSION_DENIED for demo accounts', async () => {
mockRequireWriteAccess.mockRejectedValue(new PermissionDeniedError())
const result = await handleSetPbiPr({ pbi_id: PBI_ID, pr_url: VALID_PR_URL })
expect(result.isError).toBe(true)
const text = result.content[0].type === 'text' ? result.content[0].text : ''
expect(text).toMatch(/PERMISSION_DENIED/)
})
})
describe('inputSchema validation', () => {
it('accepts a valid GitHub PR URL', () => {
const r = inputSchema.safeParse({ pbi_id: PBI_ID, pr_url: VALID_PR_URL })
expect(r.success).toBe(true)
})
it('rejects a URL pointing to an issue instead of a pull', () => {
const r = inputSchema.safeParse({ pbi_id: PBI_ID, pr_url: 'https://github.com/owner/repo/issues/42' })
expect(r.success).toBe(false)
})
it('rejects a non-GitHub URL', () => {
const r = inputSchema.safeParse({ pbi_id: PBI_ID, pr_url: 'https://gitlab.com/owner/repo/pull/42' })
expect(r.success).toBe(false)
})
it('rejects a URL without a numeric PR number', () => {
const r = inputSchema.safeParse({ pbi_id: PBI_ID, pr_url: 'https://github.com/owner/repo/pull/abc' })
expect(r.success).toBe(false)
})
it('rejects an empty pbi_id', () => {
const r = inputSchema.safeParse({ pbi_id: '', pr_url: VALID_PR_URL })
expect(r.success).toBe(false)
})
})

View file

@ -8,24 +8,6 @@ vi.mock('../src/prisma.js', () => ({
},
story: {
findUniqueOrThrow: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
},
pbi: {
findUniqueOrThrow: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
},
sprint: {
findUniqueOrThrow: vi.fn(),
update: vi.fn(),
},
claudeJob: {
findFirst: vi.fn(),
updateMany: vi.fn(),
},
sprintRun: {
findUnique: vi.fn(),
update: vi.fn(),
},
$transaction: vi.fn(),
@ -33,47 +15,14 @@ vi.mock('../src/prisma.js', () => ({
}))
import { prisma } from '../src/prisma.js'
import {
propagateStatusUpwards,
updateTaskStatusWithStoryPromotion,
} from '../src/lib/tasks-status-update.js'
import { updateTaskStatusWithStoryPromotion } from '../src/lib/tasks-status-update.js'
type MockedPrisma = {
const mockPrisma = prisma as unknown as {
task: { update: ReturnType<typeof vi.fn>; findMany: ReturnType<typeof vi.fn> }
story: {
findUniqueOrThrow: ReturnType<typeof vi.fn>
findMany: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
pbi: {
findUniqueOrThrow: ReturnType<typeof vi.fn>
findMany: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
sprint: {
findUniqueOrThrow: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
claudeJob: {
findFirst: ReturnType<typeof vi.fn>
updateMany: ReturnType<typeof vi.fn>
}
sprintRun: {
findUnique: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
story: { findUniqueOrThrow: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
$transaction: ReturnType<typeof vi.fn>
}
const mockPrisma = prisma as unknown as MockedPrisma
const TASK_BASE = {
id: 'task-1',
title: 'Task',
story_id: 'story-1',
implementation_plan: null,
}
beforeEach(() => {
vi.clearAllMocks()
mockPrisma.$transaction.mockImplementation(
@ -81,181 +30,107 @@ beforeEach(() => {
)
})
describe('propagateStatusUpwards — story-niveau', () => {
it('zet story op DONE wanneer alle siblings DONE zijn', async () => {
const TASK_BASE = {
id: 'task-1',
title: 'Task',
story_id: 'story-1',
implementation_plan: null,
}
describe('updateTaskStatusWithStoryPromotion', () => {
it('promotes story to DONE when last sibling task transitions to DONE', async () => {
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' })
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }, { status: 'DONE' }])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
id: 'story-1',
status: 'IN_SPRINT',
pbi_id: 'pbi-1',
sprint_id: null,
})
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
const result = await propagateStatusUpwards('task-1', 'DONE')
const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE')
expect(result.storyChanged).toBe(true)
expect(result.storyStatusChange).toBe('promoted')
expect(result.storyId).toBe('story-1')
expect(mockPrisma.story.update).toHaveBeenCalledWith({
where: { id: 'story-1' },
data: { status: 'DONE' },
})
})
it('zet story op FAILED wanneer een task FAILED is, ongeacht andere tasks', async () => {
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'FAILED' })
mockPrisma.task.findMany.mockResolvedValue([
{ status: 'FAILED' },
{ status: 'DONE' },
{ status: 'TO_DO' },
])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
id: 'story-1',
status: 'IN_SPRINT',
pbi_id: 'pbi-1',
sprint_id: null,
})
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
mockPrisma.story.findMany.mockResolvedValue([{ status: 'FAILED' }])
const result = await propagateStatusUpwards('task-1', 'FAILED')
expect(result.storyChanged).toBe(true)
expect(mockPrisma.story.update).toHaveBeenCalledWith({
where: { id: 'story-1' },
data: { status: 'FAILED' },
})
})
})
describe('propagateStatusUpwards — PBI BLOCKED met rust laten', () => {
it('overschrijft een handmatig BLOCKED PBI niet', async () => {
it('does not promote when story is already DONE (idempotent)', async () => {
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' })
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
id: 'story-1',
status: 'IN_SPRINT',
pbi_id: 'pbi-1',
sprint_id: null,
})
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'BLOCKED' })
const result = await propagateStatusUpwards('task-1', 'DONE')
expect(result.pbiChanged).toBe(false)
expect(mockPrisma.pbi.update).not.toHaveBeenCalled()
})
})
describe('propagateStatusUpwards — sprint cascade tot SprintRun', () => {
it('zet bij FAILED de hele keten op FAILED en cancelt sibling-jobs', async () => {
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'FAILED' })
mockPrisma.task.findMany.mockResolvedValue([{ status: 'FAILED' }, { status: 'DONE' }])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
id: 'story-1',
status: 'IN_SPRINT',
pbi_id: 'pbi-1',
sprint_id: 'sprint-1',
})
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
mockPrisma.story.findMany.mockImplementation(
async (args: { where?: { pbi_id?: string; sprint_id?: string } }) => {
if (args.where?.pbi_id) return [{ status: 'FAILED' }]
if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }]
return []
},
)
mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'ACTIVE' })
;(mockPrisma.pbi as unknown as { findMany: ReturnType<typeof vi.fn> }).findMany = vi
.fn()
.mockResolvedValue([{ status: 'FAILED' }])
mockPrisma.claudeJob.findFirst.mockResolvedValue({ id: 'job-1', sprint_run_id: 'run-1' })
mockPrisma.sprintRun.findUnique.mockResolvedValue({ id: 'run-1', status: 'RUNNING' })
const result = await propagateStatusUpwards('task-1', 'FAILED')
expect(result.sprintChanged).toBe(true)
expect(result.sprintRunChanged).toBe(true)
expect(mockPrisma.sprintRun.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'run-1' },
data: expect.objectContaining({ status: 'FAILED', failed_task_id: 'task-1' }),
}),
)
expect(mockPrisma.claudeJob.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
sprint_run_id: 'run-1',
status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] },
id: { not: 'job-1' },
}),
data: expect.objectContaining({ status: 'CANCELLED' }),
}),
)
})
})
describe('updateTaskStatusWithStoryPromotion (BC-wrapper)', () => {
it('mapt storyChanged + DONE-newStatus naar storyStatusChange="promoted"', async () => {
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' })
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
id: 'story-1',
status: 'IN_SPRINT',
pbi_id: 'pbi-1',
sprint_id: null,
})
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' })
const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE')
expect(result.storyStatusChange).toBe('promoted')
expect(result.storyId).toBe('story-1')
expect(result.storyStatusChange).toBe(null)
expect(mockPrisma.story.update).not.toHaveBeenCalled()
})
it('mapt storyChanged + non-DONE naar storyStatusChange="demoted"', async () => {
it('does not promote when not all siblings are DONE', async () => {
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' })
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }, { status: 'IN_PROGRESS' }])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE')
expect(result.storyStatusChange).toBe(null)
expect(mockPrisma.story.update).not.toHaveBeenCalled()
})
it('demotes story to IN_SPRINT when a task moves out of DONE on a DONE story', async () => {
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' })
mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }, { status: 'DONE' }])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
id: 'story-1',
status: 'DONE',
pbi_id: 'pbi-1',
sprint_id: null,
})
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'DONE' })
mockPrisma.story.findMany.mockResolvedValue([{ status: 'OPEN' }])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' })
const result = await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS')
expect(result.storyStatusChange).toBe('demoted')
expect(mockPrisma.story.update).toHaveBeenCalledWith({
where: { id: 'story-1' },
data: { status: 'IN_SPRINT' },
})
})
it('null wanneer story niet verandert', async () => {
it('does not demote when story is not DONE', async () => {
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' })
mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }, { status: 'TO_DO' }])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
id: 'story-1',
status: 'IN_SPRINT',
pbi_id: 'pbi-1',
sprint_id: 'sprint-1',
})
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
mockPrisma.story.findMany.mockImplementation(
async (args: { where?: { pbi_id?: string; sprint_id?: string } }) => {
if (args.where?.pbi_id) return [{ status: 'IN_SPRINT' }]
if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }]
return []
},
)
mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'ACTIVE' })
;(mockPrisma.pbi as unknown as { findMany: ReturnType<typeof vi.fn> }).findMany = vi
.fn()
.mockResolvedValue([{ status: 'READY' }])
mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
const result = await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS')
expect(result.storyStatusChange).toBe(null)
expect(mockPrisma.story.update).not.toHaveBeenCalled()
})
it('updates the task regardless of story-status change', async () => {
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' })
mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS')
expect(mockPrisma.task.update).toHaveBeenCalledWith({
where: { id: 'task-1' },
data: { status: 'IN_PROGRESS' },
select: expect.any(Object),
})
})
it('uses the provided transaction client when passed', async () => {
const tx = {
task: { update: vi.fn(), findMany: vi.fn() },
story: { findUniqueOrThrow: vi.fn(), update: vi.fn() },
}
tx.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' })
tx.task.findMany.mockResolvedValue([{ status: 'DONE' }])
tx.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE', tx as any)
expect(result.storyStatusChange).toBe('promoted')
expect(mockPrisma.$transaction).not.toHaveBeenCalled()
expect(tx.story.update).toHaveBeenCalledWith({
where: { id: 'story-1' },
data: { status: 'DONE' },
})
})
})

View file

@ -1,140 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../src/prisma.js', () => ({
prisma: {
idea: { update: vi.fn() },
ideaLog: { create: vi.fn() },
$transaction: vi.fn(),
},
}))
vi.mock('../src/auth.js', () => ({
requireWriteAccess: vi.fn(),
PermissionDeniedError: class PermissionDeniedError extends Error {
constructor(message = 'Demo accounts cannot perform write operations') {
super(message)
this.name = 'PermissionDeniedError'
}
},
}))
vi.mock('../src/access.js', () => ({
userOwnsIdea: vi.fn(),
}))
import { prisma } from '../src/prisma.js'
import { requireWriteAccess } from '../src/auth.js'
import { userOwnsIdea } from '../src/access.js'
import { handleUpdateIdeaPlanReviewed } from '../src/tools/update-idea-plan-reviewed.js'
const mockPrisma = prisma as unknown as {
idea: { update: ReturnType<typeof vi.fn> }
ideaLog: { create: ReturnType<typeof vi.fn> }
$transaction: ReturnType<typeof vi.fn>
}
const mockRequireWriteAccess = requireWriteAccess as ReturnType<typeof vi.fn>
const mockUserOwnsIdea = userOwnsIdea as ReturnType<typeof vi.fn>
const IDEA_ID = 'idea-1'
const USER_ID = 'user-1'
const REVIEW_LOG = {
rounds: [{ score: 88 }],
convergence: { stable_at_round: 2 },
approval: { status: 'approved' },
}
beforeEach(() => {
vi.clearAllMocks()
mockRequireWriteAccess.mockResolvedValue({
userId: USER_ID,
tokenId: 'tok-1',
username: 'alice',
isDemo: false,
})
mockUserOwnsIdea.mockResolvedValue(true)
// $transaction returns the array of its two operations' results; the handler
// only reads result[0] (the idea.update result).
mockPrisma.$transaction.mockImplementation(async () => [
{ id: IDEA_ID, status: 'PLACEHOLDER', code: 'IDEA-1' },
{},
])
})
function parseResult(result: Awaited<ReturnType<typeof handleUpdateIdeaPlanReviewed>>) {
const text = result.content?.[0]?.type === 'text' ? result.content[0].text : ''
try {
return JSON.parse(text)
} catch {
return text
}
}
// The handler builds `data.status` inside the idea.update call passed to
// $transaction. We capture it by inspecting the prisma.idea.update mock args.
function statusPassedToUpdate(): string | undefined {
const call = mockPrisma.idea.update.mock.calls[0]
return call?.[0]?.data?.status
}
describe('handleUpdateIdeaPlanReviewed — status transition', () => {
it('approval_status="approved" → PLAN_REVIEWED', async () => {
await handleUpdateIdeaPlanReviewed({
idea_id: IDEA_ID,
review_log: REVIEW_LOG,
approval_status: 'approved',
})
expect(statusPassedToUpdate()).toBe('PLAN_REVIEWED')
})
it('approval_status="rejected" → PLAN_REVIEW_FAILED', async () => {
await handleUpdateIdeaPlanReviewed({
idea_id: IDEA_ID,
review_log: REVIEW_LOG,
approval_status: 'rejected',
})
expect(statusPassedToUpdate()).toBe('PLAN_REVIEW_FAILED')
})
it('approval_status="pending" → PLAN_REVIEW_FAILED (needs manual approval, never silently approved)', async () => {
await handleUpdateIdeaPlanReviewed({
idea_id: IDEA_ID,
review_log: REVIEW_LOG,
approval_status: 'pending',
})
expect(statusPassedToUpdate()).toBe('PLAN_REVIEW_FAILED')
})
it('omitted approval_status → PLAN_REVIEW_FAILED (safe default, not PLAN_REVIEWED)', async () => {
await handleUpdateIdeaPlanReviewed({
idea_id: IDEA_ID,
review_log: REVIEW_LOG,
})
expect(statusPassedToUpdate()).toBe('PLAN_REVIEW_FAILED')
})
it('returns "Idea not found" when the user does not own the idea', async () => {
mockUserOwnsIdea.mockResolvedValue(false)
const result = await handleUpdateIdeaPlanReviewed({
idea_id: IDEA_ID,
review_log: REVIEW_LOG,
approval_status: 'approved',
})
expect(parseResult(result)).toContain('Idea not found')
expect(mockPrisma.idea.update).not.toHaveBeenCalled()
})
it('persists review_log + reviewed_at and logs a PLAN_REVIEW_RESULT entry', async () => {
await handleUpdateIdeaPlanReviewed({
idea_id: IDEA_ID,
review_log: REVIEW_LOG,
approval_status: 'approved',
})
const updateArg = mockPrisma.idea.update.mock.calls[0]?.[0]
expect(updateArg?.data?.plan_review_log).toEqual(REVIEW_LOG)
expect(updateArg?.data?.reviewed_at).toBeInstanceOf(Date)
const logArg = mockPrisma.ideaLog.create.mock.calls[0]?.[0]
expect(logArg?.data?.type).toBe('PLAN_REVIEW_RESULT')
expect(logArg?.data?.idea_id).toBe(IDEA_ID)
})
})

View file

@ -4,13 +4,12 @@ vi.mock('../src/prisma.js', () => ({
prisma: {
product: { findUnique: vi.fn() },
task: { findUnique: vi.fn() },
claudeJob: { findFirst: vi.fn(), findMany: vi.fn(), findUnique: vi.fn() },
claudeJob: { findFirst: vi.fn() },
},
}))
vi.mock('../src/git/pr.js', () => ({
createPullRequest: vi.fn(),
markPullRequestReady: vi.fn(),
}))
import { prisma } from '../src/prisma.js'
@ -20,11 +19,7 @@ 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> }
claudeJob: {
findFirst: ReturnType<typeof vi.fn>
findMany: ReturnType<typeof vi.fn>
findUnique: ReturnType<typeof vi.fn>
}
claudeJob: { findFirst: ReturnType<typeof vi.fn> }
}
const mockCreatePr = createPullRequest as ReturnType<typeof vi.fn>
@ -42,12 +37,9 @@ beforeEach(() => {
mockPrisma.product.findUnique.mockResolvedValue({ auto_pr: true })
mockPrisma.task.findUnique.mockResolvedValue({
title: 'Add feature',
repo_url: null,
story: { id: 'story-1', code: 'SCRUM-42', title: 'Story title' },
})
mockPrisma.claudeJob.findMany.mockResolvedValue([]) // no sibling PRs by default
// Default: legacy job zonder sprint_run (STORY-mode pad).
mockPrisma.claudeJob.findUnique.mockResolvedValue({ sprint_run_id: null, sprint_run: null })
mockPrisma.claudeJob.findFirst.mockResolvedValue(null) // no sibling PR by default
mockCreatePr.mockResolvedValue({ url: 'https://github.com/org/repo/pull/99' })
})
@ -64,27 +56,12 @@ describe('maybeCreateAutoPr', () => {
})
it('reuses sibling pr_url when another job in same story already opened a PR', async () => {
mockPrisma.claudeJob.findMany.mockResolvedValue([
{ pr_url: 'https://github.com/org/repo/pull/77', task: { repo_url: null } },
])
mockPrisma.claudeJob.findFirst.mockResolvedValue({ pr_url: 'https://github.com/org/repo/pull/77' })
const url = await maybeCreateAutoPr(BASE_OPTS)
expect(url).toBe('https://github.com/org/repo/pull/77')
expect(mockCreatePr).not.toHaveBeenCalled()
})
it('does NOT reuse a sibling PR from a different repo (cross-repo story)', async () => {
// Sibling targeted another repo via task.repo_url — its PR must not leak in.
mockPrisma.claudeJob.findMany.mockResolvedValue([
{
pr_url: 'https://github.com/org/other-repo/pull/12',
task: { repo_url: 'https://github.com/org/other-repo' },
},
])
const url = await maybeCreateAutoPr(BASE_OPTS)
expect(url).toBe('https://github.com/org/repo/pull/99') // fresh PR, not the sibling's
expect(mockCreatePr).toHaveBeenCalledOnce()
})
it('returns null when auto_pr=false', async () => {
mockPrisma.product.findUnique.mockResolvedValue({ auto_pr: false })
const url = await maybeCreateAutoPr(BASE_OPTS)
@ -95,7 +72,6 @@ describe('maybeCreateAutoPr', () => {
it('uses story title without code prefix when story has no code', async () => {
mockPrisma.task.findUnique.mockResolvedValue({
title: 'Add feature',
repo_url: null,
story: { id: 'story-1', code: null, title: 'Story title' },
})
await maybeCreateAutoPr(BASE_OPTS)
@ -104,66 +80,6 @@ describe('maybeCreateAutoPr', () => {
)
})
it('SPRINT-mode: maakt een draft-PR aan met sprint-titel, geen auto-merge', async () => {
mockPrisma.claudeJob.findUnique.mockResolvedValue({
sprint_run_id: 'run-1',
sprint_run: {
id: 'run-1',
pr_strategy: 'SPRINT',
sprint: { sprint_goal: 'Cascade-flow live' },
},
})
const url = await maybeCreateAutoPr(BASE_OPTS)
expect(url).toBe('https://github.com/org/repo/pull/99')
expect(mockCreatePr).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Sprint: Cascade-flow live',
draft: true,
enableAutoMerge: false,
}),
)
})
it('SPRINT-mode: hergebruikt sibling-PR binnen dezelfde SprintRun', async () => {
mockPrisma.claudeJob.findUnique.mockResolvedValue({
sprint_run_id: 'run-1',
sprint_run: { id: 'run-1', pr_strategy: 'SPRINT', sprint: { sprint_goal: 'Goal' } },
})
mockPrisma.claudeJob.findMany.mockResolvedValue([
{ pr_url: 'https://github.com/org/repo/pull/55', task: { repo_url: null } },
])
const url = await maybeCreateAutoPr(BASE_OPTS)
expect(url).toBe('https://github.com/org/repo/pull/55')
expect(mockCreatePr).not.toHaveBeenCalled()
})
it('SPRINT-mode: cross-repo — sibling-PR van ander repo wordt niet hergebruikt', async () => {
mockPrisma.claudeJob.findUnique.mockResolvedValue({
sprint_run_id: 'run-1',
sprint_run: { id: 'run-1', pr_strategy: 'SPRINT', sprint: { sprint_goal: 'Goal' } },
})
// Deze job target een ander repo via task.repo_url.
mockPrisma.task.findUnique.mockResolvedValue({
title: 'MCP-taak',
repo_url: 'https://github.com/org/scrum4me-mcp',
story: { id: 'story-1', code: 'SCRUM-9', title: 'Story title' },
})
// Sibling met pr_url hoort bij het product-repo (repo_url null) → andere bucket.
mockPrisma.claudeJob.findMany.mockResolvedValue([
{ pr_url: 'https://github.com/org/repo/pull/201', task: { repo_url: null } },
])
const url = await maybeCreateAutoPr(BASE_OPTS)
// Geen hergebruik van de product-repo PR → eigen draft-PR voor het mcp-repo.
expect(url).toBe('https://github.com/org/repo/pull/99')
expect(mockCreatePr).toHaveBeenCalledOnce()
})
it('returns null and does not throw when gh fails', async () => {
mockCreatePr.mockResolvedValue({ error: 'gh CLI not found' })
const url = await maybeCreateAutoPr(BASE_OPTS)

View file

@ -1,79 +1,39 @@
import { describe, it, expect } from 'vitest'
import { checkVerifyGate } from '../src/tools/update-job-status.js'
const LONG_SUMMARY = 'Refactor touched extra files for type narrowing.'
describe('checkVerifyGate', () => {
it('rejects when verify_result is null', () => {
it('rejects when verify_result is null — agent must verify first', () => {
const r = checkVerifyGate(null, false)
expect(r.allowed).toBe(false)
if (!r.allowed) expect(r.error).toMatch(/verify_task_against_plan/i)
})
it('rejects EMPTY when task is not verify_only', () => {
it('rejects when verify_result is EMPTY and task is not verify_only', () => {
const r = checkVerifyGate('EMPTY', false)
expect(r.allowed).toBe(false)
if (!r.allowed) expect(r.error).toMatch(/EMPTY/i)
if (!r.allowed) {
expect(r.error).toMatch(/EMPTY/i)
expect(r.error).toMatch(/verify_only/i)
}
})
it('allows EMPTY when task is verify_only', () => {
expect(checkVerifyGate('EMPTY', true).allowed).toBe(true)
it('allows when verify_result is EMPTY and task IS verify_only', () => {
const r = checkVerifyGate('EMPTY', true)
expect(r.allowed).toBe(true)
})
it('always allows ALIGNED', () => {
expect(checkVerifyGate('ALIGNED', false, 'ALIGNED').allowed).toBe(true)
expect(checkVerifyGate('ALIGNED', false, 'ALIGNED_OR_PARTIAL').allowed).toBe(true)
expect(checkVerifyGate('ALIGNED', false, 'ANY').allowed).toBe(true)
it('allows when verify_result is ALIGNED', () => {
const r = checkVerifyGate('ALIGNED', false)
expect(r.allowed).toBe(true)
})
describe('verify_required=ALIGNED (strict)', () => {
it('rejects PARTIAL', () => {
const r = checkVerifyGate('PARTIAL', false, 'ALIGNED', LONG_SUMMARY)
expect(r.allowed).toBe(false)
if (!r.allowed) expect(r.error).toMatch(/ALIGNED/)
})
it('rejects DIVERGENT', () => {
const r = checkVerifyGate('DIVERGENT', false, 'ALIGNED', LONG_SUMMARY)
expect(r.allowed).toBe(false)
})
})
describe('verify_required=ALIGNED_OR_PARTIAL (default — needs summary on drift)', () => {
it('rejects PARTIAL without summary', () => {
const r = checkVerifyGate('PARTIAL', false, 'ALIGNED_OR_PARTIAL', undefined)
expect(r.allowed).toBe(false)
if (!r.allowed) expect(r.error).toMatch(/summary/i)
})
it('rejects PARTIAL with too-short summary', () => {
const r = checkVerifyGate('PARTIAL', false, 'ALIGNED_OR_PARTIAL', 'short')
expect(r.allowed).toBe(false)
})
it('allows PARTIAL with long summary', () => {
expect(checkVerifyGate('PARTIAL', false, 'ALIGNED_OR_PARTIAL', LONG_SUMMARY).allowed).toBe(true)
})
it('rejects DIVERGENT without summary', () => {
expect(checkVerifyGate('DIVERGENT', false, 'ALIGNED_OR_PARTIAL', undefined).allowed).toBe(false)
})
it('allows DIVERGENT with long summary', () => {
expect(checkVerifyGate('DIVERGENT', false, 'ALIGNED_OR_PARTIAL', LONG_SUMMARY).allowed).toBe(true)
})
})
describe('verify_required=ANY (refactor escape hatch)', () => {
it('allows PARTIAL without summary', () => {
expect(checkVerifyGate('PARTIAL', false, 'ANY').allowed).toBe(true)
})
it('allows DIVERGENT without summary', () => {
expect(checkVerifyGate('DIVERGENT', false, 'ANY').allowed).toBe(true)
})
it('still rejects EMPTY (verify_only takes precedence)', () => {
expect(checkVerifyGate('EMPTY', false, 'ANY').allowed).toBe(false)
})
})
it('default verify_required=ALIGNED_OR_PARTIAL when omitted', () => {
// No third arg → falls back to ALIGNED_OR_PARTIAL → PARTIAL needs summary
it('allows when verify_result is PARTIAL', () => {
const r = checkVerifyGate('PARTIAL', false)
expect(r.allowed).toBe(false)
expect(r.allowed).toBe(true)
})
it('allows when verify_result is DIVERGENT', () => {
const r = checkVerifyGate('DIVERGENT', false)
expect(r.allowed).toBe(true)
})
})

View file

@ -5,26 +5,13 @@ vi.mock('../src/git/push.js', () => ({
pushBranchForJob: vi.fn(),
}))
vi.mock('../src/prisma.js', () => ({
prisma: {
claudeJob: {
findUnique: vi.fn(),
},
},
}))
import { pushBranchForJob } from '../src/git/push.js'
import { prisma } from '../src/prisma.js'
import { prepareDoneUpdate } from '../src/tools/update-job-status.js'
const mockPush = pushBranchForJob as ReturnType<typeof vi.fn>
const mockFindUnique = (prisma as unknown as {
claudeJob: { findUnique: ReturnType<typeof vi.fn> }
}).claudeJob.findUnique
beforeEach(() => {
vi.clearAllMocks()
mockFindUnique.mockResolvedValue(null)
})
describe('prepareDoneUpdate', () => {
@ -52,25 +39,8 @@ describe('prepareDoneUpdate', () => {
})
})
it('reads branchName from DB (claudeJob.branch) when branch arg is undefined', async () => {
it('derives branchName from jobId when branch is undefined', async () => {
process.env.SCRUM4ME_AGENT_WORKTREE_DIR = '/wt'
mockFindUnique.mockResolvedValue({ branch: 'feat/sprint-fvy30lvv' })
mockPush.mockResolvedValue({ pushed: true, remoteRef: 'refs/heads/feat/sprint-fvy30lvv' })
await prepareDoneUpdate('job-abc12345', undefined)
expect(mockFindUnique).toHaveBeenCalledWith({
where: { id: 'job-abc12345' },
select: { branch: true },
})
expect(mockPush).toHaveBeenCalledWith(
expect.objectContaining({ branchName: 'feat/sprint-fvy30lvv' }),
)
})
it('falls back to feat/job-<8> when neither branch arg nor DB.branch is set', async () => {
process.env.SCRUM4ME_AGENT_WORKTREE_DIR = '/wt'
mockFindUnique.mockResolvedValue({ branch: null })
mockPush.mockResolvedValue({ pushed: true, remoteRef: 'refs/heads/feat/job-abc12345' })
await prepareDoneUpdate('job-abc12345', undefined)

View file

@ -1,95 +0,0 @@
// Unit-tests voor de no-op SKIPPED exit-route in update_job_status (PBI-57 ST-1273).
// Volle handler-integratie wordt niet hier getest — die hangt aan tientallen
// MCP/Prisma-mocks. Wel testen we de geëxporteerde helpers die expliciet
// SKIPPED-aware zijn gemaakt: resolveNextAction en cleanupWorktreeForTerminalStatus.
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../src/prisma.js', () => ({
prisma: {
claudeJob: { findUnique: vi.fn(), count: 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(),
}
})
import { prisma } from '../src/prisma.js'
import { removeWorktreeForJob } from '../src/git/worktree.js'
import { resolveRepoRoot } from '../src/tools/wait-for-job.js'
import {
cleanupWorktreeForTerminalStatus,
resolveNextAction,
} from '../src/tools/update-job-status.js'
const mockRemove = removeWorktreeForJob as ReturnType<typeof vi.fn>
const mockResolve = resolveRepoRoot as ReturnType<typeof vi.fn>
const mockPrisma = prisma as unknown as {
claudeJob: {
findUnique: ReturnType<typeof vi.fn>
count: ReturnType<typeof vi.fn>
}
}
beforeEach(() => {
vi.clearAllMocks()
mockPrisma.claudeJob.findUnique.mockResolvedValue({ task: { story_id: 'story-default' } })
mockPrisma.claudeJob.count.mockResolvedValue(0)
})
describe('resolveNextAction — skipped pad', () => {
it('returns wait_for_job_again when queue has jobs after skipped', () => {
expect(resolveNextAction(2, 'skipped')).toBe('wait_for_job_again')
})
it('returns queue_empty when queue is empty after skipped', () => {
expect(resolveNextAction(0, 'skipped')).toBe('queue_empty')
})
})
describe('cleanupWorktreeForTerminalStatus — skipped pad', () => {
it('calls removeWorktreeForJob with keepBranch=false when skipped (no push happened)', async () => {
mockResolve.mockResolvedValue('/repos/my-project')
mockRemove.mockResolvedValue({ removed: true })
await cleanupWorktreeForTerminalStatus('prod-001', 'job-skip', 'skipped', undefined)
expect(mockRemove).toHaveBeenCalledWith({
repoRoot: '/repos/my-project',
jobId: 'job-skip',
keepBranch: false,
})
})
it('keeps keepBranch=false when skipped even if a branch is reported', async () => {
mockResolve.mockResolvedValue('/repos/my-project')
mockRemove.mockResolvedValue({ removed: true })
await cleanupWorktreeForTerminalStatus('prod-001', 'job-skip', 'skipped', 'feat/job-skip')
expect(mockRemove).toHaveBeenCalledWith({
repoRoot: '/repos/my-project',
jobId: 'job-skip',
keepBranch: false,
})
})
it('defers cleanup when sibling jobs in same story are still active (skipped path)', async () => {
mockResolve.mockResolvedValue('/repos/my-project')
mockPrisma.claudeJob.findUnique.mockResolvedValue({ task: { story_id: 'story-shared' } })
mockPrisma.claudeJob.count.mockResolvedValue(1)
await cleanupWorktreeForTerminalStatus('prod-001', 'job-skip', 'skipped', undefined)
expect(mockRemove).not.toHaveBeenCalled()
})
})

View file

@ -1,192 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../src/prisma.js', () => ({
prisma: {
sprintTaskExecution: {
findMany: vi.fn(),
},
sprintRun: {
findUnique: vi.fn(),
update: vi.fn(),
},
story: {
count: vi.fn(),
},
},
}))
import { prisma } from '../src/prisma.js'
import {
checkSprintVerifyGate,
finalizeSprintRunOnDone,
} from '../src/tools/update-job-status.js'
type MockedPrisma = {
sprintTaskExecution: { findMany: ReturnType<typeof vi.fn> }
sprintRun: {
findUnique: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
story: { count: ReturnType<typeof vi.fn> }
}
const mocked = prisma as unknown as MockedPrisma
const LONG_SUMMARY = 'Refactor touched extra files for type narrowing.'
function execRow(overrides: Record<string, unknown>) {
return {
id: 'exec-' + Math.random().toString(36).slice(2, 8),
task_id: 't1',
order: 0,
status: 'DONE',
verify_result: 'ALIGNED',
verify_summary: null,
verify_required_snapshot: 'ALIGNED_OR_PARTIAL',
verify_only_snapshot: false,
task: { code: 'TASK-1', title: 'Sample task' },
...overrides,
}
}
describe('checkSprintVerifyGate', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('rejects when no executions exist (claim-bug)', async () => {
mocked.sprintTaskExecution.findMany.mockResolvedValue([])
const r = await checkSprintVerifyGate('job-x')
expect(r.allowed).toBe(false)
if (!r.allowed) expect(r.error).toMatch(/geen SprintTaskExecution-rows/i)
})
it('blocks PENDING/RUNNING executions', async () => {
mocked.sprintTaskExecution.findMany.mockResolvedValue([
execRow({ status: 'PENDING' }),
execRow({ status: 'RUNNING' }),
])
const r = await checkSprintVerifyGate('job-x')
expect(r.allowed).toBe(false)
if (!r.allowed) {
expect(r.error).toMatch(/PENDING/)
expect(r.error).toMatch(/RUNNING/)
}
})
it('blocks FAILED executions', async () => {
mocked.sprintTaskExecution.findMany.mockResolvedValue([
execRow({ status: 'FAILED' }),
])
const r = await checkSprintVerifyGate('job-x')
expect(r.allowed).toBe(false)
if (!r.allowed) expect(r.error).toMatch(/FAILED/)
})
it('blocks SKIPPED unless verify_required_snapshot=ANY', async () => {
mocked.sprintTaskExecution.findMany.mockResolvedValue([
execRow({ status: 'SKIPPED', verify_required_snapshot: 'ALIGNED' }),
])
const r = await checkSprintVerifyGate('job-x')
expect(r.allowed).toBe(false)
if (!r.allowed) expect(r.error).toMatch(/SKIPPED/)
})
it('allows SKIPPED when verify_required_snapshot=ANY', async () => {
mocked.sprintTaskExecution.findMany.mockResolvedValue([
execRow({ status: 'SKIPPED', verify_required_snapshot: 'ANY' }),
])
expect((await checkSprintVerifyGate('job-x')).allowed).toBe(true)
})
it('runs per-row gate for DONE executions', async () => {
// PARTIAL zonder summary onder ALIGNED_OR_PARTIAL → blocker
mocked.sprintTaskExecution.findMany.mockResolvedValue([
execRow({
status: 'DONE',
verify_result: 'PARTIAL',
verify_summary: null,
verify_required_snapshot: 'ALIGNED_OR_PARTIAL',
}),
])
const r = await checkSprintVerifyGate('job-x')
expect(r.allowed).toBe(false)
if (!r.allowed) expect(r.error).toMatch(/DONE-gate/)
})
it('passes when all DONE rows pass per-row gate', async () => {
mocked.sprintTaskExecution.findMany.mockResolvedValue([
execRow({ verify_result: 'ALIGNED' }),
execRow({
verify_result: 'PARTIAL',
verify_summary: LONG_SUMMARY,
verify_required_snapshot: 'ALIGNED_OR_PARTIAL',
}),
])
expect((await checkSprintVerifyGate('job-x')).allowed).toBe(true)
})
it('aggregates multiple blockers in one error message', async () => {
mocked.sprintTaskExecution.findMany.mockResolvedValue([
execRow({ status: 'FAILED', task: { code: 'A', title: 'a' } }),
execRow({ status: 'PENDING', task: { code: 'B', title: 'b' } }),
])
const r = await checkSprintVerifyGate('job-x')
expect(r.allowed).toBe(false)
if (!r.allowed) {
expect(r.error).toMatch(/2 task\(s\) blokkeren/)
expect(r.error).toMatch(/A: a/)
expect(r.error).toMatch(/B: b/)
}
})
})
describe('finalizeSprintRunOnDone', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('no-op when SprintRun already DONE (idempotent)', async () => {
mocked.sprintRun.findUnique.mockResolvedValue({
id: 'sr-1',
status: 'DONE',
sprint_id: 's1',
})
await finalizeSprintRunOnDone('sr-1')
expect(mocked.sprintRun.update).not.toHaveBeenCalled()
})
it('no-op when SprintRun does not exist', async () => {
mocked.sprintRun.findUnique.mockResolvedValue(null)
await finalizeSprintRunOnDone('sr-x')
expect(mocked.sprintRun.update).not.toHaveBeenCalled()
})
it('no-op when stories still open', async () => {
mocked.sprintRun.findUnique.mockResolvedValue({
id: 'sr-1',
status: 'RUNNING',
sprint_id: 's1',
})
mocked.story.count.mockResolvedValue(2)
await finalizeSprintRunOnDone('sr-1')
expect(mocked.sprintRun.update).not.toHaveBeenCalled()
})
it('sets SprintRun → DONE when all stories DONE/FAILED', async () => {
mocked.sprintRun.findUnique.mockResolvedValue({
id: 'sr-1',
status: 'RUNNING',
sprint_id: 's1',
})
mocked.story.count.mockResolvedValue(0)
await finalizeSprintRunOnDone('sr-1')
expect(mocked.sprintRun.update).toHaveBeenCalledWith({
where: { id: 'sr-1' },
data: expect.objectContaining({
status: 'DONE',
finished_at: expect.any(Date),
}),
})
})
})

View file

@ -1,74 +0,0 @@
// Unit-tests voor resolveJobTimestamps — de status-gedreven timestamp-helper
// van update_job_status. Pure functie, geen mocks (zoals update-job-status-gate).
import { describe, it, expect } from 'vitest'
import { resolveJobTimestamps } from '../src/tools/update-job-status.js'
const NOW = new Date('2026-05-14T12:00:00.000Z')
const EARLIER = new Date('2026-05-14T11:00:00.000Z')
describe('resolveJobTimestamps', () => {
describe('running', () => {
it('sets started_at when not yet set, no finished_at', () => {
const r = resolveJobTimestamps('running', { claimed_at: EARLIER, started_at: null }, NOW)
expect(r.started_at).toBe(NOW)
expect(r.finished_at).toBeUndefined()
expect(r.claimed_at).toBeUndefined()
})
it('is set-once: does not re-stamp started_at when already set', () => {
const r = resolveJobTimestamps('running', { claimed_at: EARLIER, started_at: EARLIER }, NOW)
expect(r.started_at).toBeUndefined()
expect(r.finished_at).toBeUndefined()
expect(r.claimed_at).toBeUndefined()
})
})
describe('terminal transitions (done/failed/skipped)', () => {
it.each(['done', 'failed', 'skipped'] as const)(
'backfills started_at and sets finished_at for %s when started_at is null',
(status) => {
const r = resolveJobTimestamps(status, { claimed_at: EARLIER, started_at: null }, NOW)
expect(r.started_at).toBe(NOW)
expect(r.finished_at).toBe(NOW)
expect(r.claimed_at).toBeUndefined()
},
)
it('only sets finished_at when started_at is already set', () => {
const r = resolveJobTimestamps('done', { claimed_at: EARLIER, started_at: EARLIER }, NOW)
expect(r.started_at).toBeUndefined()
expect(r.finished_at).toBe(NOW)
expect(r.claimed_at).toBeUndefined()
})
})
describe('claimed_at backfill', () => {
it.each(['running', 'done', 'failed', 'skipped'] as const)(
'backfills claimed_at for %s when it is null',
(status) => {
const r = resolveJobTimestamps(status, { claimed_at: null, started_at: null }, NOW)
expect(r.claimed_at).toBe(NOW)
},
)
it('never returns claimed_at when it is already set', () => {
const r = resolveJobTimestamps('done', { claimed_at: EARLIER, started_at: EARLIER }, NOW)
expect(r.claimed_at).toBeUndefined()
})
})
it('returns only finished_at when all timestamps are already set and status is terminal', () => {
const r = resolveJobTimestamps('failed', { claimed_at: EARLIER, started_at: EARLIER }, NOW)
expect(r).toEqual({ finished_at: NOW })
})
it('defaults now to a fresh Date when omitted', () => {
const before = Date.now()
const r = resolveJobTimestamps('running', { claimed_at: EARLIER, started_at: null })
const after = Date.now()
expect(r.started_at).toBeInstanceOf(Date)
expect(r.started_at!.getTime()).toBeGreaterThanOrEqual(before)
expect(r.started_at!.getTime()).toBeLessThanOrEqual(after)
})
})

View file

@ -1,174 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../src/prisma.js', () => ({
prisma: {
sprint: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}))
vi.mock('../src/auth.js', () => ({
requireWriteAccess: vi.fn(),
PermissionDeniedError: class PermissionDeniedError extends Error {
constructor(message = 'Demo accounts cannot perform write operations') {
super(message)
this.name = 'PermissionDeniedError'
}
},
}))
vi.mock('../src/access.js', () => ({
userCanAccessProduct: vi.fn(),
}))
import { prisma } from '../src/prisma.js'
import { requireWriteAccess } from '../src/auth.js'
import { userCanAccessProduct } from '../src/access.js'
import { handleUpdateSprint } from '../src/tools/update-sprint.js'
const mockPrisma = prisma as unknown as {
sprint: {
findUnique: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
}
const mockRequireWriteAccess = requireWriteAccess as ReturnType<typeof vi.fn>
const mockUserCanAccessProduct = userCanAccessProduct as ReturnType<typeof vi.fn>
const SPRINT_ID = 'spr-1'
const PRODUCT_ID = 'prod-1'
const USER_ID = 'user-1'
beforeEach(() => {
vi.clearAllMocks()
mockRequireWriteAccess.mockResolvedValue({ userId: USER_ID, tokenId: 'tok-1', username: 'alice', isDemo: false })
mockUserCanAccessProduct.mockResolvedValue(true)
mockPrisma.sprint.findUnique.mockResolvedValue({ id: SPRINT_ID, product_id: PRODUCT_ID })
mockPrisma.sprint.update.mockResolvedValue({
id: SPRINT_ID,
code: 'S-2026-05-11-1',
sprint_goal: 'g',
status: 'OPEN',
start_date: new Date('2026-05-11'),
end_date: null,
completed_at: null,
})
})
function getText(result: Awaited<ReturnType<typeof handleUpdateSprint>>) {
return result.content?.[0]?.type === 'text' ? result.content[0].text : ''
}
describe('handleUpdateSprint', () => {
it('returns error when no fields provided', async () => {
const result = await handleUpdateSprint({ sprint_id: SPRINT_ID })
expect(mockPrisma.sprint.update).not.toHaveBeenCalled()
expect(getText(result)).toMatch(/Minstens één veld vereist/)
})
it('updates status only', async () => {
await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'OPEN' })
expect(mockPrisma.sprint.update).toHaveBeenCalledTimes(1)
const args = mockPrisma.sprint.update.mock.calls[0][0]
expect(args.where).toEqual({ id: SPRINT_ID })
expect(args.data).toEqual({ status: 'OPEN' })
})
it('auto-sets end_date AND completed_at when status → CLOSED without explicit end_date', async () => {
const before = Date.now()
await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'CLOSED' })
const after = Date.now()
const args = mockPrisma.sprint.update.mock.calls[0][0]
expect(args.data.status).toBe('CLOSED')
expect(args.data.end_date).toBeInstanceOf(Date)
expect(args.data.end_date.getTime()).toBeGreaterThanOrEqual(before)
expect(args.data.end_date.getTime()).toBeLessThanOrEqual(after)
expect(args.data.completed_at).toBeInstanceOf(Date)
expect(args.data.completed_at.getTime()).toBeGreaterThanOrEqual(before)
expect(args.data.completed_at.getTime()).toBeLessThanOrEqual(after)
})
it('auto-sets end_date when status → FAILED, but does NOT set completed_at', async () => {
await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'FAILED' })
const args = mockPrisma.sprint.update.mock.calls[0][0]
expect(args.data.end_date).toBeInstanceOf(Date)
expect(args.data.completed_at).toBeUndefined()
})
it('auto-sets end_date when status → ARCHIVED, but does NOT set completed_at', async () => {
await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'ARCHIVED' })
const args = mockPrisma.sprint.update.mock.calls[0][0]
expect(args.data.end_date).toBeInstanceOf(Date)
expect(args.data.completed_at).toBeUndefined()
})
it('still sets completed_at when status → CLOSED even with explicit end_date', async () => {
await handleUpdateSprint({
sprint_id: SPRINT_ID,
status: 'CLOSED',
end_date: '2025-12-31',
})
const args = mockPrisma.sprint.update.mock.calls[0][0]
expect(args.data.end_date.toISOString().slice(0, 10)).toBe('2025-12-31')
expect(args.data.completed_at).toBeInstanceOf(Date)
})
it('does NOT auto-set end_date or completed_at when status → OPEN', async () => {
await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'OPEN' })
const args = mockPrisma.sprint.update.mock.calls[0][0]
expect(args.data.end_date).toBeUndefined()
expect(args.data.completed_at).toBeUndefined()
})
it('updates multiple fields at once', async () => {
await handleUpdateSprint({
sprint_id: SPRINT_ID,
sprint_goal: 'New goal',
start_date: '2026-05-15',
})
const args = mockPrisma.sprint.update.mock.calls[0][0]
expect(args.data.sprint_goal).toBe('New goal')
expect(args.data.start_date.toISOString().slice(0, 10)).toBe('2026-05-15')
expect(args.data.status).toBeUndefined()
expect(args.data.end_date).toBeUndefined()
})
it('returns error when sprint not found', async () => {
mockPrisma.sprint.findUnique.mockResolvedValue(null)
const result = await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'CLOSED' })
expect(mockPrisma.sprint.update).not.toHaveBeenCalled()
expect(getText(result)).toMatch(/not found/)
})
it('returns error when user cannot access sprint product', async () => {
mockUserCanAccessProduct.mockResolvedValue(false)
const result = await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'CLOSED' })
expect(mockPrisma.sprint.update).not.toHaveBeenCalled()
expect(getText(result)).toMatch(/not accessible/)
})
it('allows any status transition (no state-machine)', async () => {
await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'OPEN' })
expect(mockPrisma.sprint.update).toHaveBeenCalledTimes(1)
await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'CLOSED' })
expect(mockPrisma.sprint.update).toHaveBeenCalledTimes(2)
await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'OPEN' })
expect(mockPrisma.sprint.update).toHaveBeenCalledTimes(3)
})
})

View file

@ -1,199 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../src/prisma.js', () => ({
prisma: {
sprintTaskExecution: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}))
vi.mock('../src/auth.js', async (importOriginal) => {
const original = await importOriginal<typeof import('../src/auth.js')>()
return { ...original, requireWriteAccess: vi.fn() }
})
import { prisma } from '../src/prisma.js'
import { requireWriteAccess } from '../src/auth.js'
import { registerUpdateTaskExecutionTool } from '../src/tools/update-task-execution.js'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
const mockPrisma = prisma as unknown as {
sprintTaskExecution: {
findUnique: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
}
const mockAuth = requireWriteAccess as ReturnType<typeof vi.fn>
const TOKEN_ID = 'tok-owner'
function makeServer() {
let handler: (args: Record<string, unknown>) => Promise<unknown>
const server = {
registerTool: vi.fn((_name: string, _meta: unknown, fn: typeof handler) => {
handler = fn
}),
call: (args: Record<string, unknown>) => handler(args),
}
registerUpdateTaskExecutionTool(server as unknown as McpServer)
return server
}
function execRecord(overrides: Record<string, unknown> = {}) {
return {
id: 'exec-1',
sprint_job_id: 'job-1',
sprint_job: {
claimed_by_token_id: TOKEN_ID,
status: 'CLAIMED',
kind: 'SPRINT_IMPLEMENTATION',
},
...overrides,
}
}
beforeEach(() => {
vi.clearAllMocks()
mockAuth.mockResolvedValue({
userId: 'u-1',
tokenId: TOKEN_ID,
username: 'agent',
isDemo: false,
})
})
describe('update_task_execution', () => {
it('rejects when execution not found', async () => {
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(null)
const server = makeServer()
const result = (await server.call({
execution_id: 'missing',
status: 'RUNNING',
})) as { content: { text: string }[]; isError?: boolean }
expect(result.isError).toBe(true)
expect(result.content[0].text).toMatch(/not found/i)
})
it('rejects wrong job-kind', async () => {
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(
execRecord({
sprint_job: { claimed_by_token_id: TOKEN_ID, status: 'CLAIMED', kind: 'TASK_IMPLEMENTATION' },
}),
)
const server = makeServer()
const result = (await server.call({
execution_id: 'exec-1',
status: 'RUNNING',
})) as { content: { text: string }[]; isError?: boolean }
expect(result.isError).toBe(true)
expect(result.content[0].text).toMatch(/SPRINT_IMPLEMENTATION/)
})
it('rejects when token does not own the job', async () => {
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(
execRecord({
sprint_job: { claimed_by_token_id: 'other-token', status: 'CLAIMED', kind: 'SPRINT_IMPLEMENTATION' },
}),
)
const server = makeServer()
const result = (await server.call({
execution_id: 'exec-1',
status: 'RUNNING',
})) as { content: { text: string }[]; isError?: boolean }
expect(result.isError).toBe(true)
expect(result.content[0].text).toMatch(/Forbidden/)
})
it('rejects when job is in terminal state', async () => {
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(
execRecord({
sprint_job: { claimed_by_token_id: TOKEN_ID, status: 'DONE', kind: 'SPRINT_IMPLEMENTATION' },
}),
)
const server = makeServer()
const result = (await server.call({
execution_id: 'exec-1',
status: 'DONE',
})) as { content: { text: string }[]; isError?: boolean }
expect(result.isError).toBe(true)
expect(result.content[0].text).toMatch(/terminal/)
})
it('writes started_at on RUNNING', async () => {
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(execRecord())
mockPrisma.sprintTaskExecution.update.mockResolvedValue({
id: 'exec-1',
status: 'RUNNING',
base_sha: null,
head_sha: null,
verify_result: null,
verify_summary: null,
skip_reason: null,
started_at: new Date(),
finished_at: null,
})
const server = makeServer()
await server.call({ execution_id: 'exec-1', status: 'RUNNING' })
const updateCall = mockPrisma.sprintTaskExecution.update.mock.calls[0][0]
expect(updateCall.data.status).toBe('RUNNING')
expect(updateCall.data.started_at).toBeInstanceOf(Date)
expect(updateCall.data.finished_at).toBeUndefined()
})
it('writes finished_at on DONE/FAILED/SKIPPED', async () => {
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(execRecord())
mockPrisma.sprintTaskExecution.update.mockResolvedValue({
id: 'exec-1',
status: 'DONE',
base_sha: 'sha-base',
head_sha: 'sha-head',
verify_result: null,
verify_summary: null,
skip_reason: null,
started_at: new Date(),
finished_at: new Date(),
})
const server = makeServer()
await server.call({
execution_id: 'exec-1',
status: 'DONE',
head_sha: 'sha-head',
})
const updateCall = mockPrisma.sprintTaskExecution.update.mock.calls[0][0]
expect(updateCall.data.status).toBe('DONE')
expect(updateCall.data.finished_at).toBeInstanceOf(Date)
expect(updateCall.data.head_sha).toBe('sha-head')
})
it('persists skip_reason on SKIPPED', async () => {
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(execRecord())
mockPrisma.sprintTaskExecution.update.mockResolvedValue({
id: 'exec-1',
status: 'SKIPPED',
base_sha: null,
head_sha: null,
verify_result: null,
verify_summary: null,
skip_reason: 'no-op task',
started_at: null,
finished_at: new Date(),
})
const server = makeServer()
await server.call({
execution_id: 'exec-1',
status: 'SKIPPED',
skip_reason: 'no-op task',
})
const updateCall = mockPrisma.sprintTaskExecution.update.mock.calls[0][0]
expect(updateCall.data.skip_reason).toBe('no-op task')
expect(updateCall.data.finished_at).toBeInstanceOf(Date)
})
})

View file

@ -1,216 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../src/prisma.js', () => ({
prisma: {
sprintTaskExecution: {
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
},
},
}))
vi.mock('../src/auth.js', async (importOriginal) => {
const original = await importOriginal<typeof import('../src/auth.js')>()
return { ...original, requireWriteAccess: vi.fn() }
})
vi.mock('../src/verify/classify.js', () => ({
classifyDiffAgainstPlan: vi.fn(),
}))
vi.mock('node:child_process', () => ({
execFile: vi.fn(),
}))
import { prisma } from '../src/prisma.js'
import { requireWriteAccess } from '../src/auth.js'
import { classifyDiffAgainstPlan } from '../src/verify/classify.js'
import { execFile } from 'node:child_process'
import { registerVerifySprintTaskTool } from '../src/tools/verify-sprint-task.js'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
const mockPrisma = prisma as unknown as {
sprintTaskExecution: {
findUnique: ReturnType<typeof vi.fn>
findFirst: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
}
const mockAuth = requireWriteAccess as ReturnType<typeof vi.fn>
const mockClassify = classifyDiffAgainstPlan as ReturnType<typeof vi.fn>
const mockExecFile = execFile as unknown as ReturnType<typeof vi.fn>
const TOKEN_ID = 'tok-owner'
function makeServer() {
let handler: (args: Record<string, unknown>) => Promise<unknown>
const server = {
registerTool: vi.fn((_name: string, _meta: unknown, fn: typeof handler) => {
handler = fn
}),
call: (args: Record<string, unknown>) => handler(args),
}
registerVerifySprintTaskTool(server as unknown as McpServer)
return server
}
function stubGitDiff(stdout: string) {
// promisify(execFile) calls (cmd, args, opts, cb)
mockExecFile.mockImplementation(
(
_cmd: string,
_args: string[],
_opts: unknown,
cb: (err: null, result: { stdout: string; stderr: string }) => void,
) => {
cb(null, { stdout, stderr: '' })
},
)
}
function execRecord(overrides: Record<string, unknown> = {}) {
return {
id: 'exec-1',
sprint_job_id: 'job-1',
order: 0,
base_sha: 'sha-base',
plan_snapshot: 'frozen plan',
verify_required_snapshot: 'ALIGNED_OR_PARTIAL',
verify_only_snapshot: false,
sprint_job: {
claimed_by_token_id: TOKEN_ID,
status: 'CLAIMED',
kind: 'SPRINT_IMPLEMENTATION',
},
...overrides,
}
}
beforeEach(() => {
vi.clearAllMocks()
mockAuth.mockResolvedValue({
userId: 'u-1',
tokenId: TOKEN_ID,
username: 'agent',
isDemo: false,
})
})
describe('verify_sprint_task', () => {
it('rejects when execution not found', async () => {
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(null)
const server = makeServer()
const result = (await server.call({
execution_id: 'missing',
worktree_path: '/tmp/wt',
})) as { content: { text: string }[]; isError?: boolean }
expect(result.isError).toBe(true)
expect(result.content[0].text).toMatch(/not found/i)
})
it('rejects wrong token', async () => {
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(
execRecord({
sprint_job: { claimed_by_token_id: 'other', status: 'CLAIMED', kind: 'SPRINT_IMPLEMENTATION' },
}),
)
const server = makeServer()
const result = (await server.call({
execution_id: 'exec-1',
worktree_path: '/tmp/wt',
})) as { content: { text: string }[]; isError?: boolean }
expect(result.isError).toBe(true)
expect(result.content[0].text).toMatch(/Forbidden/)
})
it('PARTIAL with summary returns allowed_for_done=true under ALIGNED_OR_PARTIAL', async () => {
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(execRecord())
stubGitDiff('diff --git a/x b/x\n+ change\n')
mockClassify.mockReturnValue({ result: 'PARTIAL', reasoning: 'extra files' })
const server = makeServer()
const result = (await server.call({
execution_id: 'exec-1',
worktree_path: '/tmp/wt',
summary: 'Refactor touched extra files for type narrowing.',
})) as { content: { text: string }[] }
const body = JSON.parse(result.content[0].text)
expect(body.result).toBe('partial')
expect(body.allowed_for_done).toBe(true)
expect(body.reason).toBeNull()
})
it('PARTIAL without summary returns allowed_for_done=false', async () => {
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(execRecord())
stubGitDiff('diff --git a/x b/x\n')
mockClassify.mockReturnValue({ result: 'PARTIAL', reasoning: 'r' })
const server = makeServer()
const result = (await server.call({
execution_id: 'exec-1',
worktree_path: '/tmp/wt',
})) as { content: { text: string }[] }
const body = JSON.parse(result.content[0].text)
expect(body.result).toBe('partial')
expect(body.allowed_for_done).toBe(false)
expect(body.reason).toMatch(/summary/i)
})
it('DIVERGENT with strict ALIGNED returns allowed_for_done=false', async () => {
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(
execRecord({ verify_required_snapshot: 'ALIGNED' }),
)
stubGitDiff('diff --git a/x b/x\n')
mockClassify.mockReturnValue({ result: 'DIVERGENT', reasoning: 'r' })
const server = makeServer()
const result = (await server.call({
execution_id: 'exec-1',
worktree_path: '/tmp/wt',
summary: 'Long enough summary describing the deviation rationale clearly.',
})) as { content: { text: string }[] }
const body = JSON.parse(result.content[0].text)
expect(body.allowed_for_done).toBe(false)
expect(body.reason).toMatch(/ALIGNED/)
})
it('auto-fills base_sha from previous DONE execution head_sha', async () => {
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(
execRecord({ order: 1, base_sha: null }),
)
mockPrisma.sprintTaskExecution.findFirst.mockResolvedValue({
head_sha: 'prev-head-sha',
})
stubGitDiff('diff\n')
mockClassify.mockReturnValue({ result: 'ALIGNED', reasoning: 'ok' })
const server = makeServer()
const result = (await server.call({
execution_id: 'exec-1',
worktree_path: '/tmp/wt',
})) as { content: { text: string }[] }
const body = JSON.parse(result.content[0].text)
expect(body.base_sha).toBe('prev-head-sha')
// Persisted back to row
const updateCalls = mockPrisma.sprintTaskExecution.update.mock.calls
const baseShaPersist = updateCalls.find((c) => c[0].data.base_sha === 'prev-head-sha')
expect(baseShaPersist).toBeDefined()
})
it('errors when base_sha cannot be derived (no prior DONE)', async () => {
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(
execRecord({ order: 2, base_sha: null }),
)
mockPrisma.sprintTaskExecution.findFirst.mockResolvedValue(null)
const server = makeServer()
const result = (await server.call({
execution_id: 'exec-1',
worktree_path: '/tmp/wt',
})) as { content: { text: string }[]; isError?: boolean }
expect(result.isError).toBe(true)
expect(result.content[0].text).toMatch(/MISSING_BASE_SHA/)
})
})

View file

@ -1,59 +0,0 @@
import { describe, it, expect } from 'vitest'
import { classifyDiffAgainstPlan } from '../../src/verify/classify.js'
describe('classify — delete-only commits (PBI-47 C5)', () => {
it('returns ALIGNED when the deleted path is in the plan', () => {
const diff = `diff --git a/app/todos/page.tsx b/app/todos/page.tsx
deleted file mode 100644
index 1234567..0000000
--- a/app/todos/page.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-export default function TodosPage() {
- return null
-}`
const plan = '- Verwijder `app/todos/page.tsx`\n- Verwijder gerelateerde imports'
const result = classifyDiffAgainstPlan({ diff, plan })
expect(result.result).toBe('ALIGNED')
})
it('returns ALIGNED for multi-file delete-only when both paths in plan', () => {
const diff = `diff --git a/app/todos/page.tsx b/app/todos/page.tsx
deleted file mode 100644
--- a/app/todos/page.tsx
+++ /dev/null
@@ -1,2 +0,0 @@
-line 1
-line 2
diff --git a/components/todo-list.tsx b/components/todo-list.tsx
deleted file mode 100644
--- a/components/todo-list.tsx
+++ /dev/null
@@ -1,1 +0,0 @@
-line`
const plan = '- `app/todos/page.tsx`\n- `components/todo-list.tsx`'
const result = classifyDiffAgainstPlan({ diff, plan })
expect(result.result).toBe('ALIGNED')
})
it('returns PARTIAL when only some plan deletes appear in the diff', () => {
const diff = `diff --git a/a.ts b/a.ts
deleted file mode 100644
--- a/a.ts
+++ /dev/null
@@ -1,1 +0,0 @@
-x`
const plan = '- `a.ts`\n- `b.ts`' // b.ts missing
const result = classifyDiffAgainstPlan({ diff, plan })
expect(result.result).toBe('PARTIAL')
})
it('returns EMPTY for a no-op diff', () => {
const result = classifyDiffAgainstPlan({ diff: '', plan: 'irrelevant' })
expect(result.result).toBe('EMPTY')
})
})

View file

@ -124,92 +124,3 @@ describe('classifyDiffAgainstPlan — DIVERGENT (scope creep)', () => {
expect(r.reasoning).toMatch(/extra/i)
})
})
// Helper voor pure-delete diffs: +++ /dev/null betekent dat het bestand
// volledig verwijderd is. Pad zit alleen nog in de "--- a/<path>" regel.
function makeDeleteDiff(files: string[], linesPerFile = 5): string {
return files
.map(
(f) =>
`diff --git a/${f} b/${f}\ndeleted file mode 100644\n--- a/${f}\n+++ /dev/null\n` +
Array.from({ length: linesPerFile }, (_, i) => `-removed line ${i}`).join('\n'),
)
.join('\n')
}
describe('classifyDiffAgainstPlan — delete-only commits', () => {
it('herkent delete-only diff (geen +++ b/, wel --- a/) als ALIGNED bij matchend plan', () => {
const plan = 'Verwijder `src/old-helper.ts` — niet meer gebruikt.'
const diff = makeDeleteDiff(['src/old-helper.ts'])
const r = classifyDiffAgainstPlan({ diff, plan })
expect(r.result).toBe('ALIGNED')
})
it('retourneert PARTIAL wanneer plan meer paden noemt dan zijn verwijderd', () => {
const plan = 'Verwijder `src/a.ts` en `src/b.ts`.'
const diff = makeDeleteDiff(['src/a.ts'])
const r = classifyDiffAgainstPlan({ diff, plan })
expect(r.result).toBe('PARTIAL')
})
it('retourneert ALIGNED voor delete-only diff zonder plan-baseline', () => {
const diff = makeDeleteDiff(['src/old.ts'])
const r = classifyDiffAgainstPlan({ diff, plan: null })
expect(r.result).toBe('ALIGNED')
})
it('retourneert nog steeds EMPTY voor echt lege diff', () => {
const r = classifyDiffAgainstPlan({ diff: '', plan: 'Verwijder `src/x.ts`.' })
expect(r.result).toBe('EMPTY')
})
})
// Pseudo-paths in plans (code-snippets, attribute-syntax, ellipses) moeten
// niet als plan-paden meetellen — anders krijg je PARTIAL terwijl het werk
// volledig gedaan is. Regression-guard voor T-815-incident (sprint
// cmoyiu4yd000zf917acq9twtr, 2026-05-09).
describe('classifyDiffAgainstPlan — plan met pseudo-paths', () => {
it('negeert `data-debug-label="..."` als pseudo-pad en classificeert ALIGNED', () => {
const plan = [
'Verwijder alle voorkomens van `data-debug-label="..."` uit:',
'',
'- `app/components/shared/status-bar.tsx`',
'- `app/components/shared/header.tsx`',
].join('\n')
const diff = makeDiff([
'app/components/shared/status-bar.tsx',
'app/components/shared/header.tsx',
])
const r = classifyDiffAgainstPlan({ diff, plan })
expect(r.result).toBe('ALIGNED')
})
it('negeert ellipsis-tokens (drie of meer dots) als pad', () => {
const plan = 'Refactor `foo(...)` naar `bar()`. Files: `src/a.ts`.'
const diff = makeDiff(['src/a.ts'])
const r = classifyDiffAgainstPlan({ diff, plan })
expect(r.result).toBe('ALIGNED')
})
it('negeert tokens met operators/quotes als pad', () => {
const plan = 'Wijzig `props={x: 1}` en `useState<string>()` in `src/c.tsx`.'
const diff = makeDiff(['src/c.tsx'])
const r = classifyDiffAgainstPlan({ diff, plan })
expect(r.result).toBe('ALIGNED')
})
it('accepteert package.json en andere extension-only paths', () => {
const plan = 'Update `package.json` en `tsconfig.json`.'
const diff = makeDiff(['package.json', 'tsconfig.json'])
const r = classifyDiffAgainstPlan({ diff, plan })
expect(r.result).toBe('ALIGNED')
})
it('blijft PARTIAL retourneren wanneer een echt plan-pad ontbreekt', () => {
const plan = 'Wijzig `src/foo.ts` en `src/bar.ts`. Verwijder `data-x="..."`.'
const diff = makeDiff(['src/foo.ts'])
const r = classifyDiffAgainstPlan({ diff, plan })
expect(r.result).toBe('PARTIAL')
expect(r.reasoning).toMatch(/bar\.ts/)
})
})

View file

@ -1,55 +0,0 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import * as os from 'node:os'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { getDiffInWorktree } from '../../src/tools/verify-task-against-plan.js'
const exec = promisify(execFile)
describe('verify scope per-job (PBI-47 P0)', () => {
let tmpRepo: string
let baseSha: string
let task1Sha: string
beforeAll(async () => {
tmpRepo = await fs.mkdtemp(path.join(os.tmpdir(), 'verify-scope-'))
await exec('git', ['init', '-b', 'main'], { cwd: tmpRepo })
await exec('git', ['config', 'user.email', 't@t.local'], { cwd: tmpRepo })
await exec('git', ['config', 'user.name', 'Test'], { cwd: tmpRepo })
await fs.writeFile(path.join(tmpRepo, 'README.md'), '# init\n')
await exec('git', ['add', '-A'], { cwd: tmpRepo })
await exec('git', ['commit', '-m', 'init'], { cwd: tmpRepo })
const baseRev = await exec('git', ['rev-parse', 'HEAD'], { cwd: tmpRepo })
baseSha = baseRev.stdout.trim()
// Simulate task 1: add a.ts
await fs.writeFile(path.join(tmpRepo, 'a.ts'), 'task 1\n')
await exec('git', ['add', '-A'], { cwd: tmpRepo })
await exec('git', ['commit', '-m', 'task 1'], { cwd: tmpRepo })
const t1Rev = await exec('git', ['rev-parse', 'HEAD'], { cwd: tmpRepo })
task1Sha = t1Rev.stdout.trim()
// Simulate task 2: add b.ts
await fs.writeFile(path.join(tmpRepo, 'b.ts'), 'task 2\n')
await exec('git', ['add', '-A'], { cwd: tmpRepo })
await exec('git', ['commit', '-m', 'task 2'], { cwd: tmpRepo })
})
afterAll(async () => {
await fs.rm(tmpRepo, { recursive: true, force: true })
})
it('diff vs base = origin/main → both task 1 and task 2 visible', async () => {
const diff = await getDiffInWorktree(tmpRepo, baseSha)
expect(diff).toContain('a.ts')
expect(diff).toContain('b.ts')
})
it('diff vs base = task1_sha → only task 2 visible', async () => {
const diff = await getDiffInWorktree(tmpRepo, task1Sha)
expect(diff).not.toContain('a.ts')
expect(diff).toContain('b.ts')
})
})

View file

@ -1,91 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../src/prisma.js', () => ({
prisma: {
claudeJob: {
findUnique: vi.fn(),
findFirst: vi.fn(),
},
},
}))
import { prisma } from '../src/prisma.js'
import { resolveBranchForJob } from '../src/tools/wait-for-job.js'
const mockPrisma = prisma as unknown as {
claudeJob: {
findUnique: ReturnType<typeof vi.fn>
findFirst: ReturnType<typeof vi.fn>
}
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('resolveBranchForJob — sprint-aware', () => {
it('SPRINT-mode: kiest feat/sprint-<id-suffix> en marks reused=false bij eerste task', async () => {
mockPrisma.claudeJob.findUnique.mockResolvedValue({
sprint_run_id: 'run-cuid-12345678',
sprint_run: { id: 'run-cuid-12345678', pr_strategy: 'SPRINT' },
})
mockPrisma.claudeJob.findFirst.mockResolvedValue(null)
const result = await resolveBranchForJob('job-1', 'story-anything')
expect(result.branchName).toBe('feat/sprint-12345678')
expect(result.reused).toBe(false)
})
it('SPRINT-mode: marks reused=true wanneer sibling al de branch gebruikt', async () => {
mockPrisma.claudeJob.findUnique.mockResolvedValue({
sprint_run_id: 'run-cuid-12345678',
sprint_run: { id: 'run-cuid-12345678', pr_strategy: 'SPRINT' },
})
mockPrisma.claudeJob.findFirst.mockResolvedValue({ branch: 'feat/sprint-12345678' })
const result = await resolveBranchForJob('job-2', 'story-anything')
expect(result.branchName).toBe('feat/sprint-12345678')
expect(result.reused).toBe(true)
})
it('STORY-mode (sprint-flow): valt terug op story-branch via legacy-pad', async () => {
mockPrisma.claudeJob.findUnique.mockResolvedValue({
sprint_run_id: 'run-cuid-12345678',
sprint_run: { id: 'run-cuid-12345678', pr_strategy: 'STORY' },
})
mockPrisma.claudeJob.findFirst.mockResolvedValue(null)
const result = await resolveBranchForJob('job-1', 'story-cuid-87654321')
expect(result.branchName).toBe('feat/story-87654321')
expect(result.reused).toBe(false)
})
it('Legacy (geen sprint_run): bestaand gedrag — feat/story-<id-suffix>', async () => {
mockPrisma.claudeJob.findUnique.mockResolvedValue({
sprint_run_id: null,
sprint_run: null,
})
mockPrisma.claudeJob.findFirst.mockResolvedValue(null)
const result = await resolveBranchForJob('job-1', 'story-cuid-87654321')
expect(result.branchName).toBe('feat/story-87654321')
expect(result.reused).toBe(false)
})
it('Legacy: hergebruik branch wanneer sibling-job in dezelfde story al een branch heeft', async () => {
mockPrisma.claudeJob.findUnique.mockResolvedValue({
sprint_run_id: null,
sprint_run: null,
})
mockPrisma.claudeJob.findFirst.mockResolvedValue({ branch: 'feat/story-87654321' })
const result = await resolveBranchForJob('job-2', 'story-cuid-87654321')
expect(result.branchName).toBe('feat/story-87654321')
expect(result.reused).toBe(true)
})
})

View file

@ -6,7 +6,7 @@ import * as fs from 'node:fs/promises'
vi.mock('../src/prisma.js', () => ({
prisma: {
$executeRaw: vi.fn(),
claudeJob: { findFirst: vi.fn(), findUnique: vi.fn(), update: vi.fn() },
claudeJob: { findFirst: vi.fn() },
product: { findUnique: vi.fn() },
},
}))
@ -21,15 +21,13 @@ import { resolveRepoRoot, rollbackClaim, attachWorktreeToJob } from '../src/tool
const mockPrisma = prisma as unknown as {
$executeRaw: ReturnType<typeof vi.fn>
claudeJob: { findFirst: ReturnType<typeof vi.fn>; findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
claudeJob: { findFirst: ReturnType<typeof vi.fn> }
product: { findUnique: ReturnType<typeof vi.fn> }
}
const mockCreateWorktree = createWorktreeForJob as ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
// Default: legacy job zonder sprint_run (oude flow).
mockPrisma.claudeJob.findUnique.mockResolvedValue({ sprint_run_id: null, sprint_run: null })
})
describe('resolveRepoRoot', () => {

71
package-lock.json generated
View file

@ -1,22 +1,19 @@
{
"name": "scrum4me-mcp",
"version": "0.8.0",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "scrum4me-mcp",
"version": "0.8.0",
"version": "0.1.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"@types/proper-lockfile": "^4.1.4",
"pg": "^8.13.1",
"proper-lockfile": "^4.1.2",
"yaml": "^2.8.4",
"zod": "^4.0.0"
},
"bin": {
@ -1095,6 +1092,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1112,6 +1112,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1129,6 +1132,9 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1146,6 +1152,9 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1163,6 +1172,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1180,6 +1192,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1329,15 +1344,6 @@
"pg-types": "^2.2.0"
}
},
"node_modules/@types/proper-lockfile": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@types/proper-lockfile/-/proper-lockfile-4.1.4.tgz",
"integrity": "sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==",
"license": "MIT",
"dependencies": {
"@types/retry": "*"
}
},
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@ -1349,12 +1355,6 @@
"csstype": "^3.2.2"
}
},
"node_modules/@types/retry": {
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.5.tgz",
"integrity": "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==",
"license": "MIT"
},
"node_modules/@vitest/expect": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
@ -2349,6 +2349,7 @@
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"devOptional": true,
"license": "ISC"
},
"node_modules/grammex": {
@ -2658,6 +2659,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -2679,6 +2683,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -2700,6 +2707,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -2721,6 +2731,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -3293,6 +3306,7 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
"integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
@ -3304,6 +3318,7 @@
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"devOptional": true,
"license": "ISC"
},
"node_modules/proxy-addr": {
@ -3458,6 +3473,7 @@
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
"integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">= 4"
@ -4119,21 +4135,6 @@
"node": ">=0.4"
}
},
"node_modules/yaml": {
"version": "2.8.4",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz",
"integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/zeptomatch": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz",

View file

@ -1,6 +1,6 @@
{
"name": "scrum4me-mcp",
"version": "0.8.0",
"version": "0.1.0",
"description": "MCP server for Scrum4Me — exposes dev-flow tools and prompts via the Model Context Protocol",
"type": "module",
"bin": {
@ -32,10 +32,7 @@
"@modelcontextprotocol/sdk": "^1.29.0",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"@types/proper-lockfile": "^4.1.4",
"pg": "^8.13.1",
"proper-lockfile": "^4.1.2",
"yaml": "^2.8.4",
"zod": "^4.0.0"
},
"devDependencies": {

View file

@ -2,6 +2,7 @@ generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
@ -10,20 +11,17 @@ enum Role {
PRODUCT_OWNER
SCRUM_MASTER
DEVELOPER
ADMIN
}
enum StoryStatus {
OPEN
IN_SPRINT
DONE
FAILED
}
enum PbiStatus {
READY
BLOCKED
FAILED
DONE
}
@ -34,20 +32,6 @@ enum ClaudeJobStatus {
DONE
FAILED
CANCELLED
SKIPPED
}
enum VerifyResult {
ALIGNED
PARTIAL
EMPTY
DIVERGENT
}
enum VerifyRequired {
ALIGNED
ALIGNED_OR_PARTIAL
ANY
}
enum TaskStatus {
@ -55,8 +39,6 @@ enum TaskStatus {
IN_PROGRESS
REVIEW
DONE
FAILED
EXCLUDED
}
enum LogType {
@ -71,94 +53,34 @@ enum TestStatus {
}
enum SprintStatus {
OPEN
CLOSED
ARCHIVED
FAILED
ACTIVE
COMPLETED
}
enum SprintRunStatus {
QUEUED
RUNNING
PAUSED
DONE
FAILED
CANCELLED
}
enum PrStrategy {
SPRINT
STORY
SPRINT_BATCH
}
enum IdeaStatus {
DRAFT
GRILLING
GRILL_FAILED
GRILLED
PLANNING
PLAN_FAILED
PLAN_READY
REVIEWING_PLAN
PLAN_REVIEW_FAILED
PLAN_REVIEWED
PLANNED
}
enum ClaudeJobKind {
TASK_IMPLEMENTATION
IDEA_GRILL
IDEA_MAKE_PLAN
IDEA_REVIEW_PLAN
PLAN_CHAT
SPRINT_IMPLEMENTATION
}
enum SprintTaskExecutionStatus {
PENDING
RUNNING
DONE
FAILED
SKIPPED
}
enum IdeaLogType {
DECISION
NOTE
GRILL_RESULT
PLAN_RESULT
PLAN_REVIEW_RESULT
STATUS_CHANGE
JOB_EVENT
}
enum UserQuestionStatus {
pending
answered
enum VerifyResult {
ALIGNED
PARTIAL
EMPTY
DIVERGENT
}
model User {
id String @id @default(cuid())
username String @unique
email String? @unique
password_hash String
is_demo Boolean @default(false)
bio String? @db.VarChar(160)
bio_detail String? @db.VarChar(2000)
must_reset_password Boolean @default(false)
avatar_data Bytes?
active_product_id String?
active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull)
idea_code_counter Int @default(0)
min_quota_pct Int @default(20)
settings Json @default("{}")
created_at DateTime @default(now())
updated_at DateTime @updatedAt
roles UserRole[]
id String @id @default(cuid())
username String @unique
email String? @unique
password_hash String
is_demo Boolean @default(false)
bio String? @db.VarChar(160)
bio_detail String? @db.VarChar(2000)
avatar_data Bytes?
active_product_id String?
active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
roles UserRole[]
api_tokens ApiToken[]
products Product[]
ideas Idea[]
todos Todo[]
product_members ProductMember[]
assigned_stories Story[] @relation("StoryAssignee")
login_pairings LoginPairing[]
@ -166,8 +88,6 @@ model User {
answered_questions ClaudeQuestion[] @relation("ClaudeQuestionAnswerer")
claude_jobs ClaudeJob[]
claude_workers ClaudeWorker[]
started_sprint_runs SprintRun[] @relation("SprintRunStartedBy")
push_subscriptions PushSubscription[]
@@index([active_product_id])
@@map("users")
@ -184,47 +104,41 @@ model UserRole {
}
model ApiToken {
id String @id @default(cuid())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
user_id String
token_hash String @unique
label String?
created_at DateTime @default(now())
revoked_at DateTime?
claimed_jobs ClaudeJob[]
claude_worker ClaudeWorker?
id String @id @default(cuid())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
user_id String
token_hash String @unique
label String?
created_at DateTime @default(now())
revoked_at DateTime?
claimed_jobs ClaudeJob[]
claude_worker ClaudeWorker?
@@index([token_hash])
@@map("api_tokens")
}
model Product {
id String @id @default(cuid())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
id String @id @default(cuid())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
user_id String
name String
code String? @db.VarChar(30)
code String? @db.VarChar(30)
description String?
repo_url String?
definition_of_done String
auto_pr Boolean @default(false)
pr_strategy PrStrategy @default(SPRINT)
preferred_model String?
thinking_budget_default Int?
preferred_permission_mode String?
archived Boolean @default(false)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
auto_pr Boolean @default(false)
archived Boolean @default(false)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
pbis Pbi[]
sprints Sprint[]
stories Story[]
tasks Task[]
todos Todo[]
members ProductMember[]
active_for_users User[] @relation("UserActiveProduct")
claude_questions ClaudeQuestion[]
claude_jobs ClaudeJob[]
ideas Idea[]
idea_products IdeaProduct[]
@@unique([user_id, name])
@@unique([user_id, code])
@ -233,21 +147,18 @@ model Product {
}
model Pbi {
id String @id @default(cuid())
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
product_id String
code String @db.VarChar(30)
title String
description String?
priority Int
sort_order Float
status PbiStatus @default(READY)
pr_url String?
pr_merged_at DateTime?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
stories Story[]
idea Idea?
id String @id @default(cuid())
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
product_id String
code String? @db.VarChar(30)
title String
description String?
priority Int
sort_order Float
status PbiStatus @default(READY)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
stories Story[]
@@unique([product_id, code])
@@index([product_id, priority, sort_order])
@ -256,24 +167,24 @@ model Pbi {
}
model Story {
id String @id @default(cuid())
pbi Pbi @relation(fields: [pbi_id], references: [id], onDelete: Cascade)
id String @id @default(cuid())
pbi Pbi @relation(fields: [pbi_id], references: [id], onDelete: Cascade)
pbi_id String
product Product @relation(fields: [product_id], references: [id])
product Product @relation(fields: [product_id], references: [id])
product_id String
sprint Sprint? @relation(fields: [sprint_id], references: [id])
sprint Sprint? @relation(fields: [sprint_id], references: [id])
sprint_id String?
assignee User? @relation("StoryAssignee", fields: [assignee_id], references: [id], onDelete: SetNull)
assignee User? @relation("StoryAssignee", fields: [assignee_id], references: [id], onDelete: SetNull)
assignee_id String?
code String @db.VarChar(30)
code String? @db.VarChar(30)
title String
description String?
acceptance_criteria String?
priority Int
sort_order Float
status StoryStatus @default(OPEN)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
status StoryStatus @default(OPEN)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
logs StoryLog[]
tasks Task[]
claude_questions ClaudeQuestion[]
@ -306,196 +217,80 @@ model Sprint {
id String @id @default(cuid())
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
product_id String
code String @db.VarChar(30)
sprint_goal String
status SprintStatus @default(OPEN)
start_date DateTime? @db.Date
end_date DateTime? @db.Date
status SprintStatus @default(ACTIVE)
created_at DateTime @default(now())
completed_at DateTime?
stories Story[]
tasks Task[]
sprint_runs SprintRun[]
@@unique([product_id, code])
@@index([product_id, status])
@@map("sprints")
}
model SprintRun {
id String @id @default(cuid())
sprint Sprint @relation(fields: [sprint_id], references: [id], onDelete: Cascade)
sprint_id String
started_by User @relation("SprintRunStartedBy", fields: [started_by_id], references: [id])
started_by_id String
status SprintRunStatus @default(QUEUED)
pr_strategy PrStrategy
branch String?
pr_url String?
started_at DateTime?
finished_at DateTime?
failure_reason String?
failed_task Task? @relation("SprintRunFailedTask", fields: [failed_task_id], references: [id], onDelete: SetNull)
failed_task_id String?
pause_context Json?
previous_run_id String? @unique
previous_run SprintRun? @relation("SprintRunChain", fields: [previous_run_id], references: [id], onDelete: SetNull)
next_run SprintRun? @relation("SprintRunChain")
created_at DateTime @default(now())
updated_at DateTime @updatedAt
jobs ClaudeJob[]
@@index([sprint_id, status])
@@index([started_by_id, status])
@@map("sprint_runs")
}
model Task {
id String @id @default(cuid())
story Story @relation(fields: [story_id], references: [id], onDelete: Cascade)
story_id String
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
product_id String
sprint Sprint? @relation(fields: [sprint_id], references: [id])
sprint_id String?
code String @db.VarChar(30)
title String
description String?
implementation_plan String?
priority Int
sort_order Float
status TaskStatus @default(TO_DO)
verify_only Boolean @default(false)
verify_required VerifyRequired @default(ALIGNED_OR_PARTIAL)
requires_opus Boolean @default(false)
// Override product.repo_url for branch/worktree/push purposes. Set when
// a task targets a different repo than its parent product (e.g. an
// MCP-server task tracked under the main product's PBI). Falls back to
// product.repo_url when null.
repo_url String?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
claude_questions ClaudeQuestion[]
claude_jobs ClaudeJob[]
sprint_run_failures SprintRun[] @relation("SprintRunFailedTask")
sprint_task_executions SprintTaskExecution[]
id String @id @default(cuid())
story Story @relation(fields: [story_id], references: [id], onDelete: Cascade)
story_id String
sprint Sprint? @relation(fields: [sprint_id], references: [id])
sprint_id String?
title String
description String?
implementation_plan String?
priority Int
sort_order Float
status TaskStatus @default(TO_DO)
verify_only Boolean @default(false)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
claude_questions ClaudeQuestion[]
claude_jobs ClaudeJob[]
@@unique([product_id, code])
@@index([story_id, priority, sort_order])
@@index([sprint_id, status])
@@index([product_id])
@@map("tasks")
}
model ClaudeJob {
id String @id @default(cuid())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
id String @id @default(cuid())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
user_id String
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
product_id String
task Task? @relation(fields: [task_id], references: [id], onDelete: Cascade)
task_id String?
idea Idea? @relation(fields: [idea_id], references: [id], onDelete: Cascade)
idea_id String?
sprint_run SprintRun? @relation(fields: [sprint_run_id], references: [id], onDelete: SetNull)
sprint_run_id String?
kind ClaudeJobKind @default(TASK_IMPLEMENTATION)
status ClaudeJobStatus @default(QUEUED)
claimed_by_token ApiToken? @relation(fields: [claimed_by_token_id], references: [id], onDelete: SetNull)
task Task @relation(fields: [task_id], references: [id], onDelete: Cascade)
task_id String
status ClaudeJobStatus @default(QUEUED)
claimed_by_token ApiToken? @relation(fields: [claimed_by_token_id], references: [id], onDelete: SetNull)
claimed_by_token_id String?
claimed_at DateTime?
started_at DateTime?
finished_at DateTime?
pushed_at DateTime?
verify_result VerifyResult?
model_id String?
input_tokens Int?
output_tokens Int?
cache_read_tokens Int?
cache_write_tokens Int?
requested_model String?
requested_thinking_budget Int?
requested_permission_mode String?
actual_thinking_tokens Int?
plan_snapshot String?
base_sha String?
head_sha String?
branch String?
pr_url String?
summary String?
error String?
retry_count Int @default(0)
lease_until DateTime?
task_executions SprintTaskExecution[] @relation("SprintJobExecutions")
created_at DateTime @default(now())
updated_at DateTime @updatedAt
verify_result VerifyResult?
retry_count Int @default(0)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@index([user_id, status])
@@index([task_id, status])
@@index([idea_id, status])
@@index([sprint_run_id, status])
@@index([status, claimed_at])
@@index([status, finished_at])
@@index([status, lease_until])
@@map("claude_jobs")
}
// PBI-50: frozen scope-snapshot per SPRINT_IMPLEMENTATION-claim. Bij claim
// wordt voor elke TO_DO-task in scope één PENDING-record gemaakt met
// implementation_plan + verify_required gesnapshot. Worker en gate werken
// uitsluitend op deze rows; latere wijzigingen aan Task hebben geen
// invloed op de lopende batch.
model SprintTaskExecution {
id String @id @default(cuid())
sprint_job ClaudeJob @relation("SprintJobExecutions", fields: [sprint_job_id], references: [id], onDelete: Cascade)
sprint_job_id String
task Task @relation(fields: [task_id], references: [id], onDelete: Cascade)
task_id String
order Int
plan_snapshot String @db.Text
verify_required_snapshot VerifyRequired
verify_only_snapshot Boolean @default(false)
base_sha String?
head_sha String?
status SprintTaskExecutionStatus @default(PENDING)
verify_result VerifyResult?
verify_summary String? @db.Text
skip_reason String? @db.Text
started_at DateTime?
finished_at DateTime?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@unique([sprint_job_id, task_id])
@@index([sprint_job_id, order])
@@map("sprint_task_executions")
}
model ModelPrice {
id String @id @default(cuid())
model_id String @unique
input_price_per_1m Decimal @db.Decimal(12, 6)
output_price_per_1m Decimal @db.Decimal(12, 6)
cache_read_price_per_1m Decimal @db.Decimal(12, 6)
cache_write_price_per_1m Decimal @db.Decimal(12, 6)
currency String @default("USD")
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@map("model_prices")
}
model ClaudeWorker {
id String @id @default(cuid())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
user_id String
token ApiToken @relation(fields: [token_id], references: [id], onDelete: Cascade)
token_id String
product_id String?
started_at DateTime @default(now())
last_seen_at DateTime @default(now())
last_quota_pct Int?
last_quota_check_at DateTime?
id String @id @default(cuid())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
user_id String
token ApiToken @relation(fields: [token_id], references: [id], onDelete: Cascade)
token_id String
product_id String?
started_at DateTime @default(now())
last_seen_at DateTime @default(now())
@@unique([token_id])
@@index([user_id, last_seen_at])
@ -515,80 +310,22 @@ model ProductMember {
@@map("product_members")
}
model Idea {
id String @id @default(cuid())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
user_id String
product Product? @relation(fields: [product_id], references: [id], onDelete: SetNull)
product_id String?
code String @db.VarChar(30)
title String
description String? @db.VarChar(4000)
grill_md String? @db.Text
plan_md String? @db.Text
plan_review_log Json? // ReviewLog from orchestrator (all rounds, convergence metrics, approval status)
reviewed_at DateTime? // When last reviewed
pbi Pbi? @relation(fields: [pbi_id], references: [id], onDelete: SetNull)
pbi_id String? @unique
status IdeaStatus @default(DRAFT)
archived Boolean @default(false)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
questions ClaudeQuestion[]
jobs ClaudeJob[]
logs IdeaLog[]
user_questions UserQuestion[]
secondary_products IdeaProduct[]
@@unique([user_id, code])
@@index([user_id, archived, status])
@@index([user_id, product_id])
@@map("ideas")
}
model IdeaProduct {
model Todo {
id String @id @default(cuid())
idea_id String
product_id String
created_at DateTime @default(now())
idea Idea @relation(fields: [idea_id], references: [id], onDelete: Cascade)
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
@@unique([idea_id, product_id])
@@index([product_id])
@@map("idea_products")
}
model IdeaLog {
id String @id @default(cuid())
idea Idea @relation(fields: [idea_id], references: [id], onDelete: Cascade)
idea_id String
type IdeaLogType
content String @db.Text
metadata Json?
created_at DateTime @default(now())
@@index([idea_id, created_at])
@@map("idea_logs")
}
model UserQuestion {
id String @id @default(cuid())
idea_id String
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
user_id String
question String @db.Text
answer String? @db.Text
status UserQuestionStatus @default(pending)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
product Product? @relation(fields: [product_id], references: [id], onDelete: SetNull)
product_id String?
title String
description String? @db.VarChar(2000)
done Boolean @default(false)
archived Boolean @default(false)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
idea Idea @relation(fields: [idea_id], references: [id], onDelete: Cascade)
@@index([idea_id, status])
@@index([user_id])
@@map("user_questions")
@@index([user_id, done, archived])
@@index([user_id, product_id])
@@map("todos")
}
model LoginPairing {
@ -611,45 +348,27 @@ model LoginPairing {
}
model ClaudeQuestion {
id String @id @default(cuid())
story Story? @relation(fields: [story_id], references: [id], onDelete: Cascade)
story_id String?
task Task? @relation(fields: [task_id], references: [id], onDelete: SetNull)
task_id String?
idea Idea? @relation(fields: [idea_id], references: [id], onDelete: Cascade)
idea_id String?
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
product_id String // gedenormaliseerd uit story.product_id voor SSE-filter
asker User @relation("ClaudeQuestionAsker", fields: [asked_by], references: [id])
asked_by String // user_id van token-houder (= Claude-token)
question String @db.Text
options Json? // string[] voor multi-choice; null voor free-text
status String // 'open' | 'answered' | 'cancelled' | 'expired'
answer String? @db.Text
answerer User? @relation("ClaudeQuestionAnswerer", fields: [answered_by], references: [id])
answered_by String?
answered_at DateTime?
created_at DateTime @default(now())
expires_at DateTime // ingesteld door MCP-tool, default now() + 24h
id String @id @default(cuid())
story Story @relation(fields: [story_id], references: [id], onDelete: Cascade)
story_id String
task Task? @relation(fields: [task_id], references: [id], onDelete: SetNull)
task_id String?
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
product_id String // gedenormaliseerd uit story.product_id voor SSE-filter
asker User @relation("ClaudeQuestionAsker", fields: [asked_by], references: [id])
asked_by String // user_id van token-houder (= Claude-token)
question String @db.Text
options Json? // string[] voor multi-choice; null voor free-text
status String // 'open' | 'answered' | 'cancelled' | 'expired'
answer String? @db.Text
answerer User? @relation("ClaudeQuestionAnswerer", fields: [answered_by], references: [id])
answered_by String?
answered_at DateTime?
created_at DateTime @default(now())
expires_at DateTime // ingesteld door MCP-tool, default now() + 24h
@@index([story_id, status])
@@index([idea_id, status])
@@index([product_id, status])
@@index([status, expires_at])
@@map("claude_questions")
}
model PushSubscription {
id String @id @default(cuid())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
user_id String
endpoint String @unique
p256dh String
auth String
user_agent String?
created_at DateTime @default(now())
last_used_at DateTime @default(now())
@@index([user_id])
@@map("push_subscriptions")
}

View file

@ -1,229 +0,0 @@
// PostToolUse hook for mcp__scrum4me__update_job_status.
//
// Reads the local Claude Code transcript (no Anthropic API needed) and writes
// per-job token usage + model_id to claude_jobs. The hook receives a JSON
// payload on stdin with { session_id, transcript_path, tool_name, tool_input }.
//
// Window detection: the most-recent assistant message before EOF that issued a
// `mcp__scrum4me__wait_for_job` tool_use marks the job's start. All assistant
// messages after that index, up to and including the one that just called
// update_job_status, are summed.
//
// Idempotent — running twice for the same job overwrites with the same values.
// Designed to never block the agent: any failure logs a warning and exits 0.
import { readFile } from 'node:fs/promises'
import { prisma } from '../src/prisma.js'
export type HookInput = {
session_id?: string
transcript_path?: string
tool_name?: string
tool_input?: { job_id?: string; status?: string }
}
type Usage = {
input_tokens?: number
output_tokens?: number
cache_creation_input_tokens?: number
cache_read_input_tokens?: number
}
type ContentBlock = { type?: string; name?: string }
type TranscriptLine = {
type?: string
uuid?: string
isSidechain?: boolean
message?: {
role?: string
model?: string
content?: ContentBlock[]
usage?: Usage
}
}
export type ComputedUsage = {
model_id: string | null
input_tokens: number
output_tokens: number
cache_read_tokens: number
cache_write_tokens: number
}
const WAIT_TOOL_NAME = 'mcp__scrum4me__wait_for_job'
const UPDATE_TOOL_NAME = 'mcp__scrum4me__update_job_status'
export function parseTranscript(raw: string): TranscriptLine[] {
const lines = raw.split('\n')
const out: TranscriptLine[] = []
const seenUuids = new Set<string>()
for (const line of lines) {
if (!line) continue
let parsed: TranscriptLine
try {
parsed = JSON.parse(line) as TranscriptLine
} catch {
continue // skip malformed lines — transcript may be partially written
}
// Dedup on uuid: branching/resumption can re-write the same message into
// multiple JSONLs. Keep first occurrence.
if (parsed.uuid) {
if (seenUuids.has(parsed.uuid)) continue
seenUuids.add(parsed.uuid)
}
out.push(parsed)
}
return out
}
function hasToolUse(line: TranscriptLine, toolName: string): boolean {
const content = line.message?.content
if (!Array.isArray(content)) return false
return content.some((c) => c.type === 'tool_use' && c.name === toolName)
}
export function computeUsageFromTranscript(lines: TranscriptLine[]): ComputedUsage {
// Skip subagent (sidechain) lines: token usage attributed to subagent work
// is reported in the main transcript via assistant messages of the parent
// agent. Counting sidechain lines as well risks double-attribution because
// those same units of work also appear in `subagents/`-subdirectory files.
const main = lines.filter((l) => !l.isSidechain)
// Find the last main-agent assistant message that called wait_for_job.
let startIdx = -1
for (let i = main.length - 1; i >= 0; i--) {
if (hasToolUse(main[i], WAIT_TOOL_NAME)) {
startIdx = i
break
}
}
// Window = (startIdx, end]. If no wait_for_job found, sum the whole session.
const from = startIdx + 1
const window = main.slice(from)
let input = 0
let output = 0
let cacheRead = 0
let cacheWrite = 0
let model: string | null = null
const modelsSeen = new Set<string>()
for (const line of window) {
if (line.type !== 'assistant') continue
const msg = line.message
if (!msg || msg.role !== 'assistant') continue
const u = msg.usage
if (u) {
input += u.input_tokens ?? 0
output += u.output_tokens ?? 0
cacheRead += u.cache_read_input_tokens ?? 0
cacheWrite += u.cache_creation_input_tokens ?? 0
}
if (msg.model) {
modelsSeen.add(msg.model)
model = msg.model // keep last
}
}
if (modelsSeen.size > 1) {
console.warn(
`[persist-job-usage] multiple models in window: ${[...modelsSeen].join(', ')} — using last (${model})`,
)
}
return {
model_id: model ? normalizeModelId(model) : null,
input_tokens: input,
output_tokens: output,
cache_read_tokens: cacheRead,
cache_write_tokens: cacheWrite,
}
}
// Strip wrapping brackets so [1m]-suffix maps cleanly to a model_prices row.
// Example: 'claude-opus-4-7[1m]' → 'claude-opus-4-7-1m'.
export function normalizeModelId(raw: string): string {
return raw.replace(/\[(.*?)\]/g, '-$1')
}
export async function readHookInput(): Promise<HookInput> {
const chunks: Buffer[] = []
for await (const chunk of process.stdin) {
chunks.push(chunk as Buffer)
}
const raw = Buffer.concat(chunks).toString('utf8').trim()
if (!raw) return {}
try {
return JSON.parse(raw) as HookInput
} catch {
return {}
}
}
export async function persistJobUsage(input: HookInput): Promise<'skipped' | 'written' | 'noop'> {
if (input.tool_name !== UPDATE_TOOL_NAME) return 'skipped'
const status = input.tool_input?.status
if (status !== 'done' && status !== 'failed') return 'skipped'
const jobId = input.tool_input?.job_id
if (!jobId) return 'skipped'
const transcriptPath = input.transcript_path
if (!transcriptPath) return 'skipped'
let raw: string
try {
raw = await readFile(transcriptPath, 'utf8')
} catch (err) {
console.warn(`[persist-job-usage] cannot read transcript ${transcriptPath}:`, err)
return 'skipped'
}
const lines = parseTranscript(raw)
const usage = computeUsageFromTranscript(lines)
// Skip pure no-op: no usage data and no model — nothing meaningful to persist.
if (
usage.model_id === null &&
usage.input_tokens === 0 &&
usage.output_tokens === 0 &&
usage.cache_read_tokens === 0 &&
usage.cache_write_tokens === 0
) {
return 'noop'
}
await prisma.claudeJob.update({
where: { id: jobId },
data: {
...(usage.model_id !== null ? { model_id: usage.model_id } : {}),
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
cache_read_tokens: usage.cache_read_tokens,
cache_write_tokens: usage.cache_write_tokens,
},
})
return 'written'
}
async function main(): Promise<void> {
try {
const input = await readHookInput()
const result = await persistJobUsage(input)
if (result === 'written') {
console.log(`[persist-job-usage] persisted usage for job=${input.tool_input?.job_id}`)
}
} catch (err) {
console.warn('[persist-job-usage] error:', err)
} finally {
// Ensure clean exit even if Prisma keeps a connection pool alive.
process.exit(0)
}
}
const isDirect =
import.meta.url === `file://${process.argv[1]}` ||
process.argv[1]?.endsWith('persist-job-usage.ts')
if (isDirect) {
void main()
}

View file

@ -28,13 +28,3 @@ export async function userCanAccessStory(storyId: string, userId: string): Promi
if (!story) return false
return userCanAccessProduct(story.product_id, userId)
}
// M12: idee is strikt user_id-only (geen productAccessFilter — Q8).
// Idea-questions, idea-jobs, en idea-md-mutaties scopen op de eigenaar.
export async function userOwnsIdea(ideaId: string, userId: string): Promise<boolean> {
const idea = await prisma.idea.findUnique({
where: { id: ideaId },
select: { user_id: true },
})
return idea !== null && idea.user_id === userId
}

View file

@ -1,253 +0,0 @@
// PBI fail-cascade — wanneer een TASK_IMPLEMENTATION-job FAILED wordt,
// cancellen we alle queued/claimed/running siblings binnen dezelfde PBI
// en draaien we eerder gepushte commits ongedaan via PR-close of een
// auto-revert-PR. Idempotent en non-blocking: elke fout wordt gelogd in
// het error-veld van de oorspronkelijke failed-job en stopt de cascade niet.
import { prisma } from '../prisma.js'
import { resolveRepoRoot } from '../tools/wait-for-job.js'
import { removeWorktreeForJob } from '../git/worktree.js'
import {
closePullRequest,
createRevertPullRequest,
getPullRequestState,
} from '../git/pr.js'
import { deleteRemoteBranch } from '../git/push.js'
import { releaseLocksOnTerminal } from '../git/job-locks.js'
export type CascadeOutcome = {
cancelled_job_ids: string[]
closed_prs: string[]
reverted_prs: { original: string; revertPr: string }[]
deleted_branches: string[]
warnings: string[]
}
const EMPTY: CascadeOutcome = {
cancelled_job_ids: [],
closed_prs: [],
reverted_prs: [],
deleted_branches: [],
warnings: [],
}
// Public entry. Always returns; never throws.
export async function cancelPbiOnFailure(failedJobId: string): Promise<CascadeOutcome> {
try {
return await runCascade(failedJobId)
} catch (err) {
console.warn(`[pbi-cascade] unexpected error for failedJob=${failedJobId}:`, err)
return { ...EMPTY, warnings: [`unexpected: ${(err as Error).message}`] }
}
}
async function runCascade(failedJobId: string): Promise<CascadeOutcome> {
const failedJob = await prisma.claudeJob.findUnique({
where: { id: failedJobId },
select: {
id: true,
kind: true,
status: true,
product_id: true,
task_id: true,
branch: true,
pr_url: true,
task: {
select: {
story: {
select: {
pbi: { select: { id: true, code: true } },
},
},
},
},
},
})
if (!failedJob) return EMPTY
if (failedJob.kind !== 'TASK_IMPLEMENTATION') return EMPTY
// SKIPPED is een no-op exit (zie update_job_status). Geen cascade naar siblings.
if (failedJob.status === 'SKIPPED') return EMPTY
const pbi = failedJob.task?.story?.pbi
if (!pbi) return EMPTY
// 1. Atomic cascade: select + updateMany. Race-window between SELECT
// and UPDATE is harmless because the cascade is idempotent — a second
// invocation simply finds zero rows.
const eligible = await prisma.claudeJob.findMany({
where: {
id: { not: failedJobId },
status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] },
task: { story: { pbi_id: pbi.id } },
},
select: { id: true, branch: true, pr_url: true, status: true, task_id: true },
})
if (eligible.length > 0) {
await prisma.claudeJob.updateMany({
where: { id: { in: eligible.map((j) => j.id) } },
data: {
status: 'CANCELLED',
finished_at: new Date(),
error: 'cancelled_by_pbi_failure',
},
})
// PBI-9: release product-worktree locks for cancelled jobs.
// No-op for jobs without registered locks (TASK_IMPLEMENTATION).
for (const j of eligible) await releaseLocksOnTerminal(j.id)
}
const outcome: CascadeOutcome = {
cancelled_job_ids: eligible.map((j) => j.id),
closed_prs: [],
reverted_prs: [],
deleted_branches: [],
warnings: [],
}
// 2. Group affected jobs (cascade-set failed) by branch to avoid
// closing the same PR twice for siblings sharing a story-branch.
const branchSet = new Map<string, { prUrl: string | null }>()
const all = [...eligible, { branch: failedJob.branch, pr_url: failedJob.pr_url }]
for (const j of all) {
if (!j.branch) continue
const existing = branchSet.get(j.branch)
// Prefer a non-null pr_url if any sibling has one.
if (!existing) {
branchSet.set(j.branch, { prUrl: j.pr_url ?? null })
} else if (!existing.prUrl && j.pr_url) {
branchSet.set(j.branch, { prUrl: j.pr_url })
}
}
const repoRoot = await resolveRepoRoot(failedJob.product_id)
const cascadeComment = `PBI ${pbi.code ?? pbi.id} cascaded fail — see job ${failedJobId}`
for (const [branch, { prUrl }] of branchSet) {
if (prUrl) {
const info = await getPullRequestState({ prUrl, cwd: repoRoot ?? undefined })
if ('error' in info) {
outcome.warnings.push(`gh pr view ${prUrl}: ${info.error}`)
continue
}
if (info.state === 'CLOSED') {
// Already closed; nothing to do for the PR. Branch may still exist.
if (repoRoot) await tryDeleteBranch(repoRoot, branch, outcome)
continue
}
if (info.state === 'OPEN') {
const closed = await closePullRequest({
prUrl,
comment: cascadeComment,
cwd: repoRoot ?? undefined,
})
if ('error' in closed) {
outcome.warnings.push(`close ${prUrl}: ${closed.error}`)
} else {
outcome.closed_prs.push(prUrl)
}
continue
}
if (info.state === 'MERGED') {
if (!repoRoot) {
outcome.warnings.push(
`merged PR ${prUrl} not reverted: no repo root configured for product ${failedJob.product_id}`,
)
continue
}
if (!info.mergeCommit) {
outcome.warnings.push(`merged PR ${prUrl} has no mergeCommit — skipping revert`)
continue
}
const revert = await createRevertPullRequest({
repoRoot,
mergeSha: info.mergeCommit,
baseRef: info.baseRefName,
originalTitle: info.title,
originalBranch: branch,
jobId: failedJobId,
pbiCode: pbi.code,
})
if ('error' in revert) {
outcome.warnings.push(`revert ${prUrl}: ${revert.error}`)
} else {
outcome.reverted_prs.push({ original: prUrl, revertPr: revert.url })
}
continue
}
} else {
// Branch without PR: best-effort delete on remote.
if (repoRoot) await tryDeleteBranch(repoRoot, branch, outcome)
}
}
// 3. Worktree cleanup for every cancelled job (and the failed job itself
// is handled elsewhere by cleanupWorktreeForTerminalStatus). For
// cancelled jobs we always discard the branch locally — they did not
// succeed.
if (repoRoot) {
for (const j of eligible) {
try {
await removeWorktreeForJob({ repoRoot, jobId: j.id, keepBranch: false })
} catch (err) {
outcome.warnings.push(`worktree cleanup for ${j.id}: ${(err as Error).message}`)
}
}
}
// 4. Persist a trace on the failed-job's error field so the operator can
// follow up. Use a structured one-liner to keep the column readable.
// Append to the existing error (separated by '\n---\n') so the original
// failure reason is preserved instead of being overwritten by the trace.
const trace = formatTrace(outcome)
if (trace) {
try {
const fresh = await prisma.claudeJob.findUnique({
where: { id: failedJobId },
select: { error: true },
})
const merged = fresh?.error
? `${fresh.error}\n---\n${trace}`.slice(0, 1900)
: trace.slice(0, 1900)
await prisma.claudeJob.update({
where: { id: failedJobId },
data: { error: merged },
})
} catch (err) {
console.warn(`[pbi-cascade] failed to persist trace for ${failedJobId}:`, err)
}
}
return outcome
}
async function tryDeleteBranch(
repoRoot: string,
branch: string,
outcome: CascadeOutcome,
): Promise<void> {
const result = await deleteRemoteBranch({ repoRoot, branch })
if (result.deleted) {
outcome.deleted_branches.push(branch)
return
}
if (result.reason === 'not-found') {
// Already gone — silent no-op.
return
}
outcome.warnings.push(
`delete-branch ${branch} (${result.reason}): ${result.stderr.slice(0, 120)}`,
)
}
function formatTrace(o: CascadeOutcome): string {
const parts: string[] = ['cancelled_by_self']
if (o.cancelled_job_ids.length) parts.push(`siblings_cancelled=${o.cancelled_job_ids.length}`)
if (o.closed_prs.length) parts.push(`closed=${o.closed_prs.join(',')}`)
if (o.reverted_prs.length) {
parts.push(`reverted=${o.reverted_prs.map((r) => `${r.original}->${r.revertPr}`).join(';')}`)
}
if (o.deleted_branches.length) parts.push(`branches_deleted=${o.deleted_branches.join(',')}`)
if (o.warnings.length) parts.push(`warnings=${o.warnings.length}`)
return parts.join('; ')
}

View file

@ -1,192 +0,0 @@
// PBI-9 + PBI-47: declarative effects produced by pure transitions.
// Executor handles each effect idempotently; failures are logged, not thrown.
export type PauseContext = {
pause_reason: 'MERGE_CONFLICT'
pr_url: string
pr_head_sha: string
conflict_files: string[]
claude_question_id: string
resume_instructions: string
paused_at: string
}
export type FlowEffect =
| { type: 'RELEASE_WORKTREE_LOCKS'; jobId: string }
| { type: 'ENABLE_AUTO_MERGE'; prUrl: string; expectedHeadSha: string }
| { type: 'MARK_PR_READY'; prUrl: string }
| {
type: 'CREATE_CLAUDE_QUESTION'
sprintRunId: string
prUrl: string
files: string[]
}
| { type: 'CLOSE_CLAUDE_QUESTION'; questionId: string }
| {
type: 'SET_SPRINT_RUN_STATUS'
sprintRunId: string
status: 'QUEUED' | 'RUNNING' | 'PAUSED' | 'DONE' | 'FAILED' | 'CANCELLED'
pauseContextDraft?: Omit<PauseContext, 'claude_question_id'>
clearPauseContext?: boolean
}
export type AutoMergeOutcome =
| { effect: 'ENABLE_AUTO_MERGE'; ok: true }
| {
effect: 'ENABLE_AUTO_MERGE'
ok: false
reason: 'CHECKS_FAILED' | 'MERGE_CONFLICT' | 'GH_AUTH_ERROR' | 'AUTO_MERGE_NOT_ALLOWED' | 'UNKNOWN'
stderr: string
}
/**
* Execute a list of effects in order. Returns outcome objects only for
* effects whose result the caller needs to react to (auto-merge fail
* triggers MERGE_CONFLICT-event in update-job-status). Other failures
* are logged but swallowed.
*
* CREATE_CLAUDE_QUESTION SET_SPRINT_RUN_STATUS chains: the question_id
* created in the first effect is injected into the pause_context of the
* second.
*/
export async function executeEffects(
effects: FlowEffect[],
): Promise<AutoMergeOutcome[]> {
const outcomes: AutoMergeOutcome[] = []
let lastQuestionId: string | undefined
for (const effect of effects) {
try {
if (effect.type === 'CREATE_CLAUDE_QUESTION') {
lastQuestionId = await createOrReuseClaudeQuestion(effect)
continue
}
if (effect.type === 'SET_SPRINT_RUN_STATUS') {
await applySprintRunStatus(effect, lastQuestionId)
continue
}
const outcome = await executeEffect(effect)
if (outcome) outcomes.push(outcome)
} catch (err) {
console.warn(`[effects] effect ${effect.type} failed (idempotent skip):`, err)
}
}
return outcomes
}
async function executeEffect(effect: FlowEffect): Promise<AutoMergeOutcome | undefined> {
switch (effect.type) {
case 'RELEASE_WORKTREE_LOCKS': {
const { releaseLocksOnTerminal } = await import('../git/job-locks.js')
await releaseLocksOnTerminal(effect.jobId)
return undefined
}
case 'ENABLE_AUTO_MERGE': {
const { enableAutoMergeOnPr } = await import('../git/pr.js')
const result = await enableAutoMergeOnPr({
prUrl: effect.prUrl,
expectedHeadSha: effect.expectedHeadSha,
})
if (result.ok) return { effect: 'ENABLE_AUTO_MERGE', ok: true }
return { effect: 'ENABLE_AUTO_MERGE', ok: false, reason: result.reason, stderr: result.stderr }
}
case 'MARK_PR_READY': {
const { markPullRequestReady } = await import('../git/pr.js')
const result = await markPullRequestReady({ prUrl: effect.prUrl })
if ('error' in result) {
console.warn(`[effects] MARK_PR_READY failed for ${effect.prUrl}: ${result.error}`)
}
return undefined
}
case 'CLOSE_CLAUDE_QUESTION': {
const { prisma } = await import('../prisma.js')
await prisma.claudeQuestion.updateMany({
where: { id: effect.questionId, status: 'open' },
data: { status: 'closed' },
})
return undefined
}
// CREATE_CLAUDE_QUESTION + SET_SPRINT_RUN_STATUS handled in executeEffects.
case 'CREATE_CLAUDE_QUESTION':
case 'SET_SPRINT_RUN_STATUS':
return undefined
}
}
async function createOrReuseClaudeQuestion(effect: {
sprintRunId: string
prUrl: string
files: string[]
}): Promise<string> {
const { prisma } = await import('../prisma.js')
// Reuse existing open question for the same SprintRun + PR if present.
const existing = await prisma.claudeQuestion.findFirst({
where: {
status: 'open',
options: { path: ['sprint_run_id'], equals: effect.sprintRunId } as never,
},
orderBy: { created_at: 'desc' },
select: { id: true },
})
if (existing) return existing.id
// Need product_id + asker (user) to create. Resolve via SprintRun.
const sprintRun = await prisma.sprintRun.findUnique({
where: { id: effect.sprintRunId },
select: {
started_by_id: true,
sprint: { select: { product_id: true } },
},
})
if (!sprintRun) {
throw new Error(`SprintRun ${effect.sprintRunId} not found`)
}
const fileList =
effect.files.length === 0
? '(unknown files — check the PR)'
: effect.files.slice(0, 5).join(', ')
+ (effect.files.length > 5 ? ` + ${effect.files.length - 5} more` : '')
const created = await prisma.claudeQuestion.create({
data: {
product_id: sprintRun.sprint.product_id,
asked_by: sprintRun.started_by_id,
question:
`Merge-conflict on ${effect.prUrl}. Conflict files: ${fileList}. `
+ `Resolve on the branch and push, then resume the sprint.`,
options: {
sprint_run_id: effect.sprintRunId,
pr_url: effect.prUrl,
conflict_files: effect.files,
},
status: 'open',
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
},
select: { id: true },
})
return created.id
}
async function applySprintRunStatus(
effect: Extract<FlowEffect, { type: 'SET_SPRINT_RUN_STATUS' }>,
lastQuestionId: string | undefined,
): Promise<void> {
const { prisma, Prisma } = await (async () => {
const mod = await import('../prisma.js')
const prismaPkg = await import('@prisma/client')
return { prisma: mod.prisma, Prisma: prismaPkg.Prisma }
})()
const data: Record<string, unknown> = { status: effect.status }
if (effect.pauseContextDraft && lastQuestionId) {
data.pause_context = {
...effect.pauseContextDraft,
claude_question_id: lastQuestionId,
}
}
if (effect.clearPauseContext) {
data.pause_context = Prisma.JsonNull
}
await prisma.sprintRun.update({ where: { id: effect.sprintRunId }, data })
}

View file

@ -1,110 +0,0 @@
import type { FlowEffect } from './effects.js'
import type { AutoMergeFailReason } from '../git/pr.js'
export type PrStrategy = 'STORY' | 'SPRINT'
export type PrFlowState =
| { kind: 'none'; strategy: PrStrategy }
| { kind: 'branch_pushed'; strategy: PrStrategy; prUrl?: string }
| { kind: 'pr_opened'; strategy: 'STORY'; prUrl: string }
| { kind: 'draft_opened'; strategy: 'SPRINT'; prUrl: string }
| { kind: 'waiting_for_checks'; strategy: 'STORY'; prUrl: string; headSha: string }
| { kind: 'auto_merge_enabled'; strategy: 'STORY'; prUrl: string; headSha: string }
| { kind: 'ready_for_review'; strategy: 'SPRINT'; prUrl: string }
| { kind: 'merged'; strategy: PrStrategy; prUrl: string }
| { kind: 'checks_failed'; strategy: PrStrategy; prUrl: string }
| { kind: 'merge_conflict_paused'; strategy: PrStrategy; prUrl: string; headSha: string }
export type PrFlowEvent =
| { type: 'PR_CREATED'; prUrl: string }
| { type: 'TASK_DONE'; taskId: string; headSha: string }
| { type: 'STORY_COMPLETED'; storyId: string; headSha: string }
| { type: 'SPRINT_COMPLETED'; sprintRunId: string }
| { type: 'MERGE_RESULT'; reason?: AutoMergeFailReason }
export type TransitionResult = { nextState: PrFlowState; effects: FlowEffect[] }
export function transition(state: PrFlowState, event: PrFlowEvent): TransitionResult {
if (state.strategy === 'STORY') {
switch (state.kind) {
case 'none':
case 'branch_pushed':
if (event.type === 'PR_CREATED') {
return {
nextState: { kind: 'pr_opened', strategy: 'STORY', prUrl: event.prUrl },
effects: [],
}
}
break
case 'pr_opened':
if (event.type === 'STORY_COMPLETED') {
return {
nextState: {
kind: 'waiting_for_checks',
strategy: 'STORY',
prUrl: state.prUrl,
headSha: event.headSha,
},
effects: [
{ type: 'ENABLE_AUTO_MERGE', prUrl: state.prUrl, expectedHeadSha: event.headSha },
],
}
}
break
case 'waiting_for_checks':
if (event.type === 'MERGE_RESULT' && !event.reason) {
return {
nextState: {
kind: 'auto_merge_enabled',
strategy: 'STORY',
prUrl: state.prUrl,
headSha: state.headSha,
},
effects: [],
}
}
if (event.type === 'MERGE_RESULT' && event.reason === 'MERGE_CONFLICT') {
return {
nextState: {
kind: 'merge_conflict_paused',
strategy: 'STORY',
prUrl: state.prUrl,
headSha: state.headSha,
},
effects: [],
}
}
if (event.type === 'MERGE_RESULT' && event.reason === 'CHECKS_FAILED') {
return {
nextState: { kind: 'checks_failed', strategy: 'STORY', prUrl: state.prUrl },
effects: [],
}
}
break
}
}
if (state.strategy === 'SPRINT') {
switch (state.kind) {
case 'none':
case 'branch_pushed':
if (event.type === 'PR_CREATED') {
return {
nextState: { kind: 'draft_opened', strategy: 'SPRINT', prUrl: event.prUrl },
effects: [],
}
}
break
case 'draft_opened':
if (event.type === 'SPRINT_COMPLETED') {
return {
nextState: { kind: 'ready_for_review', strategy: 'SPRINT', prUrl: state.prUrl },
effects: [{ type: 'MARK_PR_READY', prUrl: state.prUrl }],
}
}
break
}
}
return { nextState: state, effects: [] }
}

View file

@ -1,136 +0,0 @@
import type { FlowEffect, PauseContext } from './effects.js'
export type SprintRunStateKind =
| 'queued'
| 'running'
| 'paused_merge_conflict'
| 'done'
| 'failed'
| 'cancelled'
export type SprintRunState = {
kind: SprintRunStateKind
sprintRunId: string
pauseContext?: PauseContext
}
export type SprintRunEvent =
| { type: 'CLAIM_FIRST_JOB' }
| { type: 'TASK_DONE'; taskId: string }
| { type: 'TASK_FAILED'; taskId: string; error: string }
| {
type: 'MERGE_CONFLICT'
prUrl: string
prHeadSha: string
conflictFiles: string[]
resumeInstructions: string
}
| { type: 'USER_RESUMED' }
| { type: 'USER_CANCELLED' }
| { type: 'ALL_DONE' }
export type TransitionResult = { nextState: SprintRunState; effects: FlowEffect[] }
export function transition(state: SprintRunState, event: SprintRunEvent): TransitionResult {
switch (state.kind) {
case 'queued':
if (event.type === 'CLAIM_FIRST_JOB') {
return {
nextState: { ...state, kind: 'running' },
effects: [
{ type: 'SET_SPRINT_RUN_STATUS', sprintRunId: state.sprintRunId, status: 'RUNNING' },
],
}
}
break
case 'running':
if (event.type === 'TASK_DONE') {
return { nextState: state, effects: [] }
}
if (event.type === 'TASK_FAILED') {
return {
nextState: { ...state, kind: 'failed' },
effects: [
{ type: 'SET_SPRINT_RUN_STATUS', sprintRunId: state.sprintRunId, status: 'FAILED' },
],
}
}
if (event.type === 'ALL_DONE') {
return {
nextState: { ...state, kind: 'done' },
effects: [
{ type: 'SET_SPRINT_RUN_STATUS', sprintRunId: state.sprintRunId, status: 'DONE' },
],
}
}
if (event.type === 'MERGE_CONFLICT') {
const pauseContextDraft: Omit<PauseContext, 'claude_question_id'> = {
pause_reason: 'MERGE_CONFLICT',
pr_url: event.prUrl,
pr_head_sha: event.prHeadSha,
conflict_files: event.conflictFiles,
resume_instructions: event.resumeInstructions,
paused_at: new Date().toISOString(),
}
return {
nextState: { ...state, kind: 'paused_merge_conflict' },
effects: [
{
type: 'CREATE_CLAUDE_QUESTION',
sprintRunId: state.sprintRunId,
prUrl: event.prUrl,
files: event.conflictFiles,
},
{
type: 'SET_SPRINT_RUN_STATUS',
sprintRunId: state.sprintRunId,
status: 'PAUSED',
pauseContextDraft,
},
],
}
}
if (event.type === 'USER_CANCELLED') {
return {
nextState: { ...state, kind: 'cancelled' },
effects: [
{ type: 'SET_SPRINT_RUN_STATUS', sprintRunId: state.sprintRunId, status: 'CANCELLED' },
],
}
}
break
case 'paused_merge_conflict':
if (event.type === 'USER_RESUMED') {
const closeQuestionEffects: FlowEffect[] = state.pauseContext
? [{ type: 'CLOSE_CLAUDE_QUESTION', questionId: state.pauseContext.claude_question_id }]
: []
return {
nextState: { ...state, kind: 'running', pauseContext: undefined },
effects: [
...closeQuestionEffects,
{
type: 'SET_SPRINT_RUN_STATUS',
sprintRunId: state.sprintRunId,
status: 'RUNNING',
clearPauseContext: true,
},
],
}
}
if (event.type === 'USER_CANCELLED') {
return {
nextState: { ...state, kind: 'cancelled', pauseContext: undefined },
effects: [
{
type: 'SET_SPRINT_RUN_STATUS',
sprintRunId: state.sprintRunId,
status: 'CANCELLED',
clearPauseContext: true,
},
],
}
}
break
}
return { nextState: state, effects: [] }
}

View file

@ -1,103 +0,0 @@
import type { FlowEffect } from './effects.js'
export type WorktreeLeaseState =
| { kind: 'idle' }
| { kind: 'acquiring_lock'; jobId: string; productIds: string[] }
| { kind: 'creating_or_reusing'; jobId: string; productIds: string[] }
| { kind: 'syncing'; jobId: string; productIds: string[] }
| { kind: 'ready'; jobId: string; productIds: string[] }
| { kind: 'releasing'; jobId: string }
| { kind: 'released'; jobId: string }
| { kind: 'lock_timeout'; jobId: string; productIds: string[] }
| { kind: 'sync_failed'; jobId: string; productIds: string[]; error: string }
| { kind: 'stale_released'; jobId: string }
export type WorktreeLeaseEvent =
| { type: 'JOB_CLAIMED'; jobId: string; productIds: string[] }
| { type: 'LOCK_ACQUIRED' }
| { type: 'LOCK_TIMEOUT' }
| { type: 'WORKTREE_READY' }
| { type: 'SYNC_DONE' }
| { type: 'SYNC_FAILED'; error: string }
| { type: 'JOB_TERMINAL'; jobId: string }
| { type: 'STALE_RESET'; jobId: string }
export type TransitionResult = {
nextState: WorktreeLeaseState
effects: FlowEffect[]
}
export function transition(
state: WorktreeLeaseState,
event: WorktreeLeaseEvent,
): TransitionResult {
switch (state.kind) {
case 'idle':
if (event.type === 'JOB_CLAIMED') {
return {
nextState: { kind: 'acquiring_lock', jobId: event.jobId, productIds: event.productIds },
effects: [],
}
}
break
case 'acquiring_lock':
if (event.type === 'LOCK_ACQUIRED') {
return {
nextState: { kind: 'creating_or_reusing', jobId: state.jobId, productIds: state.productIds },
effects: [],
}
}
if (event.type === 'LOCK_TIMEOUT') {
return {
nextState: { kind: 'lock_timeout', jobId: state.jobId, productIds: state.productIds },
effects: [],
}
}
break
case 'creating_or_reusing':
if (event.type === 'WORKTREE_READY') {
return {
nextState: { kind: 'syncing', jobId: state.jobId, productIds: state.productIds },
effects: [],
}
}
break
case 'syncing':
if (event.type === 'SYNC_DONE') {
return {
nextState: { kind: 'ready', jobId: state.jobId, productIds: state.productIds },
effects: [],
}
}
if (event.type === 'SYNC_FAILED') {
return {
nextState: {
kind: 'sync_failed',
jobId: state.jobId,
productIds: state.productIds,
error: event.error,
},
effects: [{ type: 'RELEASE_WORKTREE_LOCKS', jobId: state.jobId }],
}
}
break
case 'ready':
if (event.type === 'JOB_TERMINAL') {
return {
nextState: { kind: 'releasing', jobId: state.jobId },
effects: [{ type: 'RELEASE_WORKTREE_LOCKS', jobId: state.jobId }],
}
}
if (event.type === 'STALE_RESET') {
return {
nextState: { kind: 'stale_released', jobId: state.jobId },
effects: [{ type: 'RELEASE_WORKTREE_LOCKS', jobId: state.jobId }],
}
}
break
case 'releasing':
return { nextState: { kind: 'released', jobId: state.jobId }, effects: [] }
}
// Unknown or forbidden transition — keep current state, no effects
return { nextState: state, effects: [] }
}

View file

@ -1,38 +0,0 @@
import lockfile from 'proper-lockfile'
export async function acquireFileLock(lockPath: string): Promise<() => Promise<void>> {
const release = await lockfile.lock(lockPath, {
realpath: false,
stale: 30_000,
update: 5_000,
retries: { retries: 60, factor: 1, minTimeout: 1_000, maxTimeout: 1_000 },
})
let released = false
return async () => {
if (released) return
released = true
await release()
}
}
export async function acquireFileLocksOrdered(
lockPaths: string[],
): Promise<() => Promise<void>> {
const sorted = [...lockPaths].sort()
const releases: Array<() => Promise<void>> = []
try {
for (const p of sorted) {
releases.push(await acquireFileLock(p))
}
} catch (err) {
for (const r of releases.reverse()) {
await r().catch(() => {})
}
throw err
}
return async () => {
for (const r of releases.reverse()) {
await r().catch(() => {})
}
}
}

View file

@ -1,73 +0,0 @@
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { acquireFileLocksOrdered } from './file-lock.js'
import {
getProductWorktreeLockPath,
getWorktreeRoot,
} from './worktree-paths.js'
import {
getOrCreateProductWorktree,
syncProductWorktree,
} from './product-worktree.js'
type JobReleases = Map<string, Array<() => Promise<void>>>
const jobReleases: JobReleases = new Map()
export async function setupProductWorktrees(
jobId: string,
productIds: string[],
resolveRepoRoot: (productId: string) => Promise<string | null>,
): Promise<Array<{ productId: string; worktreePath: string }>> {
if (productIds.length === 0) return []
// Ensure parent dir exists so lockfile creation succeeds
await fs.mkdir(path.join(getWorktreeRoot(), '_products'), { recursive: true })
// Lock-first, alphabetically sorted (deadlock prevention for multi-product idea-jobs).
// Locks acquired in sorted order; output preserves caller's input order so that
// worktrees[0] is the primary product (Idea.product_id), regardless of how its
// id sorts alphabetically against secondary products.
const sorted = [...productIds].sort()
const lockPaths = sorted.map(getProductWorktreeLockPath)
const releaseAll = await acquireFileLocksOrdered(lockPaths)
registerJobLockReleases(jobId, [releaseAll])
// After lock-acquire, create/reuse worktrees and sync — iterate input order
// so callers get back [primary, ...secondaries] in their original sequence.
const out: Array<{ productId: string; worktreePath: string }> = []
for (const productId of productIds) {
const repoRoot = await resolveRepoRoot(productId)
if (!repoRoot) continue
const { worktreePath } = await getOrCreateProductWorktree({ repoRoot, productId })
await syncProductWorktree({ worktreePath })
out.push({ productId, worktreePath })
}
return out
}
export function registerJobLockReleases(
jobId: string,
releases: Array<() => Promise<void>>,
): void {
const existing = jobReleases.get(jobId) ?? []
jobReleases.set(jobId, [...existing, ...releases])
}
export async function releaseLocksOnTerminal(jobId: string): Promise<void> {
const releases = jobReleases.get(jobId)
if (!releases) return // idempotent — already released or never locked
jobReleases.delete(jobId)
for (const release of releases) {
try {
await release()
} catch (err) {
console.warn(`[job-locks] release failed for job ${jobId}:`, err)
}
}
}
// For tests
export function _resetJobReleasesForTest(): void {
jobReleases.clear()
}

View file

@ -1,7 +1,5 @@
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import * as path from 'node:path'
import { getWorktreeRoot } from './worktree-paths.js'
const exec = promisify(execFile)
@ -10,31 +8,22 @@ export async function createPullRequest(opts: {
branchName: string
title: string
body: string
/** Open as draft PR (mens moet 'm later ready-for-review zetten). Default false. */
draft?: boolean
/**
* PBI-47 (P0): default changed to false. Auto-merge is now enabled
* separately via `enableAutoMergeOnPr` only on the **last task** of a
* STORY-mode story, with a head-SHA guard to prevent racing earlier
* task merges. Callers may still pass `true` for one-off PRs that
* are immediately ready to merge; in that case we use the new typed
* helper rather than the previous fire-and-forget gh call.
*/
enableAutoMerge?: boolean
}): Promise<{ url: string } | { error: string }> {
const { worktreePath, branchName, title, body, draft = false, enableAutoMerge = false } = opts
const { worktreePath, branchName, title, body } = opts
let url: string
try {
const args = ['pr', 'create', '--title', title, '--body', body, '--head', branchName]
if (draft) args.push('--draft')
const { stdout } = await exec('gh', args, { cwd: worktreePath })
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)
url = lines[lines.length - 1]?.trim() ?? ''
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 =
@ -46,248 +35,4 @@ export async function createPullRequest(opts: {
}
return { error: `gh pr create failed: ${msg.slice(0, 300)}` }
}
// Legacy opt-in: enableAutoMerge=true and not draft → fire the new typed
// helper without head-SHA guard (caller didn't supply one). Result is
// logged but not propagated — same shape as before.
if (enableAutoMerge && !draft) {
const result = await enableAutoMergeOnPr({ prUrl: url, cwd: worktreePath })
if (!result.ok) {
console.warn(
`[createPullRequest] auto-merge enable failed for ${url}: ${result.reason} ${result.stderr.slice(0, 200)}`,
)
}
}
return { url }
}
export type AutoMergeFailReason =
| 'CHECKS_FAILED'
| 'MERGE_CONFLICT'
| 'GH_AUTH_ERROR'
| 'AUTO_MERGE_NOT_ALLOWED'
| 'UNKNOWN'
export type EnableAutoMergeResult =
| { ok: true }
| { ok: false; reason: AutoMergeFailReason; stderr: string }
function classifyAutoMergeError(stderr: string): AutoMergeFailReason {
if (/conflict|not in mergeable state|dirty/i.test(stderr)) return 'MERGE_CONFLICT'
if (/checks? failed|status check|required check/i.test(stderr)) return 'CHECKS_FAILED'
if (/authentication|HTTP 401|HTTP 403|permission|gh auth/i.test(stderr)) return 'GH_AUTH_ERROR'
if (/auto-?merge.*not.*allowed|auto-?merge.*disabled/i.test(stderr)) return 'AUTO_MERGE_NOT_ALLOWED'
return 'UNKNOWN'
}
/**
* Enable auto-merge (squash) on a PR with an optional head-SHA guard.
*
* PBI-47 (P0): when `expectedHeadSha` is provided we pass `--match-head-commit`
* so GitHub only activates auto-merge if the remote head still matches the
* SHA the caller observed. This prevents racing late pushes from another
* worker triggering a merge of a different commit set.
*/
export async function enableAutoMergeOnPr(opts: {
prUrl: string
expectedHeadSha?: string
cwd?: string
}): Promise<EnableAutoMergeResult> {
try {
const args = ['pr', 'merge', '--auto', '--squash']
if (opts.expectedHeadSha) args.push('--match-head-commit', opts.expectedHeadSha)
args.push(opts.prUrl)
await exec('gh', args, opts.cwd ? { cwd: opts.cwd } : {})
return { ok: true }
} catch (err) {
const stderr =
(err as { stderr?: string }).stderr ?? (err as Error).message ?? ''
return { ok: false, reason: classifyAutoMergeError(stderr), stderr: stderr.slice(0, 500) }
}
}
// Zet een draft-PR over naar "ready for review". Gebruikt bij sprint-mode
// wanneer alle stories in de SprintRun DONE zijn — mens reviewt en mergt zelf.
export async function markPullRequestReady(opts: {
prUrl: string
cwd?: string
}): Promise<{ ok: true } | { error: string }> {
try {
await exec('gh', ['pr', 'ready', opts.prUrl], opts.cwd ? { cwd: opts.cwd } : {})
return { ok: true }
} catch (err) {
const msg = (err as { stderr?: string }).stderr ?? (err as Error).message ?? ''
// gh-CLI fout "Pull request is not in draft state" is benign wanneer de
// PR al ready was (bv. handmatig ready gezet of een tweede call).
if (/not in draft state|already in ready/i.test(msg)) return { ok: true }
return { error: `gh pr ready failed: ${msg.slice(0, 300)}` }
}
}
export type PrState = 'OPEN' | 'MERGED' | 'CLOSED'
export type PrInfo = {
state: PrState
mergeCommit: string | null
baseRefName: string
title: string
}
export async function getPullRequestState(opts: {
prUrl: string
cwd?: string
}): Promise<PrInfo | { error: string }> {
const { prUrl } = opts
try {
const { stdout } = await exec(
'gh',
['pr', 'view', prUrl, '--json', 'state,mergeCommit,baseRefName,title'],
opts.cwd ? { cwd: opts.cwd } : {},
)
const parsed = JSON.parse(stdout) as {
state: string
mergeCommit: { oid: string } | null
baseRefName: string
title: string
}
const state = parsed.state.toUpperCase() as PrState
if (state !== 'OPEN' && state !== 'MERGED' && state !== 'CLOSED') {
return { error: `unexpected PR state: ${parsed.state}` }
}
return {
state,
mergeCommit: parsed.mergeCommit?.oid ?? null,
baseRefName: parsed.baseRefName,
title: parsed.title,
}
} catch (err) {
const msg = (err as { stderr?: string }).stderr ?? (err as Error).message ?? ''
return { error: `gh pr view failed: ${msg.slice(0, 300)}` }
}
}
export async function closePullRequest(opts: {
prUrl: string
comment: string
cwd?: string
}): Promise<{ ok: true } | { error: string }> {
try {
await exec(
'gh',
['pr', 'close', opts.prUrl, '--delete-branch', '--comment', opts.comment],
opts.cwd ? { cwd: opts.cwd } : {},
)
return { ok: true }
} catch (err) {
const msg = (err as { stderr?: string }).stderr ?? (err as Error).message ?? ''
return { error: `gh pr close failed: ${msg.slice(0, 300)}` }
}
}
// Creates a revert-PR for a merged PR. Uses an isolated worktree so it
// never touches the user's main checkout. Returns the new PR URL or an
// error string. The revert PR is opened WITHOUT auto-merge — the user
// must review + merge it manually so an unintended cascade can be undone.
export async function createRevertPullRequest(opts: {
repoRoot: string
mergeSha: string
baseRef: string
originalTitle: string
originalBranch: string
jobId: string
pbiCode: string | null
}): Promise<{ url: string } | { error: string }> {
const {
repoRoot,
mergeSha,
baseRef,
originalTitle,
originalBranch,
jobId,
pbiCode,
} = opts
const worktreeDir = getWorktreeRoot()
const wtPath = path.join(worktreeDir, `revert-${jobId}`)
const revertBranch = `revert/${originalBranch}-${jobId.slice(-8)}`
const run = async (cmd: string, args: string[], cwd: string) => {
await exec(cmd, args, { cwd })
}
// Cleanup helper, best-effort
const cleanup = async () => {
try {
await exec('git', ['worktree', 'remove', '--force', wtPath], { cwd: repoRoot })
} catch {
// ignore — worktree may not exist if creation failed
}
}
try {
await run('git', ['fetch', 'origin', baseRef, mergeSha], repoRoot)
await run('git', ['worktree', 'add', '-b', revertBranch, wtPath, `origin/${baseRef}`], repoRoot)
try {
await run('git', ['revert', '-m', '1', mergeSha, '--no-edit'], wtPath)
} catch (err) {
await cleanup()
const msg = (err as { stderr?: string }).stderr ?? (err as Error).message ?? ''
if (/conflict/i.test(msg)) {
return { error: `git revert conflicts on ${mergeSha}: ${msg.slice(0, 200)}` }
}
return { error: `git revert failed: ${msg.slice(0, 200)}` }
}
await run('git', ['push', '-u', 'origin', revertBranch], wtPath)
const pbiTag = pbiCode ? `PBI ${pbiCode}` : 'PBI'
const title = `Revert: ${originalTitle}`
const body = [
`Auto-revert by Scrum4Me agent.`,
``,
`Reason: ${pbiTag} failed (cascade from job \`${jobId}\`).`,
`Reverts merge commit \`${mergeSha}\`.`,
``,
`**Review carefully before merging** — auto-merge is intentionally NOT enabled on revert PRs.`,
].join('\n')
let prUrl: string
try {
const { stdout } = await exec(
'gh',
[
'pr',
'create',
'--base',
baseRef,
'--head',
revertBranch,
'--title',
title,
'--body',
body,
],
{ cwd: wtPath },
)
const lines = stdout.trim().split('\n').filter(Boolean)
prUrl = lines[lines.length - 1]?.trim() ?? ''
if (!prUrl.startsWith('http')) {
await cleanup()
return { error: `gh pr create produced unexpected output: ${stdout.slice(0, 200)}` }
}
} catch (err) {
await cleanup()
const msg = (err as { stderr?: string }).stderr ?? (err as Error).message ?? ''
return { error: `gh pr create (revert) failed: ${msg.slice(0, 300)}` }
}
await cleanup()
return { url: prUrl }
} catch (err) {
await cleanup()
const msg = (err as { stderr?: string }).stderr ?? (err as Error).message ?? ''
return { error: `revert worktree setup failed: ${msg.slice(0, 300)}` }
}
}

View file

@ -1,66 +0,0 @@
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { getProductWorktreePath } from './worktree-paths.js'
const exec = promisify(execFile)
export async function getOrCreateProductWorktree(opts: {
repoRoot: string
productId: string
}): Promise<{ worktreePath: string; created: boolean }> {
const worktreePath = getProductWorktreePath(opts.productId)
await fs.mkdir(path.dirname(worktreePath), { recursive: true })
try {
await fs.access(worktreePath)
return { worktreePath, created: false }
} catch {
// Path bestaat niet — aanmaken
}
await exec('git', ['fetch', 'origin', '--prune'], { cwd: opts.repoRoot })
await exec('git', ['worktree', 'add', '--detach', worktreePath, 'origin/main'], {
cwd: opts.repoRoot,
})
// Resolve REAL exclude-pad (linked worktree heeft .git als file, niet directory)
const { stdout } = await exec('git', ['rev-parse', '--git-path', 'info/exclude'], {
cwd: worktreePath,
})
const excludePath = path.resolve(worktreePath, stdout.trim())
const existing = await fs.readFile(excludePath, 'utf8').catch(() => '')
if (!existing.split('\n').includes('.scratch/')) {
const sep = existing === '' || existing.endsWith('\n') ? '' : '\n'
await fs.appendFile(excludePath, `${sep}.scratch/\n`)
}
return { worktreePath, created: true }
}
export async function syncProductWorktree(opts: { worktreePath: string }): Promise<void> {
const { worktreePath } = opts
await exec('git', ['fetch', 'origin', '--prune'], { cwd: worktreePath })
await exec('git', ['reset', '--hard', 'origin/main'], { cwd: worktreePath })
await exec('git', ['clean', '-fd', '-e', '.scratch/'], { cwd: worktreePath })
// Wis .scratch/ inhoud, behoud de map
const scratch = path.join(worktreePath, '.scratch')
await fs.rm(scratch, { recursive: true, force: true })
await fs.mkdir(scratch, { recursive: true })
}
export async function removeProductWorktree(opts: {
repoRoot: string
productId: string
}): Promise<{ removed: boolean }> {
const worktreePath = getProductWorktreePath(opts.productId)
try {
await exec('git', ['worktree', 'remove', '--force', worktreePath], {
cwd: opts.repoRoot,
})
return { removed: true }
} catch {
return { removed: false }
}
}

View file

@ -51,27 +51,3 @@ export async function pushBranchForJob(opts: {
return { pushed: false, reason: 'unknown', stderr }
}
}
export type DeleteRemoteResult =
| { deleted: true }
| { deleted: false; reason: 'not-found' | 'no-credentials' | 'unknown'; stderr: string }
export async function deleteRemoteBranch(opts: {
repoRoot: string
branch: string
}): Promise<DeleteRemoteResult> {
const { repoRoot, branch } = opts
try {
await exec('git', ['push', 'origin', '--delete', branch], { cwd: repoRoot })
return { deleted: true }
} catch (err) {
const stderr = (err as { stderr?: string }).stderr ?? (err as Error).message ?? ''
if (/remote ref does not exist|unable to delete .* remote ref does not exist/i.test(stderr)) {
return { deleted: false, reason: 'not-found', stderr }
}
if (/Authentication failed|could not read Username/i.test(stderr)) {
return { deleted: false, reason: 'no-credentials', stderr }
}
return { deleted: false, reason: 'unknown', stderr }
}
}

View file

@ -1,19 +0,0 @@
import * as os from 'node:os'
import * as path from 'node:path'
export const SYSTEM_WORKTREE_DIRS = new Set(['_products'])
export function getWorktreeRoot(): string {
return (
process.env.SCRUM4ME_AGENT_WORKTREE_DIR
?? path.join(os.homedir(), '.scrum4me-agent-worktrees')
)
}
export function getProductWorktreePath(productId: string): string {
return path.join(getWorktreeRoot(), '_products', productId)
}
export function getProductWorktreeLockPath(productId: string): string {
return path.join(getWorktreeRoot(), '_products', `${productId}.lock`)
}

View file

@ -1,8 +1,8 @@
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'
import { getWorktreeRoot } from './worktree-paths.js'
const exec = promisify(execFile)
@ -15,19 +15,6 @@ async function branchExists(repoRoot: string, name: string): Promise<boolean> {
}
}
async function remoteBranchExists(repoRoot: string, name: string): Promise<boolean> {
try {
await exec(
'git',
['show-ref', '--verify', '--quiet', `refs/remotes/origin/${name}`],
{ cwd: repoRoot },
)
return true
} catch {
return false
}
}
async function findWorktreeForBranch(
repoRoot: string,
branchName: string,
@ -63,7 +50,9 @@ export async function createWorktreeForJob(opts: {
const { repoRoot, jobId, baseRef = 'origin/main', reuseBranch = false } = opts
let { branchName } = opts
const parent = getWorktreeRoot()
const parent =
process.env.SCRUM4ME_AGENT_WORKTREE_DIR ??
path.join(os.homedir(), '.scrum4me-agent-worktrees')
await fs.mkdir(parent, { recursive: true })
@ -88,54 +77,13 @@ export async function createWorktreeForJob(opts: {
if (occupant) {
await exec('git', ['worktree', 'remove', '--force', occupant], { cwd: repoRoot })
}
// reuseBranch is decided sprint-wide, but git branches are per-repo. For a
// cross-repo sprint the first job targeting THIS repo gets reuseBranch=true
// even though the branch was never created here; a container recreate also
// wipes the local clone. Fall back gracefully instead of failing with
// "invalid reference":
// - local branch exists → reuse it
// - exists on origin only → recreate the local branch tracking origin
// - nowhere → create it fresh from baseRef
if (await branchExists(repoRoot, branchName)) {
await exec('git', ['worktree', 'add', worktreePath, branchName], { cwd: repoRoot })
} else if (await remoteBranchExists(repoRoot, branchName)) {
await exec(
'git',
['worktree', 'add', '-b', branchName, worktreePath, `origin/${branchName}`],
{ cwd: repoRoot },
)
} else {
await exec('git', ['worktree', 'add', '-b', branchName, worktreePath, baseRef], {
cwd: repoRoot,
})
}
await exec('git', ['worktree', 'add', worktreePath, branchName], { cwd: repoRoot })
return { worktreePath, branchName }
}
// Fresh branch: if a local branch with this name already exists, it is an
// orphan from a prior failed run (the agent didn't push or branch was
// never tied to a worktree). Remove the orphan so the new worktree gets
// the predictable `feat/story-<id>`-name; this prevents the kind of
// 2-May-2026 failure where the agent inherited an unrelated suffix and
// pushed to a non-existent remote ref.
// Fresh branch: suffix with timestamp when name collision occurs
if (await branchExists(repoRoot, branchName)) {
const occupant = await findWorktreeForBranch(repoRoot, branchName)
if (occupant) {
// Branch is currently checked out elsewhere — likely a sibling worktree
// that should have been cleaned up. Remove it before reusing the name.
try {
await exec('git', ['worktree', 'remove', '--force', occupant], { cwd: repoRoot })
} catch {
// ignore — fall through to deletion below
}
}
try {
await exec('git', ['branch', '-D', branchName], { cwd: repoRoot })
console.warn(`[createWorktreeForJob] removed orphan branch ${branchName} before recreate`)
} catch {
// last resort: timestamp-suffix to avoid collision rather than fail
branchName = `${branchName}-${Date.now()}`
}
branchName = `${branchName}-${Date.now()}`
}
await exec('git', ['worktree', 'add', '-b', branchName, worktreePath, baseRef], {
@ -152,7 +100,9 @@ export async function removeWorktreeForJob(opts: {
}): Promise<{ removed: boolean }> {
const { repoRoot, jobId, keepBranch = false } = opts
const parent = getWorktreeRoot()
const parent =
process.env.SCRUM4ME_AGENT_WORKTREE_DIR ??
path.join(os.homedir(), '.scrum4me-agent-worktrees')
const worktreePath = path.join(parent, jobId)

View file

@ -9,11 +9,10 @@ import { registerUpdateTaskPlanTool } from './tools/update-task-plan.js'
import { registerLogImplementationTool } from './tools/log-implementation.js'
import { registerLogTestResultTool } from './tools/log-test-result.js'
import { registerLogCommitTool } from './tools/log-commit.js'
import { registerCreateTodoTool } from './tools/create-todo.js'
import { registerCreatePbiTool } from './tools/create-pbi.js'
import { registerCreateStoryTool } from './tools/create-story.js'
import { registerCreateTaskTool } from './tools/create-task.js'
import { registerCreateSprintTool } from './tools/create-sprint.js'
import { registerUpdateSprintTool } from './tools/update-sprint.js'
import { registerAskUserQuestionTool } from './tools/ask-user-question.js'
import { registerGetQuestionAnswerTool } from './tools/get-question-answer.js'
import { registerListOpenQuestionsTool } from './tools/list-open-questions.js'
@ -22,42 +21,13 @@ import { registerWaitForJobTool } from './tools/wait-for-job.js'
import { registerUpdateJobStatusTool } from './tools/update-job-status.js'
import { registerVerifyTaskAgainstPlanTool } from './tools/verify-task-against-plan.js'
import { registerCleanupMyWorktreesTool } from './tools/cleanup-my-worktrees.js'
import { registerCheckQueueEmptyTool } from './tools/check-queue-empty.js'
import { registerSetPbiPrTool } from './tools/set-pbi-pr.js'
import { registerMarkPbiPrMergedTool } from './tools/mark-pbi-pr-merged.js'
import { registerGetIdeaContextTool } from './tools/get-idea-context.js'
import { registerUpdateIdeaGrillMdTool } from './tools/update-idea-grill-md.js'
import { registerUpdateIdeaPlanMdTool } from './tools/update-idea-plan-md.js'
import { registerUpdateIdeaPlanReviewedTool } from './tools/update-idea-plan-reviewed.js'
import { registerLogIdeaDecisionTool } from './tools/log-idea-decision.js'
import { registerGetWorkerSettingsTool } from './tools/get-worker-settings.js'
import { registerWorkerHeartbeatTool } from './tools/worker-heartbeat.js'
// PBI-50: SPRINT_IMPLEMENTATION-tools
import { registerVerifySprintTaskTool } from './tools/verify-sprint-task.js'
import { registerUpdateTaskExecutionTool } from './tools/update-task-execution.js'
import { registerJobHeartbeatTool } from './tools/job-heartbeat.js'
import { registerImplementNextStoryPrompt } from './prompts/implement-next-story.js'
import { getAuth } from './auth.js'
import { registerWorker } from './presence/worker.js'
import { startHeartbeat } from './presence/heartbeat.js'
import { registerShutdownHandlers } from './presence/shutdown.js'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
// Read version dynamically from package.json — voorheen hardcoded en
// veroorzaakte sync-issues bij deployment. Lees op module-load.
function readPkgVersion(): string {
try {
const here = dirname(fileURLToPath(import.meta.url))
const pkgPath = join(here, '..', 'package.json')
return (JSON.parse(readFileSync(pkgPath, 'utf8')) as { version?: string }).version ?? '0.0.0'
} catch {
return '0.0.0'
}
}
const VERSION = readPkgVersion()
const VERSION = '0.1.0'
async function main() {
const server = new McpServer(
@ -77,12 +47,10 @@ async function main() {
registerLogImplementationTool(server)
registerLogTestResultTool(server)
registerLogCommitTool(server)
registerCreateTodoTool(server)
registerCreatePbiTool(server)
registerCreateStoryTool(server)
registerCreateTaskTool(server)
// PBI-12: sprint lifecycle tools
registerCreateSprintTool(server)
registerUpdateSprintTool(server)
registerAskUserQuestionTool(server)
registerGetQuestionAnswerTool(server)
registerListOpenQuestionsTool(server)
@ -91,22 +59,6 @@ async function main() {
registerUpdateJobStatusTool(server)
registerVerifyTaskAgainstPlanTool(server)
registerCleanupMyWorktreesTool(server)
registerCheckQueueEmptyTool(server)
registerSetPbiPrTool(server)
registerMarkPbiPrMergedTool(server)
// M12: idee-job tools
registerGetIdeaContextTool(server)
registerUpdateIdeaGrillMdTool(server)
registerUpdateIdeaPlanMdTool(server)
registerUpdateIdeaPlanReviewedTool(server)
registerLogIdeaDecisionTool(server)
// M13: worker quota-gate tools
registerGetWorkerSettingsTool(server)
registerWorkerHeartbeatTool(server)
// PBI-50: SPRINT_IMPLEMENTATION-tools
registerVerifySprintTaskTool(server)
registerUpdateTaskExecutionTool(server)
registerJobHeartbeatTool(server)
registerImplementNextStoryPrompt(server)
// Presence bootstrap MUST run before server.connect — the stdio transport

View file

@ -1,97 +0,0 @@
// MCP-side port van scrum4me/lib/idea-plan-parser.ts (M12).
//
// Parser voor de plan_md die make-plan-job produceert: yaml-frontmatter
// (structuur) + markdown-body (vrije reasoning). Gebruikt door
// update_idea_plan_md voor server-side validatie vóór persistentie.
//
// LET OP: deze code is BEWUST een duplicaat van de Scrum4Me-parser om
// drift-detectie te krijgen via de vendor/scrum4me schema-watchdog. Houd
// het schema (zod-shape) in sync met scrum4me/lib/schemas/idea.ts.
import { parse as parseYaml, YAMLParseError } from 'yaml'
import { z } from 'zod'
const verifyRequiredEnum = z.enum(['ALIGNED', 'ALIGNED_OR_PARTIAL', 'ANY'])
const planTaskSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(4000).optional(),
implementation_plan: z.string().max(8000).optional(),
priority: z.number().int().min(1).max(4),
verify_required: verifyRequiredEnum.optional(),
verify_only: z.boolean().optional(),
})
const planStorySchema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(4000).optional(),
acceptance_criteria: z.string().max(4000).optional(),
priority: z.number().int().min(1).max(4),
tasks: z.array(planTaskSchema).min(1, 'Story moet minimaal 1 taak hebben'),
})
const planPbiSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(4000).optional(),
priority: z.number().int().min(1).max(4),
})
export const ideaPlanMdFrontmatterSchema = z.object({
pbi: planPbiSchema,
stories: z.array(planStorySchema).min(1, 'Plan moet minimaal 1 story bevatten'),
})
export type IdeaPlanFrontmatter = z.infer<typeof ideaPlanMdFrontmatterSchema>
export type PlanParseError = { line?: number; message: string }
export type PlanParseResult =
| { ok: true; plan: IdeaPlanFrontmatter; body: string }
| { ok: false; errors: PlanParseError[] }
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/
export function parsePlanMd(md: string): PlanParseResult {
const match = md.match(FRONTMATTER_RE)
if (!match) {
return {
ok: false,
errors: [
{
line: 1,
message: 'Plan ontbreekt yaml-frontmatter. Verwacht eerste regel: ---',
},
],
}
}
const [, frontmatterRaw, body] = match
let parsed: unknown
try {
parsed = parseYaml(frontmatterRaw)
} catch (err) {
if (err instanceof YAMLParseError) {
return {
ok: false,
errors: [{ line: err.linePos?.[0]?.line, message: err.message }],
}
}
return {
ok: false,
errors: [{ message: err instanceof Error ? err.message : String(err) }],
}
}
const validation = ideaPlanMdFrontmatterSchema.safeParse(parsed)
if (!validation.success) {
return {
ok: false,
errors: validation.error.issues.map((iss) => ({
message: `${iss.path.join('.') || '<root>'}: ${iss.message}`,
})),
}
}
return { ok: true, plan: validation.data, body: body.trimStart() }
}

View file

@ -1,207 +0,0 @@
// PBI-67: model + mode-selectie per ClaudeJob-kind.
//
// Sync met Scrum4Me/lib/job-config.ts — als je hier een veld aanpast,
// doe hetzelfde aan de webapp-kant. Bewust duplicate (geen gedeeld
// package) om de MCP-server eigenstandig te houden.
//
// Override-cascade (eerste match wint):
// 1. task.requires_opus === true → forceer Opus
// 2. job.requested_* (snapshot bij enqueue)
// 3. product.preferred_*
// 4. KIND_DEFAULTS hieronder
//
// CLI-flag-mapping (Claude CLI 2.1.x):
// - thinking_budget (number) → mapBudgetToEffort() → --effort {low,medium,high,xhigh,max}
// (de CLI heeft geen --thinking-budget flag — alleen --effort)
// - max_turns blijft audit-only: de CLI heeft géén --max-turns flag.
// De waarde wordt gesnapshot voor cost-attribution maar niet doorgegeven.
// - allowed_tools → --allowedTools (komma-gescheiden lijst)
export type ClaudeModel =
| 'claude-opus-4-7'
| 'claude-sonnet-4-6'
| 'claude-haiku-4-5-20251001'
export type PermissionMode = 'plan' | 'default' | 'acceptEdits' | 'bypassPermissions'
export type JobConfig = {
model: ClaudeModel
thinking_budget: number // 0 = uit
permission_mode: PermissionMode
max_turns: number | null // null = onbegrensd
allowed_tools: string[] | null // null = alle
}
export type JobInput = {
kind: string
requested_model?: string | null
requested_thinking_budget?: number | null
requested_permission_mode?: string | null
}
export type ProductInput = {
preferred_model?: string | null
thinking_budget_default?: number | null
preferred_permission_mode?: string | null
}
export type TaskInput = {
requires_opus?: boolean | null
}
// Tool-allowlists per kind. Bewust géén `wait_for_job`, `check_queue_empty`
// of `get_idea_context` — de runner (scrum4me-docker/bin/run-one-job.ts)
// claimt voor Claude. Vangrail tegen recursieve claims binnen één invocation.
const TASK_TOOLS = [
'Read', 'Edit', 'Write', 'Bash', 'Grep', 'Glob',
'mcp__scrum4me__get_claude_context',
'mcp__scrum4me__update_task_status',
'mcp__scrum4me__update_task_plan',
'mcp__scrum4me__log_implementation',
'mcp__scrum4me__log_test_result',
'mcp__scrum4me__log_commit',
'mcp__scrum4me__verify_task_against_plan',
'mcp__scrum4me__update_job_status',
'mcp__scrum4me__ask_user_question',
'mcp__scrum4me__get_question_answer',
'mcp__scrum4me__list_open_questions',
'mcp__scrum4me__cancel_question',
'mcp__scrum4me__worker_heartbeat',
]
const KIND_DEFAULTS: Record<string, JobConfig> = {
// Idea-kinds en PLAN_CHAT draaien in `acceptEdits` (niet `plan`):
// `plan`-mode wacht op human-approval na elke planning-fase, wat in een
// autonome runner-context betekent dat Claude geen `update_job_status`
// aanroept en de job na lease-expiry FAILED'd. De `allowed_tools`-lijst
// doet de echte sandboxing (geen Bash, geen Edit, alleen Read/Grep/etc).
IDEA_GRILL: {
model: 'claude-sonnet-4-6',
thinking_budget: 12000,
permission_mode: 'acceptEdits',
max_turns: 15,
allowed_tools: [
'Read', 'Grep', 'Glob', 'WebSearch', 'AskUserQuestion',
'mcp__scrum4me__update_idea_grill_md',
'mcp__scrum4me__log_idea_decision',
'mcp__scrum4me__update_job_status',
'mcp__scrum4me__ask_user_question',
'mcp__scrum4me__get_question_answer',
],
},
IDEA_MAKE_PLAN: {
model: 'claude-opus-4-7',
thinking_budget: 24000,
permission_mode: 'acceptEdits',
max_turns: 20,
allowed_tools: [
'Read', 'Grep', 'Glob', 'WebSearch', 'AskUserQuestion', 'Write',
'mcp__scrum4me__update_idea_plan_md',
'mcp__scrum4me__log_idea_decision',
'mcp__scrum4me__update_job_status',
],
},
IDEA_REVIEW_PLAN: {
model: 'claude-opus-4-7',
thinking_budget: 6000,
permission_mode: 'acceptEdits',
max_turns: 1,
allowed_tools: [
'Read', 'Write', 'Grep', 'Glob',
'mcp__scrum4me__update_idea_plan_reviewed',
'mcp__scrum4me__log_idea_decision',
'mcp__scrum4me__update_job_status',
'mcp__scrum4me__ask_user_question',
],
},
PLAN_CHAT: {
model: 'claude-sonnet-4-6',
thinking_budget: 6000,
permission_mode: 'acceptEdits',
max_turns: 5,
allowed_tools: [
'Read', 'Grep', 'AskUserQuestion',
'mcp__scrum4me__update_job_status',
],
},
TASK_IMPLEMENTATION: {
model: 'claude-sonnet-4-6',
thinking_budget: 6000,
permission_mode: 'bypassPermissions',
max_turns: 50,
allowed_tools: TASK_TOOLS,
},
SPRINT_IMPLEMENTATION: {
model: 'claude-sonnet-4-6',
thinking_budget: 6000,
permission_mode: 'bypassPermissions',
max_turns: null,
// Geen `mcp__scrum4me__job_heartbeat` — de runner verlengt de lease
// automatisch via setInterval (zie scrum4me-docker/bin/run-one-job.ts).
allowed_tools: [
...TASK_TOOLS,
'mcp__scrum4me__update_task_execution',
'mcp__scrum4me__verify_sprint_task',
],
},
}
const FALLBACK: JobConfig = {
model: 'claude-sonnet-4-6',
thinking_budget: 6000,
permission_mode: 'default',
max_turns: 50,
allowed_tools: null,
}
export function getKindDefault(kind: string): JobConfig {
return KIND_DEFAULTS[kind] ?? FALLBACK
}
// max_turns en allowed_tools blijven kind-default (geen product/task override
// in V1 — als de behoefte ontstaat, voeg analoge velden toe aan Product/Task).
export function resolveJobConfig(
job: JobInput,
product: ProductInput,
task?: TaskInput,
): JobConfig {
const base = getKindDefault(job.kind)
const model = (
task?.requires_opus
? 'claude-opus-4-7'
: job.requested_model ?? product.preferred_model ?? base.model
) as ClaudeModel
const thinking_budget =
job.requested_thinking_budget ?? product.thinking_budget_default ?? base.thinking_budget
const permission_mode = (job.requested_permission_mode ??
product.preferred_permission_mode ??
base.permission_mode) as PermissionMode
return {
model,
thinking_budget,
permission_mode,
max_turns: base.max_turns,
allowed_tools: base.allowed_tools,
}
}
// Map numeriek thinking_budget naar de Claude CLI 2.1.x --effort flag.
// Returns null als de flag niet meegegeven moet worden (budget = 0).
//
// Mapping (sync met Scrum4Me/lib/job-config.ts):
// 0 → null (geen --effort flag)
// 1..6000 → "medium"
// 6001..12000 → "high"
// 12001..24000→ "xhigh"
// >24000 → "max"
export function mapBudgetToEffort(budget: number): string | null {
if (budget <= 0) return null
if (budget <= 6000) return 'medium'
if (budget <= 12000) return 'high'
if (budget <= 24000) return 'xhigh'
return 'max'
}

View file

@ -1,49 +0,0 @@
// Loader voor embedded prompts per ClaudeJob-kind.
//
// De .md-bestanden in src/prompts/<kind>/ worden bewust meegebakken zodat
// elke runner ze kan inlezen zonder externe plugin-dependency. De runner
// (scrum4me-docker/bin/run-one-job.ts) leest de juiste prompt via
// getKindPromptText() en geeft die door als `claude -p`-prompt.
//
// Variabele-vervanging gebeurt door de runner zelf (bv. $PAYLOAD_PATH).
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { ClaudeJobKind } from '@prisma/client'
const cache: Partial<Record<ClaudeJobKind, string>> = {}
function loadPrompt(rel: string): string {
const here = dirname(fileURLToPath(import.meta.url))
// src/lib/kind-prompts.ts → src/lib → src → src/prompts/<rel>
const path = join(here, '..', 'prompts', rel)
return readFileSync(path, 'utf8')
}
const KIND_TO_PROMPT_PATH: Partial<Record<ClaudeJobKind, string>> = {
IDEA_GRILL: 'idea/grill.md',
IDEA_MAKE_PLAN: 'idea/make-plan.md',
IDEA_REVIEW_PLAN: 'idea/review-plan.md',
TASK_IMPLEMENTATION: 'task/implementation.md',
SPRINT_IMPLEMENTATION: 'sprint/implementation.md',
PLAN_CHAT: 'plan-chat/chat.md',
}
export function getKindPromptText(kind: ClaudeJobKind): string {
if (cache[kind]) return cache[kind]!
const rel = KIND_TO_PROMPT_PATH[kind]
if (!rel) return ''
const text = loadPrompt(rel)
cache[kind] = text
return text
}
// Back-compat re-export. wait-for-job.ts roept getIdeaPromptText aan voor
// de drie idea-kinds; behouden zodat we de bestaande call-site niet hoeven
// te wijzigen tot een aparte cleanup-pass.
export function getIdeaPromptText(kind: ClaudeJobKind): string {
if (kind !== 'IDEA_GRILL' && kind !== 'IDEA_MAKE_PLAN' && kind !== 'IDEA_REVIEW_PLAN') return ''
return getKindPromptText(kind)
}

View file

@ -1,22 +0,0 @@
export type PushPayload = { title: string; body: string; url: string; tag?: string };
export async function triggerPush(userId: string, payload: PushPayload): Promise<void> {
const url = process.env.INTERNAL_PUSH_URL;
const secret = process.env.INTERNAL_PUSH_SECRET;
if (!url || !secret) return; // feature-gated
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${secret}` },
body: JSON.stringify({ userId, payload }),
signal: controller.signal,
});
if (!res.ok) console.warn('[push-trigger] non-2xx', res.status);
} catch (err) {
console.error('[push-trigger]', err);
} finally {
clearTimeout(timeout);
}
}

View file

@ -1,11 +1,9 @@
// **HOUD SYNC** met Scrum4Me/lib/tasks-status-update.ts.
// Beide repos delen dezelfde DB; deze helper moet bit-voor-bit gelijke
// statusovergangen produceren als de Scrum4Me-versie. Bij wijziging hier
// ook in de Scrum4Me-repo updaten en omgekeerd.
import type { Prisma, TaskStatus, StoryStatus, PbiStatus, SprintStatus } from '@prisma/client'
import type { Prisma, TaskStatus } from '@prisma/client'
import { prisma } from '../prisma.js'
export interface PropagationResult {
export type StoryStatusChange = 'promoted' | 'demoted' | null
export interface UpdateTaskStatusResult {
task: {
id: string
title: string
@ -13,38 +11,21 @@ export interface PropagationResult {
story_id: string
implementation_plan: string | null
}
storyStatusChange: StoryStatusChange
storyId: string
storyChanged: boolean
pbiChanged: boolean
sprintChanged: boolean
sprintRunChanged: boolean
}
// Real-time status-propagatie: bij elke task-statuswijziging wordt de keten
// Task → Story → PBI → Sprint → SprintRun herevalueerd binnen één transactie.
//
// Regels:
// Story: ANY task FAILED → FAILED, ELSE ALL DONE → DONE,
// ELSE IN_SPRINT (mits story.sprint_id != null), anders OPEN
// PBI: ANY story FAILED → FAILED, ELSE ALL DONE → DONE, ELSE READY
// (BLOCKED is handmatig en wordt niet overschreven door deze helper)
// Sprint: ANY PBI van een story-in-sprint FAILED → FAILED,
// ELSE ALL PBIs van die stories DONE → COMPLETED,
// ELSE ACTIVE
// SprintRun: Sprint→FAILED → SprintRun=FAILED + cancel openstaand werk +
// zet failed_task_id; Sprint→COMPLETED → SprintRun=DONE; anders
// blijft SprintRun ongewijzigd.
export async function propagateStatusUpwards(
// Update task.status atomically and auto-promote/demote the parent story:
// - All sibling tasks DONE → story.status = DONE
// - Story was DONE and a task moves out of DONE → story.status = IN_SPRINT
// Demote target is IN_SPRINT (not OPEN): OPEN means "back in product backlog",
// which is a sprint-management action, not a status side-effect.
export async function updateTaskStatusWithStoryPromotion(
taskId: string,
newStatus: TaskStatus,
client?: Prisma.TransactionClient,
// PBI-50: optionele expliciete sprint_run_id voor SPRINT_IMPLEMENTATION
// (waar geen ClaudeJob.task_id-koppeling bestaat). Wanneer afwezig valt
// de helper terug op de lookup via ClaudeJob.task_id, met als laatste
// fallback Story → Sprint → SprintRun.findFirst({ status: active }).
sprintRunId?: string,
): Promise<PropagationResult> {
const run = async (tx: Prisma.TransactionClient): Promise<PropagationResult> => {
): Promise<UpdateTaskStatusResult> {
const run = async (tx: Prisma.TransactionClient): Promise<UpdateTaskStatusResult> => {
const task = await tx.task.update({
where: { id: taskId },
data: { status: newStatus },
@ -57,232 +38,35 @@ export async function propagateStatusUpwards(
},
})
// Story herevalueren
const siblings = await tx.task.findMany({
where: { story_id: task.story_id },
select: { status: true },
})
const anyTaskFailed = siblings.some((s) => s.status === 'FAILED')
const allTasksDone =
siblings.length > 0 && siblings.every((s) => s.status === 'DONE')
const allDone = siblings.every((s) => s.status === 'DONE')
const story = await tx.story.findUniqueOrThrow({
where: { id: task.story_id },
select: { id: true, status: true, pbi_id: true, sprint_id: true },
select: { status: true },
})
const defaultActive: StoryStatus = story.sprint_id ? 'IN_SPRINT' : 'OPEN'
let nextStoryStatus: StoryStatus
if (anyTaskFailed) nextStoryStatus = 'FAILED'
else if (allTasksDone) nextStoryStatus = 'DONE'
else nextStoryStatus = defaultActive
let storyChanged = false
if (nextStoryStatus !== story.status) {
let storyStatusChange: StoryStatusChange = null
if (newStatus === 'DONE' && allDone && story.status !== 'DONE') {
await tx.story.update({
where: { id: story.id },
data: { status: nextStoryStatus },
where: { id: task.story_id },
data: { status: 'DONE' },
})
storyChanged = true
storyStatusChange = 'promoted'
} else if (newStatus !== 'DONE' && story.status === 'DONE') {
await tx.story.update({
where: { id: task.story_id },
data: { status: 'IN_SPRINT' },
})
storyStatusChange = 'demoted'
}
// PBI herevalueren — BLOCKED met rust laten
const pbi = await tx.pbi.findUniqueOrThrow({
where: { id: story.pbi_id },
select: { id: true, status: true },
})
let pbiChanged = false
if (pbi.status !== 'BLOCKED') {
const pbiStories = await tx.story.findMany({
where: { pbi_id: pbi.id },
select: { status: true },
})
const anyStoryFailed = pbiStories.some((s) => s.status === 'FAILED')
const allStoriesDone =
pbiStories.length > 0 && pbiStories.every((s) => s.status === 'DONE')
let nextPbiStatus: PbiStatus
if (anyStoryFailed) nextPbiStatus = 'FAILED'
else if (allStoriesDone) nextPbiStatus = 'DONE'
else nextPbiStatus = 'READY'
if (nextPbiStatus !== pbi.status) {
await tx.pbi.update({
where: { id: pbi.id },
data: { status: nextPbiStatus },
})
pbiChanged = true
}
}
// Sprint herevalueren — alleen als deze story aan een sprint hangt
let sprintChanged = false
let nextSprintStatus: SprintStatus | null = null
if (story.sprint_id) {
const sprint = await tx.sprint.findUniqueOrThrow({
where: { id: story.sprint_id },
select: { id: true, status: true },
})
const sprintPbiRows = await tx.story.findMany({
where: { sprint_id: sprint.id },
select: { pbi_id: true },
distinct: ['pbi_id'],
})
const sprintPbis = await tx.pbi.findMany({
where: { id: { in: sprintPbiRows.map((s) => s.pbi_id) } },
select: { status: true },
})
const anyPbiFailed = sprintPbis.some((p) => p.status === 'FAILED')
const allPbisDone =
sprintPbis.length > 0 && sprintPbis.every((p) => p.status === 'DONE')
let nextStatus: SprintStatus
if (anyPbiFailed) nextStatus = 'FAILED'
else if (allPbisDone) nextStatus = 'CLOSED'
else nextStatus = 'OPEN'
if (nextStatus !== sprint.status) {
await tx.sprint.update({
where: { id: sprint.id },
data: {
status: nextStatus,
...(nextStatus === 'CLOSED' ? { completed_at: new Date() } : {}),
},
})
sprintChanged = true
nextSprintStatus = nextStatus
}
}
// SprintRun herevalueren. Resolve sprint_run_id in volgorde:
// 1. Expliciete sprintRunId-arg (PBI-50: SPRINT_IMPLEMENTATION-pad).
// 2. ClaudeJob.task_id-lookup (PER_TASK-flow).
// 3. Story → Sprint → SprintRun.findFirst({ status: active }) (geen
// task-job, bv. handmatige task-statuswijziging via UI).
let sprintRunChanged = false
if (nextSprintStatus === 'FAILED' || nextSprintStatus === 'CLOSED') {
let resolvedRunId: string | null = sprintRunId ?? null
let cancelExceptJobId: string | null = null
if (!resolvedRunId) {
const job = await tx.claudeJob.findFirst({
where: { task_id: taskId, sprint_run_id: { not: null } },
orderBy: { created_at: 'desc' },
select: { id: true, sprint_run_id: true },
})
if (job?.sprint_run_id) {
resolvedRunId = job.sprint_run_id
cancelExceptJobId = job.id
}
}
if (!resolvedRunId && story.sprint_id) {
const activeRun = await tx.sprintRun.findFirst({
where: {
sprint_id: story.sprint_id,
status: { in: ['QUEUED', 'RUNNING', 'PAUSED'] },
},
orderBy: { created_at: 'desc' },
select: { id: true },
})
if (activeRun) resolvedRunId = activeRun.id
}
if (resolvedRunId) {
const sprintRun = await tx.sprintRun.findUnique({
where: { id: resolvedRunId },
select: { id: true, status: true },
})
if (
sprintRun &&
(sprintRun.status === 'QUEUED' ||
sprintRun.status === 'RUNNING' ||
sprintRun.status === 'PAUSED')
) {
if (nextSprintStatus === 'FAILED') {
await tx.sprintRun.update({
where: { id: sprintRun.id },
data: {
status: 'FAILED',
finished_at: new Date(),
failed_task_id: taskId,
},
})
// Cancel sibling-jobs binnen dezelfde SprintRun behalve de
// huidige task-job (als die er is). Voor SPRINT_IMPLEMENTATION
// is cancelExceptJobId null en hebben we geen siblings om te
// cancellen — de SPRINT-job zelf blijft actief en de worker
// detecteert dit via job_heartbeat.
await tx.claudeJob.updateMany({
where: {
sprint_run_id: sprintRun.id,
status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] },
...(cancelExceptJobId ? { id: { not: cancelExceptJobId } } : {}),
},
data: {
status: 'CANCELLED',
finished_at: new Date(),
error: `Cancelled: task ${taskId} failed in same sprint run`,
},
})
sprintRunChanged = true
} else {
// COMPLETED
await tx.sprintRun.update({
where: { id: sprintRun.id },
data: { status: 'DONE', finished_at: new Date() },
})
sprintRunChanged = true
}
}
}
}
return {
task,
storyId: task.story_id,
storyChanged,
pbiChanged,
sprintChanged,
sprintRunChanged,
}
return { task, storyStatusChange, storyId: task.story_id }
}
if (client) return run(client)
return prisma.$transaction(run)
}
// ─── Backwards-compat wrapper ────────────────────────────────────────────────
// Bestaande tools (update-task-status, log-implementation, etc.) verwachten
// de oude { task, storyStatusChange, storyId } shape. We mappen storyChanged
// op promoted/demoted via een eenvoudige heuristiek op nieuwe TaskStatus.
export type StoryStatusChange = 'promoted' | 'demoted' | null
export interface UpdateTaskStatusResult {
task: PropagationResult['task']
storyStatusChange: StoryStatusChange
storyId: string
sprintRunChanged: boolean
}
export async function updateTaskStatusWithStoryPromotion(
taskId: string,
newStatus: TaskStatus,
client?: Prisma.TransactionClient,
sprintRunId?: string,
): Promise<UpdateTaskStatusResult> {
const result = await propagateStatusUpwards(taskId, newStatus, client, sprintRunId)
let storyStatusChange: StoryStatusChange = null
if (result.storyChanged) {
storyStatusChange = newStatus === 'DONE' ? 'promoted' : 'demoted'
}
return {
task: result.task,
storyStatusChange,
storyId: result.storyId,
sprintRunChanged: result.sprintRunChanged,
}
}

View file

@ -26,7 +26,7 @@ export function startHeartbeat(opts: {
} catch {
// non-fatal — next tick retries
}
}, opts.intervalMs ?? 10_000)
}, opts.intervalMs ?? 5_000)
return { stop: () => clearInterval(timer) }
}

View file

@ -1,106 +0,0 @@
# Grill-prompt voor IDEA_GRILL-jobs
> Deze prompt wordt door `scrum4me-docker/bin/run-one-job.ts` als
> `claude -p`-input meegegeven voor één geclaimde `IDEA_GRILL`-job. Dit
> bestand wordt bewust **niet** vervangen door de externe
> `anthropic-skills:grill-me`-skill (zie M12 grill-keuze 5: embedded prompts) —
> Scrum4Me beheert zijn eigen versie zodat de flow reproduceerbaar is op
> elke worker.
---
Je bent een **grill-agent** voor een Scrum4Me-idee. De runner heeft de job
al voor je geclaimd; jouw eerste actie is altijd:
```
Read $PAYLOAD_PATH
```
Dat JSON-bestand bevat de volledige context die je nodig hebt:
- `job_id`: nodig voor `update_job_status` aan het einde
- `idea`: het volledige idee-record incl. `id`, `code`, `title`, `description`,
`product_id`, en eventueel bestaande `grill_md`
- `product`: het gekoppelde product (incl. `repo_url` en `definition_of_done`)
- `primary_worktree_path`: lokale repo om te lezen (je `cwd` zit daar al)
## Doel
Het idee zó concretiseren dat de **make-plan**-fase er een implementeerbaar
PBI van kan maken. Eindresultaat is een markdown-document dat je via
`mcp__scrum4me__update_idea_grill_md` opslaat.
## Werkwijze (loop, één vraag per cyclus)
1. **Lees `$PAYLOAD_PATH`** met de `Read`-tool. Bewaar `idea.id`, `idea.code`,
`idea.title`, `idea.grill_md` (mag null zijn), `product.id`, en `job_id`
die heb je nodig in alle MCP-tool-calls hieronder.
2. Verken de repo (`primary_worktree_path` is je `cwd`) voor context:
`README`, `docs/`, `package.json`, relevante source. `Read`/`Grep`/`Glob`.
3. Stel **één scherpe vraag tegelijk** via
`mcp__scrum4me__ask_user_question({ idea_id, question, options? })`. Wacht
op het antwoord (`mcp__scrum4me__get_question_answer` of `wait_seconds`).
4. Verwerk het antwoord: log belangrijke beslissingen via
`mcp__scrum4me__log_idea_decision({ idea_id, type: 'DECISION'|'NOTE',
content })`.
5. Herhaal tot je voldoende hebt voor een PBI (zie stop-conditie).
6. Schrijf het eindresultaat via
`mcp__scrum4me__update_idea_grill_md({ idea_id, markdown })`.
7. Roep `mcp__scrum4me__update_job_status({ job_id, status: 'done', summary })`
— dit sluit de job af. **Verplicht**, ook als de gebruiker afbreekt.
## Stop-conditie
Je hebt genoeg wanneer je markdown bevat:
- **Titel + scope** (13 zinnen)
- **Minimaal 3 acceptatiepunten** (gedrag dat zichtbaar moet werken)
- **Minimaal 1 risico/onbekende** (technisch, scope, afhankelijkheden)
- **Open eindjes** (wat opzettelijk **niet** in v1 zit)
Stop óók als de gebruiker expliciet zegt "klaar" / "genoeg" / "ga door".
## Output-format (strikt)
```markdown
# Idee — <korte titel>
## Scope
## Acceptatie
- AC 1
- AC 2
- AC 3
## Risico's & onbekenden
- Risico 1
- Onbekende 2
## Open eindjes (niet in v1)
- …
```
## Vraag-richtlijnen
- **Scherp & specifiek**, geen open "wat denk je ervan?".
- Bij twijfel: bied **multi-choice** via `options: ["A", "B", "C"]`.
- Stel **één vraag per cyclus** — niet meerdere geneste.
- Vermijd vragen waarvan het antwoord uit de repo te lezen is — lees zelf.
- Geen meta-vragen ("zal ik nog meer vragen?"). Beslis zelf wanneer je stopt.
## Foutgevallen
- Vraag verloopt (24h): roep `update_job_status('failed', error: 'question expired')`.
- Repo niet leesbaar: roep `update_job_status('failed', error: 'repo access')`.
- Gebruiker annuleert via UI: job wordt door server op CANCELLED gezet; je krijgt geen verdere antwoorden — sluit netjes af.
## Voorbeeld-vraag
```
ask_user_question({
idea_id,
question: "Moet 'Plant-watering reminder' alleen lokale notifications doen, of ook web-push?",
options: ["Alleen lokaal (eenvoud)", "Web-push (multi-device)", "Beide"],
})
```

View file

@ -1,179 +0,0 @@
# Make-Plan-prompt voor IDEA_MAKE_PLAN-jobs
> Deze prompt wordt door `scrum4me-docker/bin/run-one-job.ts` als
> `claude -p`-input meegegeven voor één geclaimde `IDEA_MAKE_PLAN`-job.
> Single-pass, **stel geen vragen** (zie M12 grill-keuze 8). Twijfels →
> terug naar grill via UI.
---
Je bent een **planning-agent** voor een Scrum4Me-idee. De runner heeft de
job al voor je geclaimd; jouw eerste actie is altijd:
```
Read $PAYLOAD_PATH
```
Dat JSON-bestand bevat de volledige context die je nodig hebt:
- `job_id`: nodig voor `update_job_status` aan het einde
- `idea.id`, `idea.code`, `idea.title`, `idea.description`
- `idea.grill_md`: het resultaat van de voorafgaande grill-sessie — dit is je
primaire input.
- `idea.plan_md`: bij re-plan bevat dit het vorige plan; gebruik als referentie.
- `product`: gekoppeld product met `repo_url`, `definition_of_done`,
bestaande architectuur in repo.
- `primary_worktree_path`: lokale repo (je `cwd` zit daar al).
## Doel
Eén `plan_md` produceren die je via `mcp__scrum4me__update_idea_plan_md`
opslaat. Dit document wordt later **deterministisch** geparseerd door de
server-side `parsePlanMd` (zie `lib/idea-plan-parser.ts`) en omgezet in
PBI + stories + taken via `materializeIdeaPlanAction`.
## Werkwijze (single-pass)
1. **Lees `$PAYLOAD_PATH`** met de `Read`-tool. Bewaar `idea.id`, `idea.code`,
`idea.grill_md`, `idea.plan_md` (mag null zijn), `product.id`, en `job_id`
die heb je nodig in alle MCP-tool-calls hieronder.
2. Lees `idea.grill_md` volledig.
3. Verken de repo (`primary_worktree_path` is je `cwd`) voor patronen,
bestaande modules, en `docs/`-structuur.
4. **Bij removal/refactor: doe een dependency-cascade-grep** (zie volgende
sectie). Voeg per geraakte file een taak toe vóór de schema/code-edit zelf.
5. Bouw het plan op in de **strikte format** hieronder.
6. Roep `mcp__scrum4me__update_idea_plan_md({ idea_id, markdown })`.
7. Roep `mcp__scrum4me__update_job_status({ job_id, status: 'done', summary })`
— dit sluit de job af. **Verplicht**, ook bij parse-failure.
## Dependency-cascade-grep (verplicht bij removal/refactor)
Wanneer het idee een **bestaand symbool, model, route of component
verwijdert of hernoemt**, MOET je éérst de consumers in kaart brengen voordat
je het plan vaststelt. Anders breekt `next build` op type-errors die `lint`
en `vitest run` niet pakken (zie hieronder waarom).
**Concreet:**
- Verwijder je een Prisma-model `Foo`?
```bash
grep -rn "prisma\.foo\b\|prisma\.foos\b" actions/ app/ components/ lib/ \
--include="*.ts" --include="*.tsx"
```
Voeg per geraakt bestand één of meer taken toe ("schoon `actions/foos.ts`
op", "verwijder `app/(app)/foos/`-route", "haal Foo-tegel uit
`app/page.tsx`-feature-grid", etc.) **vóór** de schema-edit-taak.
- Verwijder je een component / utility / type? Idem: grep op de
bestandspaden en exports en plan per consumer een taak.
- Hernoem je een model/route/component? Plan per geraakt bestand een edit-taak.
- Wijzig je een `prisma.x.create`-veld (verplicht ↔ optioneel)? Grep op
`prisma.x.create` en `prisma.x.update` voor type-mismatches.
- Voeg óók een **eind-taak** toe: `npm run typecheck` (= `tsc --noEmit`)
als sanity-check, los van `lint && test && build`. Type-errors verschijnen
daar het eerst en zijn 10× sneller dan een full `next build`.
**Waarom zo strikt?** `eslint` doet geen diepe type-check. `vitest` met
esbuild-transpile slaat type-errors over. `next build` is de eerste step die
álles type-checkt — en die zit aan het einde van de pijp. Een gemist
consumer-bestand wordt pas zichtbaar bij verify, niet bij implementation.
## STEL GEEN VRAGEN
`mcp__scrum4me__ask_user_question` is in deze fase **verboden**. Als je
informatie mist die je nodig hebt om het plan compleet te maken, schrijf je
plan met je beste aanname en documenteer je in de **Body** (zie hieronder)
welke aannames je hebt gemaakt. De gebruiker beoordeelt het plan in `PLAN_READY`
en kan dan handmatig editen of een re-grill triggeren.
## Output-format (strikt — frontmatter wordt server-side geparseerd)
````markdown
---
pbi:
title: "Korte PBI-titel (≤200 chars)"
description: |
1-3 zinnen die de PBI samenvatten.
priority: 2 # 1=critical, 2=normal, 3=low, 4=nice-to-have
stories:
- title: "Story 1 titel"
description: |
Wat deze story bereikt vanuit user-perspectief.
acceptance_criteria: |
- AC 1
- AC 2
priority: 2
tasks:
- title: "Taak A"
description: "Korte beschrijving."
implementation_plan: |
1. Bestand X aanpassen — concrete steps
2. Test toevoegen Y
3. Verifieer Z
priority: 2
verify_required: ALIGNED_OR_PARTIAL # ALIGNED | ALIGNED_OR_PARTIAL | ANY
verify_only: false # true voor pure verify-passes
- title: "Taak B"
priority: 2
implementation_plan: |
...
- title: "Story 2 titel"
priority: 2
tasks:
- title: "..."
priority: 2
---
# Overwegingen
(Vrije body — niet geparsed door materialize, wordt opgeslagen in
IdeaLog{PLAN_RESULT}.metadata.body voor latere referentie.)
Beschrijf:
- Waarom deze opdeling in stories/taken
- Welke aannames je hebt gemaakt (indien grill onvolledig was)
- Architectuur-keuzes & verwijzingen naar bestaande modules in repo
# Alternatieven
- Optie X (verworpen omdat …)
- Optie Y (overwogen voor v2 …)
# Beslissingen
- ...
# Aannames (indien van toepassing)
- ...
````
## Validatie-regels die de parser afdwingt
- `pbi.title`: 1200 chars, **verplicht**.
- `pbi.priority`, `story.priority`, `task.priority`: integer 14.
- Minimaal 1 story; per story minimaal 1 taak.
- `implementation_plan`: max 8000 chars.
- `verify_required`: enum exact `ALIGNED` | `ALIGNED_OR_PARTIAL` | `ANY`.
- Alle string-velden trimmen, geen lege strings.
Een parse-fout zet het idee op `PLAN_FAILED`. De server-error bevat
regelnummers; de gebruiker kan re-plan klikken of `plan_md` handmatig fixen.
## Schaal-richtlijnen (geen harde limieten)
- 1 PBI per idee.
- 26 stories per PBI (te veel = te grote PBI; splits dan in idee-niveau).
- 25 taken per story.
- Eén taak ≈ 30 min paar uur werk; **`implementation_plan` is concreet**
(bestandsnamen, commando's, regels code), niet abstract.
## Voorbeelden van goede vs slechte taken
**Slecht**: "Maak de feature werkend"
**Goed**: "Voeg `actions/ideas.ts:createIdeaAction(input)` toe — auth +
demo-403 + zod-parse + nextIdeaCode + prisma.idea.create + revalidatePath"

View file

@ -1,210 +0,0 @@
# Review-Plan-prompt voor IDEA_REVIEW_PLAN-jobs
> Deze prompt wordt door `wait_for_job` meegestuurd in de payload van een
> `IDEA_REVIEW_PLAN`-job. Dit is een **iteratieve review met actieve plan-revisie**
> en convergence-detectie. Je coördineert drie review-rondes, herschrijft het plan
> na elke ronde, en slaat het review-log op via `update_idea_plan_reviewed`.
---
Je bent een **plan-review-orchestrator** voor Scrum4Me-idee `{idea_code}`.
Je context (meegegeven in `wait_for_job`-payload):
- `idea.plan_md`: het te reviewen plan-document (YAML frontmatter + body)
- `idea.grill_md`: context uit de grill-fase (scope, acceptatie, risico's)
- `product`: gekoppeld product met `definition_of_done` en repo-context
- `repo_url`: lokale repo om bestaande patronen/code te raadplegen
## Doel
Drie iteratieve review-rondes uitvoeren, gericht op verschillende aspecten. Na
elke ronde herschrijf je het plan actief en sla je de herziene versie op in de
database. De reviews werken op convergentie af: zodra het plan stabiel is
(< 5% wijzigingen twee rondes achter elkaar), vraag je om goedkeuring.
**Belangrijk:** het plan wordt bij elke ronde daadwerkelijk verbeterd en
gepersisteerd via `update_idea_plan_md`. Dit is geen passieve review — je
coördineert een actief verbeterproces.
## Werkwijze
### Setup (voor ronde 1)
1. Lees `idea.plan_md` volledig — dit is de startversie van het plan.
2. Lees `idea.grill_md` voor scope/acceptatiecriteria-context.
3. **Laad codex** (verplicht, niet optioneel):
- Glob + Read alle `docs/patterns/**/*.md` → architectuurpatronen
- Glob + Read alle `docs/architecture/**/*.md` → systeemdesign
- Read `CLAUDE.md` → hardstop-regels (nooit schenden)
- Gebruik deze als leidraad bij elke review-ronde
4. Initialiseer `review_log`:
```json
{ "plan_file": "{idea_code}", "created_at": "<now>",
"rounds": [], "approval": { "status": "pending" } }
```
### Per Review-Ronde
**Ronde 1 — Structuur & Syntax (Haiku-perspectief: snel en scherp)**
- Rol: structuur-reviewer — focus op correctheid, niet op inhoud
- Controleer: YAML parseable, alle verplichte velden aanwezig, geen lege strings,
priority-waarden valid (14), markdown-structuur intact
- Herschrijf plan_md: corrigeer structuurfouten en formatting
- *Opmerking multi-model:* directe Haiku API-call is momenteel niet beschikbaar
via job-config; voer deze rol zelf uit met een compacte, syntax-gerichte blik
**Ronde 2 — Logica & Patronen (Sonnet-perspectief: diep en patroon-bewust)**
- Rol: architectuur-reviewer — focus op logica, volledigheid en patroonconformiteit
- Controleer: stories volgen uit grill-criteria, tasks zijn concreet
(bestandsnamen, commando's), patterns uit `docs/patterns/` worden gevolgd,
`verify_required` coherent, dependency-cascades geadresseerd
- Herschrijf plan_md: vul gaten aan, maak tasks specifieker, voeg missende stappen toe
**Ronde 3 — Risico & Edge Cases (Opus-perspectief: kritisch en breed)**
- Rol: risico-reviewer — focus op wat mis kan gaan
- Controleer: grote taken gesplitst, refactors hebben undo-strategie,
schema-changes hebben migratie-taken, type-checking expliciet, concurrency
geadresseerd, error-handling per actie, feature-flags voor grote changes
- Herschrijf plan_md: voeg risico-mitigatie toe, split te grote taken
### Plan Revision (na elke ronde — verplicht)
Na het uitvoeren van de review-criteria:
1. Sla de huidige versie op als `plan_before` in `review_log.rounds[N]`.
2. Herschrijf `plan_md` — integreer de gevonden verbeteringen.
3. Bereken `diff_pct = changed_lines / total_lines * 100`.
4. Sla de herziene versie op als `plan_after` in `review_log.rounds[N]`.
5. **Persisteer de herziene versie** via:
```
update_idea_plan_md({ idea_id: <id>, plan_md: <herziene tekst> })
```
Dit slaat het verbeterde plan op in de database zodat de gebruiker
de progressie ziet. Sla dit stap niet over — ook al zijn er weinig
wijzigingen.
### Convergence Detection
Na elke ronde (m.u.v. ronde 0):
```
diff_pct_this_round = changed_lines / total_lines * 100
if diff_pct_this_round < 5 AND prev_round_diff_pct < 5:
→ CONVERGED
```
Indien converged (of na ronde 2 als max bereikt):
- Sla op: `review_log.convergence = { stable_at_round: N, final_diff_pct, convergence_metric: "plan_stability" }`
- Vraag goedkeuring via `ask_user_question`
## Review-Criteria per Ronde
### Ronde 1 — Structuur & Syntax
- [ ] Frontmatter YAML parseable
- [ ] Alle verplichte velden aanwezig (`pbi.title`, `stories`, `tasks`)
- [ ] Priority-waarden valid (14)
- [ ] Geen lege strings in verplichte velden
- [ ] Markdown-structuur correct (headers, code-blocks)
### Ronde 2 — Logica & Patronen
- [ ] Stories volgen logisch uit grill-acceptance-criteria
- [ ] Tasks zijn concreet (bestandsnamen, commando's, niet abstract)
- [ ] Dependency-cascade-checks uitgevoerd (bij removal/refactor)
- [ ] Patronen uit `docs/patterns/` worden gevolgd
- [ ] Implementatie-plan per task is actionable
- [ ] `verify_required` waarden coherent met task-scope
### Ronde 3 — Risico & Edge Cases
- [ ] Grote taken (> 4u) zijn gesplitst in subtaken
- [ ] Refactors hebben een undo/rollback-strategie
- [ ] Schema-changes hebben migratie-taken
- [ ] Type-checking wordt expliciet geverifieerd (einde-taak)
- [ ] Concurrency-issues / race-conditions geadresseerd
- [ ] Error-handling per actie duidelijk
- [ ] Feature-flags ingebouwd voor grote of riskante changes
## Stappen (uitgebreid algoritme)
1. **Init**
- Lees plan_md + grill_md.
- Laad codex (docs/patterns, docs/architecture, CLAUDE.md).
- Initialiseer `review_log`.
2. **Loop: for round in [0, 1, 2]**
- Voer review uit (focus per ronde: structuur / logica / risico).
- Sla `plan_before` op.
- Herschrijf plan_md op basis van bevindingen.
- Roep `update_idea_plan_md` aan met de herziene tekst.
- Sla `plan_after` + `issues` + `score` + `diff_pct` op in review_log.
- Check convergence (na ronde 1+).
- Break indien converged.
3. **Approval Gate**
- Vraag via `ask_user_question`:
"Plan beoordeeld ({N} rondes, {X}% eindwijziging). Goedkeuren?"
- Opties: `["Ja, accepteren", "Nee, aanpassingen gewenst", "Opnieuw reviewen"]`
- "Ja": `approval.status = 'approved'` → ga door naar Save & Close.
- "Nee": `approval.status = 'rejected'` → sluit af (user kan handmatig editen).
- "Opnieuw": max 2 extra rondes (rondes 34), dan dwingend approval vragen.
4. **Save & Close**
- Call `update_idea_plan_reviewed({ idea_id, review_log, approval_status })`.
- Call `update_job_status({ job_id, status: 'done', summary: review_log.summary })`.
## Output-format review_log (strikt JSON)
```json
{
"plan_file": "IDEA-016",
"created_at": "ISO8601",
"rounds": [
{
"round": 0,
"model": "claude-opus-4-7",
"role": "Structure Review",
"focus": "YAML parsing, format, syntax",
"plan_before": "<origineel plan_md>",
"plan_after": "<herzien plan_md na ronde>",
"issues": [
{
"category": "structure|logic|risk|pattern",
"severity": "error|warning|info",
"suggestion": "wat te fixen"
}
],
"score": 75,
"plan_diff_lines": 12,
"converged": false,
"timestamp": "ISO8601"
}
],
"convergence": {
"stable_at_round": 2,
"final_diff_pct": 2.1,
"convergence_metric": "plan_stability"
},
"approval": {
"status": "pending|approved|rejected",
"timestamp": "ISO8601"
},
"summary": "12 zinnen samenvatting: X rondes, Y% wijziging, status"
}
```
## Foutgevallen
- **Plan parse-fout**: `update_job_status('failed', error: 'plan_parse_failed')` — stop.
- **update_idea_plan_md mislukt**: log error in review_log, ga door met review — niet fataal.
- **Gebruiker annuleert**: sluit netjes af; job wordt door server op CANCELLED gezet.
- **Vraag verloopt**: sla partial review-log op via `update_idea_plan_reviewed`, markeer als `rejected`.
## Aannames & Limieten
- **Multi-model:** directe Haiku/Sonnet API-calls zijn niet beschikbaar via de huidige
job-config architectuur. Alle rondes draaien op het geconfigureerde Opus model.
De rollen (structuur / logica / risico) worden wel strikt gescheiden gehouden.
Toekomst: directe model-switching via Anthropic API.
- Plan bevat geen versleutelde data (review-log opgeslagen als JSON in DB).
- Repo is leesbaar; geen network-fouts verwacht.
- Max 2 extra review-rondes buiten de initiële 3 (max 5 rondes totaal).
- Per ronde: max 10 issues gelogd (overige → samenvatting in `summary`).

View file

@ -1,16 +0,0 @@
# PLAN_CHAT-prompt (placeholder)
> Deze prompt is een placeholder. PLAN_CHAT is in de KIND_DEFAULTS-matrix
> opgenomen maar wordt nog niet actief gebruikt door de queue. Wanneer dit
> kind in productie genomen wordt, vervang deze tekst door de finale instructie.
---
Je bent gestart voor een `PLAN_CHAT`-job. De payload staat in:
```
$PAYLOAD_PATH
```
Lees de payload en doe wat erin staat. Sluit af met
`mcp__scrum4me__update_job_status({ job_id, status: 'done' })`.

View file

@ -1,77 +0,0 @@
# SPRINT_IMPLEMENTATION-prompt
> Deze prompt wordt door `scrum4me-docker/bin/run-one-job.ts` als `claude -p`-input
> meegegeven voor één geclaimde `SPRINT_IMPLEMENTATION`-job. Eén job = de hele
> sprint-run sequentieel afhandelen.
---
Je bent gestart voor één geclaimde `SPRINT_IMPLEMENTATION`-job. De payload bevat
een **frozen scope-snapshot** met alle te verwerken taken:
```
$PAYLOAD_PATH
```
Lees die payload eerst. Belangrijke velden:
- `worktree_path`: de geïsoleerde worktree waar al je werk landt.
- `branch_name`: de feature-branch (bv. `feat/sprint-<id>`); bij PR-strategy
SPRINT zit alle werk in één branch.
- `task_executions[]`: ordered lijst van `SprintTaskExecution`-rijen. Verwerk in
`order`-volgorde. Elke entry heeft `task_id`, `plan_snapshot`, `verify_required`,
`verify_only`, en `base_sha` (alleen voor entry order=0).
- `pbis[]`, `stories[]`: context voor begrip; geen wijzigingen daarop.
- `sprint_run.id`: nodig voor `update_task_status` cascade-prop. Geef altijd
`sprint_run_id` mee aan `update_task_status`.
## Hard regels
- **GEEN** `mcp__scrum4me__wait_for_job` aanroepen. De runner heeft geclaimd.
- **GEEN** `mcp__scrum4me__job_heartbeat` aanroepen. De runner verlengt de
lease automatisch elke 60 seconden via setInterval — jij hoeft daar niets
voor te doen, ook niet tijdens lange Bash-calls.
- Werk uitsluitend in `worktree_path` op `branch_name`. Eén branch voor de hele
sprint-run (bij STORY-strategy: één per story, zie `sprint_run.pr_strategy`).
- Verwerk taken in de exacte `order`-volgorde uit `task_executions[]`.
## Workflow per task_execution
Voor elke entry in `task_executions[]` (in order-volgorde):
1. **Start**: `update_task_execution({ execution_id, status: 'RUNNING' })` en
`update_task_status({ task_id, status: 'in_progress', sprint_run_id })`.
2. **Lees** het `plan_snapshot` uit de execution + de bredere context uit
`task`/`story`/`pbi` in de payload.
3. **Implementeer** de taak in `worktree_path`. Commit per logische laag met
`git add -A && git commit`.
4. **Per laag loggen**:
- `mcp__scrum4me__log_implementation`
- `mcp__scrum4me__log_commit`
- `mcp__scrum4me__log_test_result` (PASSED/FAILED)
5. **Verify-gate** (als `verify_required === true`):
`mcp__scrum4me__verify_sprint_task({ execution_id })`. Bij DIVERGENT: stop de
sprint en sluit af met `update_job_status('failed')`.
6. **Afronden taak**:
- Bij ALIGNED/PARTIAL: `update_task_status({ task_id, status: 'done', sprint_run_id })`
en `update_task_execution({ execution_id, status: 'DONE' })`.
- Bij EMPTY (no-op): `update_task_execution({ execution_id, status: 'SKIPPED' })`
en `update_task_status({ task_id, status: 'done', sprint_run_id })`.
## Sprint afronden
Na de laatste `task_execution`:
- **Verify-gate run**: optioneel een algemene `npm run verify` op de hele worktree.
- **Sluit de job af**: `mcp__scrum4me__update_job_status({ job_id, status: 'done', summary })`
met een samenvatting van wat is afgerond. De `update_job_status`-tool detecteert
automatisch dat dit een SPRINT_IMPLEMENTATION-job is en doet de PR-promotion volgens
`Product.auto_pr` en `sprint_run.pr_strategy`.
Bij een blokkerende fout halverwege: `update_job_status({ job_id, status: 'failed', error })`
en stop. De runner zorgt voor lease-cleanup.
## Vragen aan de gebruiker
Voor blokkerende keuzes: `mcp__scrum4me__ask_user_question` + wacht op antwoord
met `mcp__scrum4me__get_question_answer`. Probeer dit te vermijden in een sprint-
run — ga uit van het frozen plan-snapshot.

View file

@ -1,58 +0,0 @@
# TASK_IMPLEMENTATION-prompt
> Deze prompt wordt door `scrum4me-docker/bin/run-one-job.ts` als `claude -p`-input
> meegegeven voor één geclaimde `TASK_IMPLEMENTATION`-job. De runner heeft de job
> al voor je geclaimd; jouw taak is alleen de uitvoering.
---
Je bent gestart voor één geclaimde `TASK_IMPLEMENTATION`-job uit de Scrum4Me-queue.
De volledige job-payload (inclusief task, story, pbi, sprint, product, config en
worktree_path) staat in:
```
$PAYLOAD_PATH
```
Lees die payload eerst met `Read $PAYLOAD_PATH`. Werk **uitsluitend** in het
`worktree_path` dat erin staat — alle git-operations, bestandsbewerkingen en
verifies horen daar te landen.
## Hard regels
- **GEEN** `mcp__scrum4me__wait_for_job` aanroepen. De runner heeft al voor je
geclaimd. Eén Claude-invocation = één job.
- **GEEN** `mcp__scrum4me__check_queue_empty`. Je sluit af na deze ene job.
- Werk in het toegewezen worktree-pad; geen edits in andere directories.
- Volg `task.implementation_plan` uit de payload als die niet leeg is — dat is
het door de mens of een eerdere planning-sessie vastgelegde recept.
## Workflow
1. **Status op in_progress**: `mcp__scrum4me__update_task_status({ task_id, status: 'in_progress' })`.
2. **Plan lezen**: Lees `task.implementation_plan` uit de payload + relevante
project-docs (`docs/specs/functional.md`, eventueel `docs/patterns/*.md`).
3. **Implementeer** de taak: lees → verander → test → commit per logische laag.
Gebruik `git add -A && git commit` per laag, **geen** `git push`.
4. **Logging per laag**:
- `mcp__scrum4me__log_implementation` met een korte beschrijving van wat je
gewijzigd hebt en waarom.
- `mcp__scrum4me__log_commit` met `commit_hash` en `commit_message` na elke
commit (haal hash uit `git rev-parse HEAD`).
- `mcp__scrum4me__log_test_result` met PASSED/FAILED en uitleg na elke
`npm test` of build-run.
5. **Verify-gate**: roep `mcp__scrum4me__verify_task_against_plan({ task_id })`
aan om de wijzigingen tegen het plan te toetsen.
6. **Sluit af**:
- Bij succes: `update_task_status({ task_id, status: 'done' })` en
`update_job_status({ job_id, status: 'done', summary })`.
- Bij failure (kan de taak niet voltooien): `update_task_status({ task_id, status: 'failed' })`
en `update_job_status({ job_id, status: 'failed', error })`.
- Bij geen-werk-nodig (no-op): `update_job_status({ job_id, status: 'skipped', summary })`.
## Vragen aan de gebruiker
Als je een blokkerende keuze tegenkomt waarvoor je input nodig hebt, gebruik
`mcp__scrum4me__ask_user_question` en wacht op het antwoord met
`mcp__scrum4me__get_question_answer`. Vraag **niet** voor zaken die je zelf
kunt afleiden uit het plan.

View file

@ -5,8 +5,6 @@ const TASK_DB_TO_API = {
IN_PROGRESS: 'in_progress',
REVIEW: 'review',
DONE: 'done',
FAILED: 'failed',
EXCLUDED: 'excluded',
} as const satisfies Record<TaskStatus, string>
const TASK_API_TO_DB: Record<string, TaskStatus> = {
@ -14,22 +12,18 @@ const TASK_API_TO_DB: Record<string, TaskStatus> = {
in_progress: 'IN_PROGRESS',
review: 'REVIEW',
done: 'DONE',
failed: 'FAILED',
excluded: 'EXCLUDED',
}
const STORY_DB_TO_API = {
OPEN: 'open',
IN_SPRINT: 'in_sprint',
DONE: 'done',
FAILED: 'failed',
} as const satisfies Record<StoryStatus, string>
const STORY_API_TO_DB: Record<string, StoryStatus> = {
open: 'OPEN',
in_sprint: 'IN_SPRINT',
done: 'DONE',
failed: 'FAILED',
}
export type TaskStatusApi = (typeof TASK_DB_TO_API)[TaskStatus]

View file

@ -8,27 +8,20 @@ import { z } from 'zod'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { prisma } from '../prisma.js'
import { requireWriteAccess } from '../auth.js'
import { userCanAccessStory, userOwnsIdea } from '../access.js'
import { userCanAccessStory } from '../access.js'
import { toolError, toolJson, withToolErrors } from '../errors.js'
import { triggerPush } from '../lib/push-trigger.js'
const PENDING_TTL_HOURS = 24
const POLL_INTERVAL_MS = 2_000
const MAX_WAIT_SECONDS = 600
// M12: schema accepteert exact één van story_id of idea_id (xor refine).
const inputSchema = z
.object({
story_id: z.string().min(1).optional(),
idea_id: z.string().min(1).optional(),
question: z.string().min(1).max(4_000),
options: z.array(z.string().min(1)).max(8).optional(),
task_id: z.string().min(1).optional(),
wait_seconds: z.number().int().min(0).max(MAX_WAIT_SECONDS).optional(),
})
.refine((d) => Boolean(d.story_id) !== Boolean(d.idea_id), {
message: 'Provide exactly one of story_id or idea_id',
})
const inputSchema = z.object({
story_id: z.string().min(1),
question: z.string().min(1).max(4_000),
options: z.array(z.string().min(1)).max(8).optional(),
task_id: z.string().min(1).optional(),
wait_seconds: z.number().int().min(0).max(MAX_WAIT_SECONDS).optional(),
})
function summarize(q: {
id: string
@ -64,60 +57,36 @@ export function registerAskUserQuestionTool(server: McpServer) {
'demo accounts.',
inputSchema,
},
async ({ story_id, idea_id, question, options, task_id, wait_seconds }) =>
async ({ story_id, question, options, task_id, wait_seconds }) =>
withToolErrors(async () => {
const auth = await requireWriteAccess()
if (!(await userCanAccessStory(story_id, auth.userId))) {
return toolError(`Story ${story_id} not found or not accessible`)
}
// M12: branch on which scope was provided. story_id en idea_id sluiten
// elkaar uit (zod-refine in inputSchema).
let productId: string
if (idea_id) {
if (!(await userOwnsIdea(idea_id, auth.userId))) {
return toolError(`Idea ${idea_id} not found`)
}
const idea = await prisma.idea.findUnique({
where: { id: idea_id },
select: { product_id: true },
})
if (!idea?.product_id) {
// Idee zonder product mag pas Q&A starten als product gekoppeld is
// (M12 grill-keuze 3: product met repo verplicht voor grill).
return toolError(`Idea ${idea_id} has no linked product`)
}
productId = idea.product_id
} else if (story_id) {
if (!(await userCanAccessStory(story_id, auth.userId))) {
return toolError(`Story ${story_id} not found or not accessible`)
}
const story = await prisma.story.findUnique({
where: { id: story_id },
select: { product_id: true },
})
if (!story) {
return toolError(`Story ${story_id} not found`)
}
productId = story.product_id
const story = await prisma.story.findUnique({
where: { id: story_id },
select: { product_id: true },
})
if (!story) {
return toolError(`Story ${story_id} not found`)
}
if (task_id) {
const task = await prisma.task.findFirst({
where: { id: task_id, story_id },
select: { id: true },
})
if (!task) {
return toolError(`Task ${task_id} does not belong to story ${story_id}`)
}
if (task_id) {
const task = await prisma.task.findFirst({
where: { id: task_id, story_id },
select: { id: true },
})
if (!task) {
return toolError(`Task ${task_id} does not belong to story ${story_id}`)
}
} else {
// Mag niet voorkomen door de zod-refine, maar TS-narrow.
return toolError('Provide exactly one of story_id or idea_id')
}
const created = await prisma.claudeQuestion.create({
data: {
story_id: story_id ?? null,
idea_id: idea_id ?? null,
story_id,
task_id: task_id ?? null,
product_id: productId,
product_id: story.product_id,
asked_by: auth.userId,
question,
// Prisma's `Json?`-veld accepteert geen `null`-literal in `data`;
@ -128,13 +97,6 @@ export function registerAskUserQuestionTool(server: McpServer) {
},
})
void triggerPush(auth.userId, {
title: 'Claude heeft een vraag',
body: question.slice(0, 120),
url: '/notifications',
tag: `claude-q-${created.id}`,
})
// Async-mode (default): return direct.
if (!wait_seconds || wait_seconds === 0) {
return toolJson(summarize(created))

View file

@ -1,67 +0,0 @@
import { z } from 'zod'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { prisma } from '../prisma.js'
import { requireWriteAccess } from '../auth.js'
import { userCanAccessProduct } from '../access.js'
import { toolError, toolJson, withToolErrors } from '../errors.js'
const ACTIVE_STATUSES = ['QUEUED', 'CLAIMED', 'RUNNING'] as const
const inputSchema = z.object({
product_id: z.string().min(1).optional(),
})
export function registerCheckQueueEmptyTool(server: McpServer) {
server.registerTool(
'check_queue_empty',
{
title: 'Check queue empty',
description:
'Synchronous, non-blocking check of how many ClaudeJobs are still active ' +
"(QUEUED, CLAIMED, RUNNING). Optionally scoped to one product via product_id; " +
'without it, aggregates across all accessible products. ' +
"Use after the last update_job_status('done') in a batch to decide whether to " +
'keep working or finalize. Forbidden for demo accounts.',
inputSchema,
annotations: { readOnlyHint: true, idempotentHint: true },
},
async ({ product_id }) =>
withToolErrors(async () => {
const auth = await requireWriteAccess()
const { userId } = auth
if (product_id) {
if (!(await userCanAccessProduct(product_id, userId))) {
return toolError(`Product ${product_id} not found or not accessible`)
}
const remaining = await prisma.claudeJob.count({
where: {
user_id: userId,
product_id,
status: { in: [...ACTIVE_STATUSES] },
},
})
return toolJson({ empty: remaining === 0, remaining })
}
const groups = await prisma.claudeJob.groupBy({
by: ['product_id'],
where: {
user_id: userId,
status: { in: [...ACTIVE_STATUSES] },
product: {
OR: [
{ user_id: userId },
{ members: { some: { user_id: userId } } },
],
},
},
_count: true,
})
const by_product = Object.fromEntries(groups.map((g) => [g.product_id, g._count]))
const remaining = groups.reduce((sum, g) => sum + g._count, 0)
return toolJson({ empty: remaining === 0, remaining, by_product })
}),
)
}

View file

@ -1,11 +1,12 @@
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 { getWorktreeRoot, SYSTEM_WORKTREE_DIRS } from '../git/worktree-paths.js'
import { resolveRepoRoot } from './wait-for-job.js'
const TERMINAL_STATUSES = new Set(['DONE', 'FAILED', 'CANCELLED'])
@ -14,20 +15,16 @@ const ACTIVE_STATUSES = new Set(['QUEUED', 'CLAIMED', 'RUNNING'])
const inputSchema = z.object({})
export async function getWorktreeParent(): Promise<string> {
return getWorktreeRoot()
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()
&& !SYSTEM_WORKTREE_DIRS.has(e.name)
&& !e.name.endsWith('.lock'),
)
.map((e) => e.name)
return entries.filter((e) => e.isDirectory()).map((e) => e.name)
} catch {
return []
}

View file

@ -1,44 +1,16 @@
// MCP authoring tool: create een Product Backlog Item.
//
// Sort_order wordt automatisch op last+1 binnen de prioriteits-groep gezet als
// niet meegegeven. Code wordt auto-gegenereerd als PBI-N (zelfde logica als de
// Scrum4Me-app), met retry bij een race-condition op de unique constraint.
// niet meegegeven. Code-veld blijft null — auto-codes (PBI-1, PBI-2, …) worden
// door de Scrum4Me-app gegenereerd, kan optioneel later via UI worden gezet.
import { z } from 'zod'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { Prisma } from '@prisma/client'
import { prisma } from '../prisma.js'
import { requireWriteAccess } from '../auth.js'
import { userCanAccessProduct } from '../access.js'
import { toolError, toolJson, withToolErrors } from '../errors.js'
const PBI_AUTO_RE = /^PBI-(\d+)$/
const MAX_CODE_ATTEMPTS = 3
async function generateNextPbiCode(productId: string): Promise<string> {
const pbis = await prisma.pbi.findMany({
where: { product_id: productId },
select: { code: true },
})
let max = 0
for (const p of pbis) {
const m = p.code?.match(PBI_AUTO_RE)
if (m) {
const n = Number.parseInt(m[1], 10)
if (!Number.isNaN(n) && n > max) max = n
}
}
return `PBI-${max + 1}`
}
function isCodeUniqueConflict(error: unknown): boolean {
if (!(error instanceof Prisma.PrismaClientKnownRequestError)) return false
if (error.code !== 'P2002') return false
const target = (error.meta as { target?: string[] | string } | undefined)?.target
if (!target) return false
return Array.isArray(target) ? target.includes('code') : target.includes('code')
}
const inputSchema = z.object({
product_id: z.string().min(1),
title: z.string().min(1).max(200),
@ -73,36 +45,24 @@ export function registerCreatePbiTool(server: McpServer) {
resolvedSortOrder = (last?.sort_order ?? 0) + 1.0
}
let lastError: unknown
for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) {
const code = await generateNextPbiCode(product_id)
try {
const pbi = await prisma.pbi.create({
data: {
product_id,
code,
title,
description: description ?? null,
priority,
sort_order: resolvedSortOrder,
},
select: {
id: true,
code: true,
title: true,
description: true,
priority: true,
sort_order: true,
created_at: true,
},
})
return toolJson(pbi)
} catch (e) {
if (isCodeUniqueConflict(e)) { lastError = e; continue }
throw e
}
}
throw lastError ?? new Error('Kon geen unieke PBI-code genereren')
const pbi = await prisma.pbi.create({
data: {
product_id,
title,
description: description ?? null,
priority,
sort_order: resolvedSortOrder,
},
select: {
id: true,
title: true,
description: true,
priority: true,
sort_order: true,
created_at: true,
},
})
return toolJson(pbi)
}),
)
}

View file

@ -1,113 +0,0 @@
// MCP authoring tool: create een Sprint binnen een product.
//
// Status start altijd op OPEN; geen reuse-check op bestaande OPEN-sprints
// (per plan-to-pbi-flow.md "altijd nieuwe sprint"). Code wordt auto-gegenereerd
// als S-{YYYY-MM-DD}-{N} per product per datum, met retry bij race-condition
// op de unique constraint (@@unique([product_id, code])).
import { z } from 'zod'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { Prisma } from '@prisma/client'
import { prisma } from '../prisma.js'
import { requireWriteAccess } from '../auth.js'
import { userCanAccessProduct } from '../access.js'
import { toolError, toolJson, withToolErrors } from '../errors.js'
const SPRINT_AUTO_RE = /^S-(\d{4}-\d{2}-\d{2})-(\d+)$/
const MAX_CODE_ATTEMPTS = 3
function todayIsoDate(): string {
return new Date().toISOString().slice(0, 10)
}
async function generateNextSprintCode(productId: string): Promise<string> {
const today = todayIsoDate()
const sprints = await prisma.sprint.findMany({
where: { product_id: productId, code: { startsWith: `S-${today}-` } },
select: { code: true },
})
let max = 0
for (const s of sprints) {
const m = s.code?.match(SPRINT_AUTO_RE)
// Dubbele check op de datum — defensive tegen filterveranderingen
// of mock-data die niet door de DB-where heen ging.
if (m && m[1] === today) {
const n = Number.parseInt(m[2], 10)
if (!Number.isNaN(n) && n > max) max = n
}
}
return `S-${today}-${max + 1}`
}
function isCodeUniqueConflict(error: unknown): boolean {
if (!(error instanceof Prisma.PrismaClientKnownRequestError)) return false
if (error.code !== 'P2002') return false
const target = (error.meta as { target?: string[] | string } | undefined)?.target
if (!target) return false
return Array.isArray(target) ? target.includes('code') : target.includes('code')
}
export const inputSchema = z.object({
product_id: z.string().min(1),
code: z.string().min(1).max(30).optional(),
sprint_goal: z.string().min(1).max(500),
start_date: z.string().date().optional(),
})
export async function handleCreateSprint(
{ product_id, code, sprint_goal, start_date }: z.infer<typeof inputSchema>,
) {
return withToolErrors(async () => {
const auth = await requireWriteAccess()
if (!(await userCanAccessProduct(product_id, auth.userId))) {
return toolError(`Product ${product_id} not found or not accessible`)
}
const resolvedStartDate = start_date ? new Date(start_date) : new Date()
const baseSelect = {
id: true,
code: true,
sprint_goal: true,
status: true,
start_date: true,
created_at: true,
} as const
if (code) {
const sprint = await prisma.sprint.create({
data: { product_id, code, sprint_goal, status: 'OPEN', start_date: resolvedStartDate },
select: baseSelect,
})
return toolJson(sprint)
}
let lastError: unknown
for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) {
const generated = await generateNextSprintCode(product_id)
try {
const sprint = await prisma.sprint.create({
data: { product_id, code: generated, sprint_goal, status: 'OPEN', start_date: resolvedStartDate },
select: baseSelect,
})
return toolJson(sprint)
} catch (e) {
if (isCodeUniqueConflict(e)) { lastError = e; continue }
throw e
}
}
throw lastError ?? new Error('Kon geen unieke sprint-code genereren')
})
}
export function registerCreateSprintTool(server: McpServer) {
server.registerTool(
'create_sprint',
{
title: 'Create Sprint',
description:
'Create a new sprint for a product with status=OPEN. Code auto-generated as S-{YYYY-MM-DD}-{N} per product per date if not provided. Forbidden for demo accounts.',
inputSchema,
},
handleCreateSprint,
)
}

View file

@ -1,45 +1,16 @@
// MCP authoring tool: create een Story onder een bestaande PBI.
//
// product_id wordt afgeleid uit de PBI (denormalized FK conform CLAUDE.md
// convention — nooit vertrouwen op client-input). Zonder sprint_id is
// status='OPEN' en landt de story in de Product Backlog; mét sprint_id
// wordt de story direct aan die sprint gekoppeld (status='IN_SPRINT').
// convention — nooit vertrouwen op client-input). status='OPEN' default;
// landt in de Product Backlog, niet auto in een sprint.
import { z } from 'zod'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { Prisma } from '@prisma/client'
import { prisma } from '../prisma.js'
import { requireWriteAccess } from '../auth.js'
import { userCanAccessProduct } from '../access.js'
import { toolError, toolJson, withToolErrors } from '../errors.js'
const STORY_AUTO_RE = /^ST-(\d+)$/
const MAX_CODE_ATTEMPTS = 3
async function generateNextStoryCode(productId: string): Promise<string> {
const stories = await prisma.story.findMany({
where: { product_id: productId },
select: { code: true },
})
let max = 0
for (const s of stories) {
const m = s.code?.match(STORY_AUTO_RE)
if (m) {
const n = Number.parseInt(m[1], 10)
if (!Number.isNaN(n) && n > max) max = n
}
}
return `ST-${String(max + 1).padStart(3, '0')}`
}
function isCodeUniqueConflict(error: unknown): boolean {
if (!(error instanceof Prisma.PrismaClientKnownRequestError)) return false
if (error.code !== 'P2002') return false
const target = (error.meta as { target?: string[] | string } | undefined)?.target
if (!target) return false
return Array.isArray(target) ? target.includes('code') : target.includes('code')
}
const inputSchema = z.object({
pbi_id: z.string().min(1),
title: z.string().min(1).max(200),
@ -47,108 +18,63 @@ const inputSchema = z.object({
acceptance_criteria: z.string().max(4000).optional(),
priority: z.number().int().min(1).max(4),
sort_order: z.number().optional(),
// Optionele sprint-koppeling: bij creatie de story direct aan een sprint
// hangen (status=IN_SPRINT). De sprint moet bij hetzelfde product horen.
sprint_id: z.string().min(1).optional(),
})
export async function handleCreateStory(
{
pbi_id,
title,
description,
acceptance_criteria,
priority,
sort_order,
sprint_id,
}: z.infer<typeof inputSchema>,
) {
return withToolErrors(async () => {
const auth = await requireWriteAccess()
const pbi = await prisma.pbi.findUnique({
where: { id: pbi_id },
select: { product_id: true },
})
if (!pbi) return toolError(`PBI ${pbi_id} not found`)
if (!(await userCanAccessProduct(pbi.product_id, auth.userId))) {
return toolError(`PBI ${pbi_id} not accessible`)
}
// Optionele sprint-koppeling: valideer dat de sprint bestaat én bij
// hetzelfde product hoort — voorkomt een cross-product koppeling.
if (sprint_id !== undefined) {
const sprint = await prisma.sprint.findUnique({
where: { id: sprint_id },
select: { product_id: true },
})
if (!sprint) return toolError(`Sprint ${sprint_id} not found`)
if (sprint.product_id !== pbi.product_id) {
return toolError(
`Sprint ${sprint_id} belongs to a different product than PBI ${pbi_id}`,
)
}
}
let resolvedSortOrder = sort_order
if (resolvedSortOrder === undefined) {
const last = await prisma.story.findFirst({
where: { pbi_id, priority },
orderBy: { sort_order: 'desc' },
select: { sort_order: true },
})
resolvedSortOrder = (last?.sort_order ?? 0) + 1.0
}
let lastError: unknown
for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) {
const code = await generateNextStoryCode(pbi.product_id)
try {
const story = await prisma.story.create({
data: {
pbi_id,
product_id: pbi.product_id, // denormalized uit DB-parent, niet uit input
sprint_id: sprint_id ?? null,
code,
title,
description: description ?? null,
acceptance_criteria: acceptance_criteria ?? null,
priority,
sort_order: resolvedSortOrder,
status: sprint_id ? 'IN_SPRINT' : 'OPEN',
},
select: {
id: true,
code: true,
title: true,
description: true,
acceptance_criteria: true,
priority: true,
sort_order: true,
status: true,
sprint_id: true,
created_at: true,
},
})
return toolJson(story)
} catch (e) {
if (isCodeUniqueConflict(e)) { lastError = e; continue }
throw e
}
}
throw lastError ?? new Error('Kon geen unieke Story-code genereren')
})
}
export function registerCreateStoryTool(server: McpServer) {
server.registerTool(
'create_story',
{
title: 'Create story',
description:
'Add a story under an existing PBI. Optionally link it to a sprint via sprint_id — when given, the story is created with status=IN_SPRINT and the sprint must belong to the same product as the PBI; otherwise status=OPEN and the story lands in the product backlog. Sort_order auto-set to last+1 within the PBI/priority group if not provided. Forbidden for demo accounts.',
'Add a story under an existing PBI. Status defaults to OPEN (lands in product backlog, not in a sprint). Sort_order auto-set to last+1 within the PBI/priority group if not provided. Forbidden for demo accounts.',
inputSchema,
},
handleCreateStory,
async ({ pbi_id, title, description, acceptance_criteria, priority, sort_order }) =>
withToolErrors(async () => {
const auth = await requireWriteAccess()
const pbi = await prisma.pbi.findUnique({
where: { id: pbi_id },
select: { product_id: true },
})
if (!pbi) return toolError(`PBI ${pbi_id} not found`)
if (!(await userCanAccessProduct(pbi.product_id, auth.userId))) {
return toolError(`PBI ${pbi_id} not accessible`)
}
let resolvedSortOrder = sort_order
if (resolvedSortOrder === undefined) {
const last = await prisma.story.findFirst({
where: { pbi_id, priority },
orderBy: { sort_order: 'desc' },
select: { sort_order: true },
})
resolvedSortOrder = (last?.sort_order ?? 0) + 1.0
}
const story = await prisma.story.create({
data: {
pbi_id,
product_id: pbi.product_id, // denormalized uit DB-parent, niet uit input
title,
description: description ?? null,
acceptance_criteria: acceptance_criteria ?? null,
priority,
sort_order: resolvedSortOrder,
status: 'OPEN',
},
select: {
id: true,
title: true,
description: true,
acceptance_criteria: true,
priority: true,
sort_order: true,
status: true,
created_at: true,
},
})
return toolJson(story)
}),
)
}

View file

@ -5,39 +5,11 @@
import { z } from 'zod'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { Prisma } from '@prisma/client'
import { prisma } from '../prisma.js'
import { requireWriteAccess } from '../auth.js'
import { userCanAccessProduct } from '../access.js'
import { toolError, toolJson, withToolErrors } from '../errors.js'
const TASK_AUTO_RE = /^T-(\d+)$/
const MAX_CODE_ATTEMPTS = 3
async function generateNextTaskCode(productId: string): Promise<string> {
const tasks = await prisma.task.findMany({
where: { product_id: productId },
select: { code: true },
})
let max = 0
for (const t of tasks) {
const m = t.code?.match(TASK_AUTO_RE)
if (m) {
const n = Number.parseInt(m[1], 10)
if (!Number.isNaN(n) && n > max) max = n
}
}
return `T-${max + 1}`
}
function isCodeUniqueConflict(error: unknown): boolean {
if (!(error instanceof Prisma.PrismaClientKnownRequestError)) return false
if (error.code !== 'P2002') return false
const target = (error.meta as { target?: string[] | string } | undefined)?.target
if (!target) return false
return Array.isArray(target) ? target.includes('code') : target.includes('code')
}
const inputSchema = z.object({
story_id: z.string().min(1),
title: z.string().min(1).max(200),
@ -45,13 +17,6 @@ const inputSchema = z.object({
implementation_plan: z.string().max(8000).optional(),
priority: z.number().int().min(1).max(4),
sort_order: z.number().optional(),
// Cross-repo override: zet expliciet de repo waarop de worker deze task
// moet uitvoeren (overrides product.repo_url). Gebruik dit voor PBI's die
// werk in meerdere repos coördineren — bv. PBI op Scrum4Me-product met
// tasks die in scrum4me-mcp of scrum4me-docker landen.
// Format: full git URL (https://github.com/owner/repo). Null/omit = erf
// van product.repo_url.
repo_url: z.string().url().optional(),
})
export function registerCreateTaskTool(server: McpServer) {
@ -60,10 +25,10 @@ export function registerCreateTaskTool(server: McpServer) {
{
title: 'Create task',
description:
'Add a task under an existing story. Inherits sprint_id from the story (denormalized). Status defaults to TO_DO. Sort_order auto-set to last+1 within the story/priority group if not provided. Optional repo_url overrides the product.repo_url for cross-repo work (e.g. tasks targeting scrum4me-mcp under a Scrum4Me PBI). Forbidden for demo accounts.',
'Add a task under an existing story. Inherits sprint_id from the story (denormalized). Status defaults to TO_DO. Sort_order auto-set to last+1 within the story/priority group if not provided. Forbidden for demo accounts.',
inputSchema,
},
async ({ story_id, title, description, implementation_plan, priority, sort_order, repo_url }) =>
async ({ story_id, title, description, implementation_plan, priority, sort_order }) =>
withToolErrors(async () => {
const auth = await requireWriteAccess()
@ -86,44 +51,29 @@ export function registerCreateTaskTool(server: McpServer) {
resolvedSortOrder = (last?.sort_order ?? 0) + 1.0
}
let lastError: unknown
for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) {
const code = await generateNextTaskCode(story.product_id)
try {
const task = await prisma.task.create({
data: {
story_id,
product_id: story.product_id, // denormalized — erf van story
sprint_id: story.sprint_id, // denormalized — erf van story
code,
title,
description: description ?? null,
implementation_plan: implementation_plan ?? null,
priority,
sort_order: resolvedSortOrder,
status: 'TO_DO',
repo_url: repo_url ?? null,
},
select: {
id: true,
code: true,
title: true,
description: true,
implementation_plan: true,
priority: true,
sort_order: true,
status: true,
repo_url: true,
created_at: true,
},
})
return toolJson(task)
} catch (e) {
if (isCodeUniqueConflict(e)) { lastError = e; continue }
throw e
}
}
throw lastError ?? new Error('Kon geen unieke Task-code genereren')
const task = await prisma.task.create({
data: {
story_id,
sprint_id: story.sprint_id, // denormalized — erf van story
title,
description: description ?? null,
implementation_plan: implementation_plan ?? null,
priority,
sort_order: resolvedSortOrder,
status: 'TO_DO',
},
select: {
id: true,
title: true,
description: true,
implementation_plan: true,
priority: true,
sort_order: true,
status: true,
created_at: true,
},
})
return toolJson(task)
}),
)
}

42
src/tools/create-todo.ts Normal file
View file

@ -0,0 +1,42 @@
import { z } from 'zod'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { prisma } from '../prisma.js'
import { requireWriteAccess } from '../auth.js'
import { userCanAccessProduct } from '../access.js'
import { toolError, toolJson, withToolErrors } from '../errors.js'
const inputSchema = z.object({
title: z.string().min(1),
description: z.string().max(2000).optional(),
product_id: z.string().min(1).optional(),
})
export function registerCreateTodoTool(server: McpServer) {
server.registerTool(
'create_todo',
{
title: 'Create todo',
description:
'Add a todo for the authenticated user, optionally scoped to a product. ' +
'Forbidden for demo accounts.',
inputSchema,
},
async ({ title, description, product_id }) =>
withToolErrors(async () => {
const auth = await requireWriteAccess()
if (product_id && !(await userCanAccessProduct(product_id, auth.userId))) {
return toolError(`Product ${product_id} not found or not accessible`)
}
const todo = await prisma.todo.create({
data: {
user_id: auth.userId,
product_id: product_id ?? null,
title,
description: description ?? null,
},
select: { id: true, title: true, description: true, created_at: true },
})
return toolJson(todo)
}),
)
}

View file

@ -47,7 +47,7 @@ export function registerGetClaudeContextTool(server: McpServer) {
}
const activeSprint = await prisma.sprint.findFirst({
where: { product_id, status: 'OPEN' },
where: { product_id, status: 'ACTIVE' },
orderBy: { created_at: 'desc' },
select: { id: true, sprint_goal: true, status: true },
})
@ -99,21 +99,19 @@ export function registerGetClaudeContextTool(server: McpServer) {
}
}
const openIdeas = await prisma.idea.findMany({
const openTodos = await prisma.todo.findMany({
where: {
user_id: auth.userId,
done: false,
archived: false,
status: { not: 'PLANNED' },
OR: [{ product_id: product_id }, { product_id: null }],
},
orderBy: { created_at: 'asc' },
take: 50,
select: {
id: true,
code: true,
title: true,
description: true,
status: true,
created_at: true,
},
})
@ -122,7 +120,7 @@ export function registerGetClaudeContextTool(server: McpServer) {
product,
active_sprint: activeSprint,
next_story: nextStory,
open_ideas: openIdeas,
open_todos: openTodos,
})
}),
)

View file

@ -1,121 +0,0 @@
// MCP-tool: laadt volledige context voor een idee — voor agents die
// idee-jobs uitvoeren of via UI-acties idee-info nodig hebben.
//
// Strikt user_id-only (M12 grill-keuze 8). Demo MAY read.
import { z } from 'zod'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { prisma } from '../prisma.js'
import { getAuth } from '../auth.js'
import { userOwnsIdea } from '../access.js'
import { toolError, toolJson, withToolErrors } from '../errors.js'
const inputSchema = z.object({
idea_id: z.string().min(1),
})
export function registerGetIdeaContextTool(server: McpServer) {
server.registerTool(
'get_idea_context',
{
title: 'Get idea context',
description:
'Fetch full idea context (idea + product + repo_url + open questions + recent logs). Strict user_id-only scope. Read-only.',
inputSchema,
annotations: { readOnlyHint: true, idempotentHint: true },
},
async ({ idea_id }) =>
withToolErrors(async () => {
const auth = await getAuth()
const idea = await prisma.idea.findFirst({
where: { id: idea_id, user_id: auth.userId },
include: {
product: {
select: {
id: true,
name: true,
code: true,
repo_url: true,
definition_of_done: true,
},
},
pbi: { select: { id: true, code: true, title: true } },
},
})
if (!idea) {
// 404, niet 403 — vermijdt enumeratie van andermans idea-ids.
return toolError('Idea not found')
}
// Open vragen + recente logs voor agent-context.
const [openQuestions, recentLogs] = await Promise.all([
prisma.claudeQuestion.findMany({
where: { idea_id: idea.id, status: 'open' },
orderBy: { created_at: 'desc' },
take: 10,
select: {
id: true,
question: true,
options: true,
created_at: true,
expires_at: true,
},
}),
prisma.ideaLog.findMany({
where: { idea_id: idea.id },
orderBy: { created_at: 'desc' },
take: 20,
select: {
id: true,
type: true,
content: true,
metadata: true,
created_at: true,
},
}),
])
return toolJson({
idea: {
id: idea.id,
code: idea.code,
title: idea.title,
description: idea.description,
grill_md: idea.grill_md,
plan_md: idea.plan_md,
status: idea.status,
product_id: idea.product_id,
pbi_id: idea.pbi_id,
archived: idea.archived,
created_at: idea.created_at.toISOString(),
updated_at: idea.updated_at.toISOString(),
},
product: idea.product,
pbi: idea.pbi,
repo_url: idea.product?.repo_url ?? null,
grill_md_so_far: idea.grill_md,
open_questions: openQuestions.map((q) => ({
id: q.id,
question: q.question,
options: Array.isArray(q.options) ? (q.options as string[]) : null,
created_at: q.created_at.toISOString(),
expires_at: q.expires_at.toISOString(),
})),
recent_logs: recentLogs.map((l) => ({
id: l.id,
type: l.type,
content: l.content,
metadata: l.metadata,
created_at: l.created_at.toISOString(),
})),
})
// Note: prompt_text wordt door wait_for_job in de job-payload
// meegestuurd (single source). get_idea_context is voor adhoc lookups
// — geen prompt-text nodig.
void userOwnsIdea
}),
)
}

View file

@ -1,33 +0,0 @@
// MCP read-tool: lees de worker-instellingen van de geauthenticeerde user.
//
// Worker roept dit aan vóór elke wait_for_job iteratie zodat hij weet
// wanneer hij stand-by moet (pre-flight quota-gate).
//
// Auth: api-token; user_id afgeleid uit token. Demo mag.
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { prisma } from '../prisma.js'
import { getAuth } from '../auth.js'
import { toolError, toolJson, withToolErrors } from '../errors.js'
export function registerGetWorkerSettingsTool(server: McpServer) {
server.registerTool(
'get_worker_settings',
{
title: 'Get worker settings',
description:
'Read the authenticated user\'s worker settings (min_quota_pct). Worker should call this each iteration before doing the pre-flight quota probe.',
inputSchema: {},
},
async () =>
withToolErrors(async () => {
const auth = await getAuth()
const user = await prisma.user.findUnique({
where: { id: auth.userId },
select: { min_quota_pct: true },
})
if (!user) return toolError('User not found')
return toolJson({ min_quota_pct: user.min_quota_pct })
}),
)
}

View file

@ -1,25 +1,9 @@
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { z } from 'zod'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { prisma } from '../prisma.js'
import { toolJson, withToolErrors } from '../errors.js'
// Read once at module-load. Health is hot-path enough that we don't want
// disk-IO per call, and the version string is fixed for the running process.
function readPkgVersion(): string {
try {
const here = dirname(fileURLToPath(import.meta.url))
// src/tools/health.ts → src/tools → src → repo-root
const pkgPath = join(here, '..', '..', 'package.json')
const raw = readFileSync(pkgPath, 'utf8')
return (JSON.parse(raw) as { version?: string }).version ?? '0.0.0'
} catch {
return '0.0.0'
}
}
const VERSION = readPkgVersion()
const VERSION = '0.1.0'
export function registerHealthTool(server: McpServer) {
server.registerTool(

View file

@ -1,81 +0,0 @@
// PBI-50 F3-T3: job_heartbeat
//
// Verlengt ClaudeJob.lease_until met 5 min zodat resetStaleClaimedJobs een
// long-running job (bv. SPRINT_IMPLEMENTATION over 30+ min) niet ten onrechte
// als stale markt. Worker draait een achtergrond-loop elke 60s.
//
// Voor SPRINT-jobs: respons bevat sprint_run_status zodat de worker zijn
// loop kan breken bij ≠ RUNNING (bv. UI-side cancel of MERGE_CONFLICT-pause).
import { z } from 'zod'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { prisma } from '../prisma.js'
import { requireWriteAccess } from '../auth.js'
import { toolError, toolJson, withToolErrors } from '../errors.js'
const inputSchema = z.object({
job_id: z.string().min(1),
})
export function registerJobHeartbeatTool(server: McpServer) {
server.registerTool(
'job_heartbeat',
{
title: 'Job heartbeat',
description:
'Extend the lease on a CLAIMED/RUNNING job by 5 minutes. Token must own the job. ' +
'For SPRINT_IMPLEMENTATION jobs: response includes sprint_run_status so the worker ' +
'can break its task-loop on UI-side cancel/pause without an extra query. ' +
'Worker should call this every ~60s during long-running batches. ' +
'Forbidden for demo accounts.',
inputSchema,
},
async ({ job_id }) =>
withToolErrors(async () => {
const auth = await requireWriteAccess()
// Atomic conditional UPDATE so a non-owner / non-active job is rejected
// without a separate read.
const updated = await prisma.$queryRaw<
Array<{ id: string; lease_until: Date; kind: string; sprint_run_id: string | null }>
>`
UPDATE claude_jobs
SET lease_until = NOW() + INTERVAL '5 minutes'
WHERE id = ${job_id}
AND claimed_by_token_id = ${auth.tokenId}
AND status IN ('CLAIMED', 'RUNNING')
RETURNING id, lease_until, kind::text AS kind, sprint_run_id
`
if (updated.length === 0) {
return toolError(
`Job ${job_id} not found, not claimed by your token, or in terminal state`,
)
}
const row = updated[0]
let sprint_run_status: string | null = null
let sprint_run_pause_reason: string | null = null
if (row.kind === 'SPRINT_IMPLEMENTATION' && row.sprint_run_id) {
const sprintRun = await prisma.sprintRun.findUnique({
where: { id: row.sprint_run_id },
select: { status: true, pause_context: true },
})
sprint_run_status = sprintRun?.status ?? null
// Extract pause_reason from pause_context Json (best-effort)
const ctx = sprintRun?.pause_context as
| { pause_reason?: string }
| null
| undefined
sprint_run_pause_reason = ctx?.pause_reason ?? null
}
return toolJson({
ok: true,
job_id: row.id,
lease_until: row.lease_until.toISOString(),
sprint_run_status,
sprint_run_pause_reason,
})
}),
)
}

View file

@ -1,57 +0,0 @@
// MCP-tool: agents loggen een tussentijdse beslissing of notitie tijdens
// een grill- of make-plan-sessie. Verschijnt in de Timeline-tab van de
// idea-detailpagina.
import { z } from 'zod'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { Prisma } from '@prisma/client'
import { prisma } from '../prisma.js'
import { requireWriteAccess } from '../auth.js'
import { userOwnsIdea } from '../access.js'
import { toolError, toolJson, withToolErrors } from '../errors.js'
const inputSchema = z.object({
idea_id: z.string().min(1),
type: z.enum(['DECISION', 'NOTE']),
content: z.string().min(1).max(4_000),
metadata: z.record(z.string(), z.unknown()).optional(),
})
export function registerLogIdeaDecisionTool(server: McpServer) {
server.registerTool(
'log_idea_decision',
{
title: 'Log idea decision/note',
description:
"Append a DECISION or NOTE entry to an idea's timeline. Use to capture deliberations during grill or make-plan sessions. Forbidden for demo accounts.",
inputSchema,
},
async ({ idea_id, type, content, metadata }) =>
withToolErrors(async () => {
const auth = await requireWriteAccess()
if (!(await userOwnsIdea(idea_id, auth.userId))) {
return toolError('Idea not found')
}
const log = await prisma.ideaLog.create({
data: {
idea_id,
type,
content,
metadata: (metadata as Prisma.InputJsonValue | undefined) ?? undefined,
},
select: { id: true, type: true, created_at: true },
})
return toolJson({
ok: true,
log: {
id: log.id,
type: log.type,
created_at: log.created_at.toISOString(),
},
})
}),
)
}

View file

@ -1,48 +0,0 @@
import { z } from 'zod'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { prisma } from '../prisma.js'
import { requireWriteAccess } from '../auth.js'
import { userCanAccessProduct } from '../access.js'
import { toolError, toolJson, withToolErrors } from '../errors.js'
const inputSchema = z.object({
pbi_id: z.string().min(1),
})
export async function handleMarkPbiPrMerged({ pbi_id }: z.infer<typeof inputSchema>) {
return withToolErrors(async () => {
const auth = await requireWriteAccess()
const pbi = await prisma.pbi.findUnique({
where: { id: pbi_id },
select: { product_id: true, pr_url: true },
})
if (!pbi || !(await userCanAccessProduct(pbi.product_id, auth.userId))) {
return toolError(`PBI ${pbi_id} not found or not accessible`)
}
if (!pbi.pr_url) {
return toolError(`PBI ${pbi_id} heeft geen gekoppelde PR`)
}
const updated = await prisma.pbi.update({
where: { id: pbi_id },
data: { pr_merged_at: new Date() },
select: { id: true, pr_url: true, pr_merged_at: true },
})
return toolJson({ ok: true, pbi_id, pr_url: updated.pr_url, pr_merged_at: updated.pr_merged_at })
})
}
export function registerMarkPbiPrMergedTool(server: McpServer) {
server.registerTool(
'mark_pbi_pr_merged',
{
title: 'Mark PBI PR Merged',
description:
'Set pr_merged_at = now() on a PBI, signalling the PR has been merged. Requires pr_url to already be set. Idempotent: re-calling overwrites the timestamp. Forbidden for demo accounts.',
inputSchema,
},
handleMarkPbiPrMerged,
)
}

View file

@ -1,45 +0,0 @@
import { z } from 'zod'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { prisma } from '../prisma.js'
import { requireWriteAccess } from '../auth.js'
import { userCanAccessProduct } from '../access.js'
import { toolError, toolJson, withToolErrors } from '../errors.js'
export const inputSchema = z.object({
pbi_id: z.string().min(1),
pr_url: z.string().regex(/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+$/),
})
export async function handleSetPbiPr({ pbi_id, pr_url }: z.infer<typeof inputSchema>) {
return withToolErrors(async () => {
const auth = await requireWriteAccess()
const pbi = await prisma.pbi.findUnique({
where: { id: pbi_id },
select: { product_id: true },
})
if (!pbi || !(await userCanAccessProduct(pbi.product_id, auth.userId))) {
return toolError(`PBI ${pbi_id} not found or not accessible`)
}
await prisma.pbi.update({
where: { id: pbi_id },
data: { pr_url, pr_merged_at: null },
})
return toolJson({ ok: true, pbi_id, pr_url })
})
}
export function registerSetPbiPrTool(server: McpServer) {
server.registerTool(
'set_pbi_pr',
{
title: 'Set PBI PR URL',
description:
'Write pr_url on a PBI and clear pr_merged_at. Idempotent: re-calling overwrites pr_url and resets pr_merged_at to null. Forbidden for demo accounts.',
inputSchema,
},
handleSetPbiPr,
)
}

View file

@ -1,57 +0,0 @@
// MCP-tool: schrijft het grill_md-resultaat na een IDEA_GRILL-job en zet
// de idea-status op GRILLED. Logt een IdeaLog{GRILL_RESULT}-entry.
//
// Wordt aangeroepen door de worker als laatste stap van een grill-sessie.
import { z } from 'zod'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { prisma } from '../prisma.js'
import { requireWriteAccess } from '../auth.js'
import { userOwnsIdea } from '../access.js'
import { toolError, toolJson, withToolErrors } from '../errors.js'
const inputSchema = z.object({
idea_id: z.string().min(1),
markdown: z.string().min(1).max(64_000),
})
export function registerUpdateIdeaGrillMdTool(server: McpServer) {
server.registerTool(
'update_idea_grill_md',
{
title: 'Update idea grill_md',
description:
'Save the grill-result markdown for an idea and transition status to GRILLED. Forbidden for demo accounts.',
inputSchema,
},
async ({ idea_id, markdown }) =>
withToolErrors(async () => {
const auth = await requireWriteAccess()
if (!(await userOwnsIdea(idea_id, auth.userId))) {
return toolError('Idea not found')
}
const result = await prisma.$transaction([
prisma.idea.update({
where: { id: idea_id },
data: { grill_md: markdown, status: 'GRILLED' },
select: { id: true, status: true, code: true },
}),
prisma.ideaLog.create({
data: {
idea_id,
type: 'GRILL_RESULT',
content: `Grill result (${markdown.length} chars)`,
metadata: { length: markdown.length },
},
}),
])
return toolJson({
ok: true,
idea: result[0],
})
}),
)
}

View file

@ -1,90 +0,0 @@
// MCP-tool: schrijft het plan_md-resultaat na een IDEA_MAKE_PLAN-job en
// transitioneert de idea-status naar PLAN_READY (bij geldige yaml-frontmatter)
// of PLAN_FAILED (bij parse-fout).
//
// Wordt aangeroepen door de worker als laatste stap van een make-plan-sessie.
import { z } from 'zod'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { prisma } from '../prisma.js'
import { requireWriteAccess } from '../auth.js'
import { userOwnsIdea } from '../access.js'
import { toolError, toolJson, withToolErrors } from '../errors.js'
import { parsePlanMd } from '../lib/idea-plan-parser.js'
const inputSchema = z.object({
idea_id: z.string().min(1),
markdown: z.string().min(1).max(64_000),
})
export function registerUpdateIdeaPlanMdTool(server: McpServer) {
server.registerTool(
'update_idea_plan_md',
{
title: 'Update idea plan_md',
description:
'Save the make-plan-result markdown for an idea. Server validates yaml-frontmatter; on success status → PLAN_READY, on parse-fail → PLAN_FAILED. Forbidden for demo accounts.',
inputSchema,
},
async ({ idea_id, markdown }) =>
withToolErrors(async () => {
const auth = await requireWriteAccess()
if (!(await userOwnsIdea(idea_id, auth.userId))) {
return toolError('Idea not found')
}
const parsed = parsePlanMd(markdown)
if (!parsed.ok) {
// Persist md + flip to PLAN_FAILED + log de errors zodat de UI ze
// aan de user kan tonen.
const result = await prisma.$transaction([
prisma.idea.update({
where: { id: idea_id },
data: { plan_md: markdown, status: 'PLAN_FAILED' },
select: { id: true, status: true, code: true },
}),
prisma.ideaLog.create({
data: {
idea_id,
type: 'JOB_EVENT',
content: 'plan_md parse failed',
metadata: { errors: parsed.errors },
},
}),
])
return toolJson({
ok: false,
idea: result[0],
errors: parsed.errors,
})
}
const result = await prisma.$transaction([
prisma.idea.update({
where: { id: idea_id },
data: { plan_md: markdown, status: 'PLAN_READY' },
select: { id: true, status: true, code: true },
}),
prisma.ideaLog.create({
data: {
idea_id,
type: 'PLAN_RESULT',
content: `Plan ready: ${parsed.plan.stories.length} stories, ${parsed.plan.stories.reduce((n, s) => n + s.tasks.length, 0)} tasks`,
metadata: {
pbi_title: parsed.plan.pbi.title,
story_count: parsed.plan.stories.length,
task_count: parsed.plan.stories.reduce((n, s) => n + s.tasks.length, 0),
},
},
}),
])
return toolJson({
ok: true,
idea: result[0],
})
}),
)
}

View file

@ -1,126 +0,0 @@
// MCP-tool: writes the review-log result after an IDEA_REVIEW_PLAN job and
// transitions idea.status. Only an explicit approval_status='approved' moves
// the idea to PLAN_REVIEWED; anything else (rejected, pending, or omitted)
// goes to PLAN_REVIEW_FAILED — a human must then decide. The tool never
// silently approves.
//
// Called by the worker as the final step of a review-plan session.
import { z } from 'zod'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { prisma } from '../prisma.js'
import { requireWriteAccess } from '../auth.js'
import { userOwnsIdea } from '../access.js'
import { toolError, toolJson, withToolErrors } from '../errors.js'
export const inputSchema = z.object({
idea_id: z.string().min(1),
review_log: z.object({}).passthrough(), // Full ReviewLog from orchestrator (JSON object)
approval_status: z
.enum(['pending', 'approved', 'rejected'] as const)
.optional(),
})
export async function handleUpdateIdeaPlanReviewed(
{ idea_id, review_log, approval_status }: z.infer<typeof inputSchema>,
) {
return withToolErrors(async () => {
const auth = await requireWriteAccess()
if (!(await userOwnsIdea(idea_id, auth.userId))) {
return toolError('Idea not found')
}
// Alleen een expliciete 'approved' brengt het idee naar PLAN_REVIEWED.
// 'rejected', 'pending' én een weggelaten approval_status betekenen
// allemaal "niet auto-goedgekeurd — mens moet beslissen" en gaan naar
// PLAN_REVIEW_FAILED. Nooit stilzwijgend goedkeuren (de vorige
// `: 'PLAN_REVIEWED'`-default deed dat wel bij pending/undefined).
const nextStatus =
approval_status === 'approved' ? 'PLAN_REVIEWED' : 'PLAN_REVIEW_FAILED'
// Log summary metrics from review_log
const logSummary = buildReviewLogSummary(review_log)
const result = await prisma.$transaction([
prisma.idea.update({
where: { id: idea_id },
data: {
plan_review_log: review_log as any,
reviewed_at: new Date(),
status: nextStatus,
},
select: { id: true, status: true, code: true },
}),
prisma.ideaLog.create({
data: {
idea_id,
type: 'PLAN_REVIEW_RESULT',
content: logSummary.summary,
metadata: {
approval_status,
convergence_status: logSummary.convergence_status,
final_score: logSummary.final_score,
rounds_completed: logSummary.rounds_completed,
},
},
}),
])
return toolJson({
ok: true,
idea: result[0],
review_log_summary: logSummary,
})
})
}
export function registerUpdateIdeaPlanReviewedTool(server: McpServer) {
server.registerTool(
'update_idea_plan_reviewed',
{
title: 'Mark plan as reviewed',
description:
'Save review-log after a plan review cycle and transition idea.status. ' +
'Only approval_status="approved" → PLAN_REVIEWED; "rejected", "pending", ' +
'or an omitted approval_status → PLAN_REVIEW_FAILED (needs manual ' +
'approval — never silently approved). Forbidden for demo accounts.',
inputSchema,
},
handleUpdateIdeaPlanReviewed,
)
}
function buildReviewLogSummary(
reviewLog: Record<string, any>,
): {
summary: string
convergence_status: string
final_score: number
rounds_completed: number
} {
const rounds = Array.isArray(reviewLog.rounds) ? reviewLog.rounds : []
const convergence = reviewLog.convergence || {}
const finalScore =
rounds.length > 0 ? rounds[rounds.length - 1].score ?? 0 : 0
const convergenceStatus =
convergence.stable_at_round !== undefined
? `stable at round ${convergence.stable_at_round}`
: convergence.final_diff_pct !== undefined
? `${convergence.final_diff_pct}% diff`
: 'pending'
const summary =
`Plan reviewed in ${rounds.length} rounds. ` +
`Convergence: ${convergenceStatus}. ` +
`Final score: ${finalScore}/100. ` +
`Status: ${reviewLog.approval?.status || 'pending'}.`
return {
summary,
convergence_status: convergenceStatus,
final_score: finalScore,
rounds_completed: rounds.length,
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,102 +0,0 @@
// MCP tool: update een Sprint.
//
// Generieke update — wijzigt elke combinatie van status, sprint_goal,
// start_date en end_date. Géén state-machine validatie (zie
// docs/plans/sprint-mcp-tools.md): last-write-wins, het resubmit/heropen-pad
// zit elders. Bij status → CLOSED/FAILED/ARCHIVED zonder expliciete end_date
// wordt end_date automatisch op vandaag gezet. Bij status → CLOSED wordt
// daarnaast `completed_at` op now() gezet (parity met
// src/lib/tasks-status-update.ts dat hetzelfde doet bij auto-close via
// task-status-cascade; zo houden reporting en UI één bron van waarheid voor
// completion-tijd).
import { z } from 'zod'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SprintStatus } from '@prisma/client'
import { prisma } from '../prisma.js'
import { requireWriteAccess } from '../auth.js'
import { userCanAccessProduct } from '../access.js'
import { toolError, toolJson, withToolErrors } from '../errors.js'
const TERMINAL_STATUSES = new Set<SprintStatus>(['CLOSED', 'FAILED', 'ARCHIVED'])
export const inputSchema = z.object({
sprint_id: z.string().min(1),
status: z.enum(['OPEN', 'CLOSED', 'ARCHIVED', 'FAILED']).optional(),
sprint_goal: z.string().min(1).max(500).optional(),
end_date: z.string().date().optional(),
start_date: z.string().date().optional(),
})
export async function handleUpdateSprint(
{ sprint_id, status, sprint_goal, end_date, start_date }: z.infer<typeof inputSchema>,
) {
return withToolErrors(async () => {
if (
status === undefined &&
sprint_goal === undefined &&
end_date === undefined &&
start_date === undefined
) {
return toolError('Minstens één veld vereist om te wijzigen')
}
const auth = await requireWriteAccess()
const sprint = await prisma.sprint.findUnique({
where: { id: sprint_id },
select: { id: true, product_id: true },
})
if (!sprint) {
return toolError(`Sprint ${sprint_id} not found`)
}
if (!(await userCanAccessProduct(sprint.product_id, auth.userId))) {
return toolError(`Sprint ${sprint_id} not accessible`)
}
const data: {
status?: SprintStatus
sprint_goal?: string
start_date?: Date
end_date?: Date
completed_at?: Date
} = {}
if (status !== undefined) data.status = status
if (sprint_goal !== undefined) data.sprint_goal = sprint_goal
if (start_date !== undefined) data.start_date = new Date(start_date)
if (end_date !== undefined) {
data.end_date = new Date(end_date)
} else if (status !== undefined && TERMINAL_STATUSES.has(status)) {
data.end_date = new Date()
}
if (status === 'CLOSED') data.completed_at = new Date()
const updated = await prisma.sprint.update({
where: { id: sprint_id },
data,
select: {
id: true,
code: true,
sprint_goal: true,
status: true,
start_date: true,
end_date: true,
completed_at: true,
},
})
return toolJson(updated)
})
}
export function registerUpdateSprintTool(server: McpServer) {
server.registerTool(
'update_sprint',
{
title: 'Update Sprint',
description:
'Update a sprint: status, sprint_goal, start_date and/or end_date. At least one field required. No state-machine validation — last-write-wins. When status goes to CLOSED/FAILED/ARCHIVED and end_date is not provided, end_date is set to today. When status goes to CLOSED, completed_at is set to now (parity with auto-close via task-cascade). Forbidden for demo accounts.',
inputSchema,
},
handleUpdateSprint,
)
}

View file

@ -1,110 +0,0 @@
// PBI-50 F3-T2: update_task_execution
//
// SPRINT_IMPLEMENTATION-flow lifecycle-tool. Worker roept dit aan voor elke
// task in de batch om de SprintTaskExecution-row te muteren:
// PENDING → RUNNING → DONE/FAILED/SKIPPED
// Idempotent: dezelfde call kan veilig herhaald worden.
import { z } from 'zod'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { prisma } from '../prisma.js'
import { requireWriteAccess } from '../auth.js'
import { toolError, toolJson, withToolErrors } from '../errors.js'
const inputSchema = z.object({
execution_id: z.string().min(1),
status: z.enum(['PENDING', 'RUNNING', 'DONE', 'FAILED', 'SKIPPED']),
base_sha: z.string().optional(),
head_sha: z.string().optional(),
skip_reason: z.string().max(2000).optional(),
})
export function registerUpdateTaskExecutionTool(server: McpServer) {
server.registerTool(
'update_task_execution',
{
title: 'Update SprintTaskExecution status',
description:
'Mutate a SprintTaskExecution row in a SPRINT_IMPLEMENTATION batch. ' +
'Status: PENDING|RUNNING|DONE|FAILED|SKIPPED. Worker calls this for each ' +
'task transition. Token must own the parent SPRINT_IMPLEMENTATION ClaudeJob. ' +
'Idempotent — safe to retry. Schrijft started_at (RUNNING) en finished_at ' +
'(DONE/FAILED/SKIPPED). Forbidden for demo accounts.',
inputSchema,
},
async ({ execution_id, status, base_sha, head_sha, skip_reason }) =>
withToolErrors(async () => {
const auth = await requireWriteAccess()
const execution = await prisma.sprintTaskExecution.findUnique({
where: { id: execution_id },
select: {
id: true,
sprint_job_id: true,
sprint_job: {
select: { claimed_by_token_id: true, status: true, kind: true },
},
},
})
if (!execution) {
return toolError(`SprintTaskExecution ${execution_id} not found`)
}
if (execution.sprint_job.kind !== 'SPRINT_IMPLEMENTATION') {
return toolError(
`Execution ${execution_id} hangs at job kind ${execution.sprint_job.kind}, expected SPRINT_IMPLEMENTATION`,
)
}
if (execution.sprint_job.claimed_by_token_id !== auth.tokenId) {
return toolError(
`Forbidden: token does not own SPRINT_IMPLEMENTATION job for execution ${execution_id}`,
)
}
if (
execution.sprint_job.status !== 'CLAIMED' &&
execution.sprint_job.status !== 'RUNNING'
) {
return toolError(
`Sprint job is in terminal state ${execution.sprint_job.status}`,
)
}
const now = new Date()
const updated = await prisma.sprintTaskExecution.update({
where: { id: execution_id },
data: {
status,
...(base_sha !== undefined ? { base_sha } : {}),
...(head_sha !== undefined ? { head_sha } : {}),
...(skip_reason !== undefined ? { skip_reason } : {}),
...(status === 'RUNNING' ? { started_at: now } : {}),
...(status === 'DONE' || status === 'FAILED' || status === 'SKIPPED'
? { finished_at: now }
: {}),
},
select: {
id: true,
status: true,
base_sha: true,
head_sha: true,
verify_result: true,
verify_summary: true,
skip_reason: true,
started_at: true,
finished_at: true,
},
})
return toolJson({
execution_id: updated.id,
status: updated.status,
base_sha: updated.base_sha,
head_sha: updated.head_sha,
verify_result: updated.verify_result,
verify_summary: updated.verify_summary,
skip_reason: updated.skip_reason,
started_at: updated.started_at?.toISOString() ?? null,
finished_at: updated.finished_at?.toISOString() ?? null,
})
}),
)
}

View file

@ -1,6 +1,5 @@
import { z } from 'zod'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { prisma } from '../prisma.js'
import { requireWriteAccess } from '../auth.js'
import { userCanAccessTask } from '../access.js'
import { toolError, toolJson, withToolErrors } from '../errors.js'
@ -10,10 +9,6 @@ import { updateTaskStatusWithStoryPromotion } from '../lib/tasks-status-update.j
const inputSchema = z.object({
task_id: z.string().min(1),
status: z.enum(TASK_STATUS_API_VALUES as [string, ...string[]]),
// PBI-50: optionele sprint_run_id voor SPRINT_IMPLEMENTATION-flow.
// Wanneer aanwezig: server valideert dat task in deze sprint zit, run
// actief is, en de huidige token een ClaudeJob in deze run heeft geclaimt.
sprint_run_id: z.string().min(1).optional(),
})
export function registerUpdateTaskStatusTool(server: McpServer) {
@ -22,14 +17,11 @@ export function registerUpdateTaskStatusTool(server: McpServer) {
{
title: 'Update task status',
description:
'Set the status of a task. Allowed values: todo, in_progress, review, done, failed. ' +
'Optional sprint_run_id binds the update to a SPRINT_IMPLEMENTATION run for ' +
'cascade-propagation; the server validates that the task belongs to the sprint ' +
'and that the calling token has claimed a job in that run. ' +
'Set the status of a task. Allowed values: todo, in_progress, review, done. ' +
'Forbidden for demo accounts.',
inputSchema,
},
async ({ task_id, status, sprint_run_id }) =>
async ({ task_id, status }) =>
withToolErrors(async () => {
const auth = await requireWriteAccess()
const dbStatus = taskStatusFromApi(status)
@ -39,74 +31,15 @@ export function registerUpdateTaskStatusTool(server: McpServer) {
if (!(await userCanAccessTask(task_id, auth.userId))) {
return toolError(`Task ${task_id} not found or not accessible`)
}
// PBI-50: validate explicit sprint_run_id binding.
if (sprint_run_id) {
const sprintRun = await prisma.sprintRun.findUnique({
where: { id: sprint_run_id },
select: { id: true, status: true, sprint_id: true },
})
if (!sprintRun) {
return toolError(`SprintRun ${sprint_run_id} not found`)
}
if (
sprintRun.status !== 'QUEUED' &&
sprintRun.status !== 'RUNNING' &&
sprintRun.status !== 'PAUSED'
) {
return toolError(
`SprintRun ${sprint_run_id} is in terminal state ${sprintRun.status}; cannot update task status against it`,
)
}
// Task moet in deze sprint zitten
const task = await prisma.task.findUnique({
where: { id: task_id },
select: { story: { select: { sprint_id: true } } },
})
if (!task || task.story.sprint_id !== sprintRun.sprint_id) {
return toolError(
`Task ${task_id} is not in sprint ${sprintRun.sprint_id} (sprint_run ${sprint_run_id})`,
)
}
// Token-coupling: huidige token moet een actieve ClaudeJob in deze
// SprintRun hebben geclaimt (typisch de SPRINT_IMPLEMENTATION-job).
const tokenJob = await prisma.claudeJob.findFirst({
where: {
sprint_run_id,
claimed_by_token_id: auth.tokenId,
status: { in: ['CLAIMED', 'RUNNING'] },
},
select: { id: true },
})
if (!tokenJob) {
return toolError(
`Forbidden: current token has no active claim in sprint_run ${sprint_run_id}`,
)
}
}
const { task, storyStatusChange, sprintRunChanged } =
await updateTaskStatusWithStoryPromotion(task_id, dbStatus, undefined, sprint_run_id)
// Voor SPRINT-flow: stuur expliciete sprint_run_status mee zodat
// worker zijn loop kan breken bij FAILED/PAUSED zonder extra query.
let sprintRunStatusChange: string | null = null
if (sprintRunChanged && sprint_run_id) {
const updated = await prisma.sprintRun.findUnique({
where: { id: sprint_run_id },
select: { status: true },
})
sprintRunStatusChange = updated?.status ?? null
}
const { task, storyStatusChange } = await updateTaskStatusWithStoryPromotion(
task_id,
dbStatus,
)
return toolJson({
id: task.id,
status: taskStatusToApi(task.status),
implementation_plan: task.implementation_plan,
story_status_change: storyStatusChange,
sprint_run_status_change: sprintRunStatusChange,
})
}),
)

View file

@ -1,151 +0,0 @@
// PBI-50 F3-T1: verify_sprint_task
//
// Execution-aware verify-tool voor SPRINT_IMPLEMENTATION-flow.
// Verschilt van verify_task_against_plan in:
// - input via execution_id (niet task_id)
// - base_sha komt uit SprintTaskExecution.base_sha; voor task[1..N] zonder
// base_sha vult de tool dynamisch met head_sha van vorige DONE-execution
// - plan_snapshot komt uit execution.plan_snapshot (frozen op claim-tijd)
// - resultaat opgeslagen op execution-row, niet op ClaudeJob.verify_result
// - response geeft allowed_for_done direct mee
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { z } from 'zod'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { prisma } from '../prisma.js'
import { requireWriteAccess } from '../auth.js'
import { toolError, toolJson, withToolErrors } from '../errors.js'
import { classifyDiffAgainstPlan } from '../verify/classify.js'
import { checkVerifyGate } from './update-job-status.js'
const exec = promisify(execFile)
const inputSchema = z.object({
execution_id: z.string().min(1),
worktree_path: z.string().min(1),
summary: z.string().max(2000).optional(),
})
export function registerVerifySprintTaskTool(server: McpServer) {
server.registerTool(
'verify_sprint_task',
{
title: 'Verify SprintTaskExecution against frozen plan',
description:
'Run `git diff <base_sha>...HEAD` in the worktree and classify against the ' +
'frozen plan_snapshot of this SprintTaskExecution. Returns ALIGNED|PARTIAL|EMPTY|' +
'DIVERGENT plus reasoning + allowed_for_done (computed via the standard verify-gate ' +
'with the execution\'s frozen verify_required/verify_only). ' +
'For task[1..N] zonder base_sha vult de tool die in op basis van de head_sha van de ' +
'vorige DONE-execution. Optional summary is opgeslagen voor PARTIAL/DIVERGENT-rationale ' +
'en gebruikt door de gate. ' +
'Call this BEFORE update_task_execution(DONE) for each task in the sprint batch. ' +
'Forbidden for demo accounts.',
inputSchema,
annotations: { readOnlyHint: false },
},
async ({ execution_id, worktree_path, summary }) =>
withToolErrors(async () => {
const auth = await requireWriteAccess()
const execution = await prisma.sprintTaskExecution.findUnique({
where: { id: execution_id },
select: {
id: true,
sprint_job_id: true,
order: true,
base_sha: true,
plan_snapshot: true,
verify_required_snapshot: true,
verify_only_snapshot: true,
sprint_job: {
select: { claimed_by_token_id: true, status: true, kind: true },
},
},
})
if (!execution) {
return toolError(`SprintTaskExecution ${execution_id} not found`)
}
if (execution.sprint_job.kind !== 'SPRINT_IMPLEMENTATION') {
return toolError(
`Execution ${execution_id} hangs at job kind ${execution.sprint_job.kind}, expected SPRINT_IMPLEMENTATION`,
)
}
if (execution.sprint_job.claimed_by_token_id !== auth.tokenId) {
return toolError(
`Forbidden: token does not own SPRINT_IMPLEMENTATION job for execution ${execution_id}`,
)
}
// Resolve base_sha. Voor task[0] is dit gevuld bij claim. Voor
// task[1..N] wordt dit dynamisch gevuld op basis van de vorige
// DONE-execution's head_sha. Persist na fill zodat herhaalde calls
// dezelfde base gebruiken.
let baseSha = execution.base_sha
if (!baseSha) {
const previousDone = await prisma.sprintTaskExecution.findFirst({
where: {
sprint_job_id: execution.sprint_job_id,
order: { lt: execution.order },
status: 'DONE',
head_sha: { not: null },
},
orderBy: { order: 'desc' },
select: { head_sha: true },
})
if (!previousDone?.head_sha) {
return toolError(
`MISSING_BASE_SHA: execution ${execution_id} has no base_sha and no previous DONE-execution with head_sha. Did you skip update_task_execution(DONE) on a prior task?`,
)
}
baseSha = previousDone.head_sha
await prisma.sprintTaskExecution.update({
where: { id: execution_id },
data: { base_sha: baseSha },
})
}
let diff: string
try {
const { stdout } = await exec('git', ['diff', `${baseSha}...HEAD`], {
cwd: worktree_path,
})
diff = stdout
} catch (err) {
return toolError(
`git diff failed in worktree (${worktree_path}): ${(err as Error).message ?? 'unknown error'}`,
)
}
const { result, reasoning } = classifyDiffAgainstPlan({
diff,
plan: execution.plan_snapshot,
})
await prisma.sprintTaskExecution.update({
where: { id: execution_id },
data: {
verify_result: result,
...(summary !== undefined ? { verify_summary: summary } : {}),
},
})
const gate = checkVerifyGate(
result,
execution.verify_only_snapshot,
execution.verify_required_snapshot,
summary,
)
return toolJson({
execution_id: execution.id,
result: result.toLowerCase() as 'aligned' | 'partial' | 'empty' | 'divergent',
reasoning,
base_sha: baseSha,
allowed_for_done: gate.allowed,
reason: gate.allowed ? null : gate.error,
})
}),
)
}

View file

@ -15,15 +15,8 @@ const inputSchema = z.object({
worktree_path: z.string().min(1),
})
export async function getDiffInWorktree(
worktreePath: string,
baseSha?: string,
): Promise<string> {
// PBI-47 (P0): when base_sha is provided, diff against the per-job base
// captured at claim-time so verify only sees the current task's changes.
// Falls back to origin/main only for legacy callers without base_sha.
const range = baseSha ? `${baseSha}...HEAD` : 'origin/main...HEAD'
const { stdout } = await exec('git', ['diff', range], { cwd: worktreePath })
export async function getDiffInWorktree(worktreePath: string): Promise<string> {
const { stdout } = await exec('git', ['diff', 'origin/main...HEAD'], { cwd: worktreePath })
return stdout
}
@ -65,7 +58,7 @@ export function registerVerifyTaskAgainstPlanTool(server: McpServer) {
where: { status: { in: ['CLAIMED', 'RUNNING'] } },
orderBy: { created_at: 'desc' },
take: 1,
select: { id: true, plan_snapshot: true, base_sha: true },
select: { id: true, plan_snapshot: true },
},
},
})
@ -74,19 +67,9 @@ export function registerVerifyTaskAgainstPlanTool(server: McpServer) {
const activeJob = task.claude_jobs[0] ?? null
// PBI-47 (P0): require base_sha so diff is scoped to this job's work,
// not the full origin/main...HEAD which would include sibling commits
// on a reused story/sprint branch.
if (activeJob && !activeJob.base_sha) {
return toolError(
'MISSING_BASE_SHA: This claim has no base_sha. '
+ 'Re-claim the task (cancel + wait_for_job) so a fresh base_sha is captured.',
)
}
let diff: string
try {
diff = await getDiffInWorktree(worktree_path, activeJob?.base_sha ?? undefined)
diff = await getDiffInWorktree(worktree_path)
} catch (err) {
return toolError(
`git diff failed in worktree (${worktree_path}): ${(err as Error).message ?? 'unknown error'}`,

View file

@ -7,18 +7,10 @@ import { Client } from 'pg'
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { prisma } from '../prisma.js'
const execFileP = promisify(execFile)
import { requireWriteAccess } from '../auth.js'
import { toolJson, toolError, withToolErrors } from '../errors.js'
import { createWorktreeForJob } from '../git/worktree.js'
import { getWorktreeRoot } from '../git/worktree-paths.js'
import { setupProductWorktrees, releaseLocksOnTerminal } from '../git/job-locks.js'
import { pushBranchForJob } from '../git/push.js'
import { resolveJobConfig } from '../lib/job-config.js'
/** Parse `https://github.com/<owner>/<name>(.git)?` → `<name>`. */
export function repoNameFromUrl(repoUrl: string | null | undefined): string | null {
@ -27,60 +19,22 @@ export function repoNameFromUrl(repoUrl: string | null | undefined): string | nu
return m ? m[1] : null
}
/**
* Resolve the repo-root path on disk for a job's worktree.
*
* Lookup order (first hit wins):
* 1. `task.repo_url`-override match against config / convention via repo-name
* 2. env var `SCRUM4ME_REPO_ROOT_<productId>`
* 3. `~/.scrum4me-agent-config.json` `repoRoots[productId]`
* 4. Convention `~/Projects/<repo-name-from-product.repo_url>/.git`
*
* The task-level override exists for cross-repo tasks (e.g. an MCP-server
* task tracked under the main product's PBI). Falls back to product-level
* resolution when null. Documented in CLAUDE.md.
*/
export async function resolveRepoRoot(
productId: string,
taskRepoUrl?: string | null,
): Promise<string | null> {
// 1. Task-level override: match by repo-name through config/convention
if (taskRepoUrl) {
const taskRepoName = repoNameFromUrl(taskRepoUrl)
if (taskRepoName) {
const overrideEnv = `SCRUM4ME_REPO_ROOT_REPO_${taskRepoName}`
if (process.env[overrideEnv]) return process.env[overrideEnv]!
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> }
if (config.repoRoots?.[taskRepoName]) return config.repoRoots[taskRepoName]
} catch { /* fall through */ }
const candidate = path.join(os.homedir(), 'Projects', taskRepoName)
try {
await fs.access(path.join(candidate, '.git'))
return candidate
} catch { /* fall through to product-level */ }
}
}
// 2. Env var per-product
export async function resolveRepoRoot(productId: string): Promise<string | null> {
const envKey = `SCRUM4ME_REPO_ROOT_${productId}`
if (process.env[envKey]) return process.env[envKey]!
// 3. Config file per-product
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> }
if (config.repoRoots?.[productId]) return config.repoRoots[productId]
} catch {
// ignore — fall through
// ignore — fall through to convention-based fallback
}
// 4. Convention via product.repo_url
// Convention-based fallback: ~/Projects/<repo-name> with .git/ inside.
// Lets the agent work without explicit env-config when checkouts follow
// the standard ~/Projects/<name> layout.
try {
const product = await prisma.product.findUnique({
where: { id: productId },
@ -119,35 +73,6 @@ export async function resolveBranchForJob(
jobId: string,
storyId: string,
): Promise<{ branchName: string; reused: boolean }> {
// Sprint-flow (PBI-46): als deze job aan een SprintRun hangt, kies de branch
// op basis van Product.pr_strategy:
// SPRINT → feat/sprint-<sprint_run_id-suffix> (één branch voor hele run)
// STORY → feat/story-<story_id-suffix> (één branch per story; sibling-tasks delen 'm)
// Voor legacy task-jobs zonder sprint_run_id valt de logica terug op het
// bestaande feat/story-<storyId>-pad.
const job = await prisma.claudeJob.findUnique({
where: { id: jobId },
select: {
sprint_run_id: true,
sprint_run: { select: { id: true, pr_strategy: true } },
},
})
if (job?.sprint_run && job.sprint_run.pr_strategy === 'SPRINT') {
const branchName = `feat/sprint-${job.sprint_run.id.slice(-8)}`
const sibling = await prisma.claudeJob.findFirst({
where: {
sprint_run_id: job.sprint_run_id,
branch: branchName,
id: { not: jobId },
},
orderBy: { created_at: 'asc' },
select: { branch: true },
})
return { branchName, reused: sibling !== null }
}
// STORY-mode (default) of legacy: branch per story
const sibling = await prisma.claudeJob.findFirst({
where: {
task: { story_id: storyId },
@ -165,19 +90,14 @@ export async function attachWorktreeToJob(
productId: string,
jobId: string,
storyId: string,
taskRepoUrl?: string | null,
): Promise<{ worktree_path: string; branch_name: string; reused_branch: boolean } | { error: string }> {
const repoRoot = await resolveRepoRoot(productId, taskRepoUrl)
const repoRoot = await resolveRepoRoot(productId)
if (!repoRoot) {
await rollbackClaim(jobId)
const repoHint = taskRepoUrl
? `task.repo_url=${taskRepoUrl}`
: `product ${productId}`
return {
error:
`No repo root configured for ${repoHint}. ` +
`Set env var SCRUM4ME_REPO_ROOT_${productId}, add a repoRoots entry to ~/.scrum4me-agent-config.json, ` +
`or place a clone at ~/Projects/<repo-name>.`,
`No repo root configured for product ${productId}. ` +
`Set env var SCRUM4ME_REPO_ROOT_${productId} or add to ~/.scrum4me-agent-config.json.`,
}
}
@ -189,32 +109,6 @@ export async function attachWorktreeToJob(
branchName,
reuseBranch: reused,
})
// PBI-47 (P0): capture base_sha so verify_task_against_plan can diff
// against the claim-time HEAD instead of origin/main. For reused branches
// (siblings already pushed), base_sha = SHA of the worktree HEAD now.
// For fresh branches, base_sha = origin/main HEAD which createWorktreeForJob
// just checked out.
let baseSha: string | null = null
try {
const { stdout } = await execFileP('git', ['rev-parse', 'HEAD'], { cwd: worktreePath })
baseSha = stdout.trim()
} catch (err) {
console.warn(`[attachWorktreeToJob] failed to resolve base_sha for ${jobId}:`, err)
}
// Persist branch + base_sha. update_job_status (prepareDoneUpdate)
// leest claudeJob.branch om naar de juiste ref te pushen — zonder deze
// update valt 'ie terug op het legacy `feat/job-<8>` patroon en faalt
// de push met "src refspec ... does not match any" voor sprint/story
// strategy branches.
await prisma.claudeJob.update({
where: { id: jobId },
data: {
branch: actualBranch,
...(baseSha ? { base_sha: baseSha } : {}),
},
})
return { worktree_path: worktreePath, branch_name: actualBranch, reused_branch: reused }
} catch (err) {
await rollbackClaim(jobId)
@ -234,96 +128,40 @@ const inputSchema = z.object({
const STALE_ERROR_MSG = 'agent did not complete job within 2 attempts'
export async function resetStaleClaimedJobs(userId: string): Promise<void> {
// PBI-50: lease-driven stale-detection. Jobs in CLAIMED of RUNNING met
// verlopen lease_until (default 5 min, verlengd door job_heartbeat) worden
// gereset. Legacy jobs zonder lease_until vallen terug op de oude
// claimed_at + 30-min-regel.
type StaleRow = {
id: string
task_id: string | null
product_id: string
kind: string
sprint_run_id: string | null
branch: string | null
}
const failedRows = await prisma.$queryRaw<StaleRow[]>`
// Jobs that exceeded the retry limit → FAILED
const failedRows = await prisma.$queryRaw<
Array<{ id: string; task_id: string; product_id: string }>
>`
UPDATE claude_jobs
SET status = 'FAILED',
finished_at = NOW(),
error = ${STALE_ERROR_MSG}
WHERE user_id = ${userId}
AND status IN ('CLAIMED', 'RUNNING')
AND status = 'CLAIMED'
AND claimed_at < NOW() - INTERVAL '30 minutes'
AND retry_count >= 2
AND (
lease_until < NOW()
OR (lease_until IS NULL AND claimed_at < NOW() - INTERVAL '30 minutes')
)
RETURNING id, task_id, product_id, kind::text AS kind, sprint_run_id, branch
RETURNING id, task_id, product_id
`
// Jobs under the retry limit → back to QUEUED, increment retry_count
const requeuedRows = await prisma.$queryRaw<
(StaleRow & { retry_count: number })[]
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,
lease_until = NULL,
retry_count = retry_count + 1
WHERE user_id = ${userId}
AND status IN ('CLAIMED', 'RUNNING')
AND status = 'CLAIMED'
AND claimed_at < NOW() - INTERVAL '30 minutes'
AND retry_count < 2
AND (
lease_until < NOW()
OR (lease_until IS NULL AND claimed_at < NOW() - INTERVAL '30 minutes')
)
RETURNING id, task_id, product_id, kind::text AS kind, sprint_run_id, branch, retry_count
RETURNING id, task_id, product_id, retry_count
`
if (failedRows.length === 0 && requeuedRows.length === 0) return
// PBI-9: release any product-worktree locks held by these stale jobs.
for (const j of failedRows) await releaseLocksOnTerminal(j.id)
for (const j of requeuedRows) await releaseLocksOnTerminal(j.id)
// PBI-50: voor stale FAILED SPRINT_IMPLEMENTATION jobs — push de branch
// zodat het werk niet verloren gaat (geen mark-ready / PR-promotie),
// en zet SprintRun.failure_reason met een verwijzing naar de laatst
// RUNNING execution voor diagnose.
for (const j of failedRows.filter((r) => r.kind === 'SPRINT_IMPLEMENTATION')) {
if (j.branch && j.product_id) {
const repoRoot = await resolveRepoRoot(j.product_id).catch(() => null)
if (repoRoot) {
const worktreeDir = getWorktreeRoot()
const worktreePath = path.join(worktreeDir, j.id)
try {
await pushBranchForJob({ worktreePath, branchName: j.branch })
} catch (err) {
console.warn(`[stale-reset] push failed for stale sprint-job ${j.id}:`, err)
}
}
}
if (j.sprint_run_id) {
const lastRunning = await prisma.sprintTaskExecution.findFirst({
where: { sprint_job_id: j.id, status: 'RUNNING' },
orderBy: { order: 'desc' },
select: { order: true, task_id: true },
})
const reasonSuffix = lastRunning
? `, last execution: order ${lastRunning.order} task ${lastRunning.task_id}`
: ''
await prisma.sprintRun.update({
where: { id: j.sprint_run_id },
data: {
status: 'FAILED',
failure_reason: `stale: lease verlopen${reasonSuffix}`,
},
})
}
}
// Notify UI via SSE for each transition (best-effort)
try {
const pg = new Client({ connectionString: process.env.DATABASE_URL })
@ -366,54 +204,27 @@ export async function tryClaimJob(
tokenId: string,
productId?: string,
): Promise<string | null> {
// Atomic claim in a single transaction — also captures plan_snapshot from task.
//
// PBI-50: claim-filter discrimineert via cj.kind:
// - IDEA_GRILL/IDEA_MAKE_PLAN/PLAN_CHAT: standalone idea-jobs.
// - TASK_IMPLEMENTATION/SPRINT_IMPLEMENTATION: alleen via actieve SprintRun
// (status QUEUED of RUNNING). Legacy task-jobs zonder sprint_run_id en
// jobs in PAUSED/FAILED/CANCELLED/DONE SprintRuns worden overgeslagen.
// Bij eerste claim van een nog QUEUED SprintRun → status RUNNING.
//
// PBI-50 lease: lease_until = NOW() + 5min op claim. resetStaleClaimedJobs
// reset bij verlopen lease.
// Atomic claim in a single transaction — also captures plan_snapshot from task
const rows = await prisma.$transaction(async (tx) => {
// SELECT FOR UPDATE OF claude_jobs SKIP LOCKED — join tasks to read implementation_plan
const found = productId
? await tx.$queryRaw<
Array<{ id: string; implementation_plan: string | null; sprint_run_id: string | null }>
>`
SELECT cj.id, t.implementation_plan, cj.sprint_run_id
? await tx.$queryRaw<Array<{ id: string; implementation_plan: string | null }>>`
SELECT cj.id, t.implementation_plan
FROM claude_jobs cj
LEFT JOIN tasks t ON t.id = cj.task_id
LEFT JOIN sprint_runs sr ON sr.id = cj.sprint_run_id
JOIN tasks t ON t.id = cj.task_id
WHERE cj.user_id = ${userId}
AND cj.product_id = ${productId}
AND cj.status = 'QUEUED'
AND (
cj.kind IN ('IDEA_GRILL', 'IDEA_MAKE_PLAN', 'PLAN_CHAT')
OR (cj.kind IN ('TASK_IMPLEMENTATION', 'SPRINT_IMPLEMENTATION')
AND cj.sprint_run_id IS NOT NULL
AND sr.status IN ('QUEUED', 'RUNNING'))
)
ORDER BY cj.created_at ASC
LIMIT 1
FOR UPDATE OF cj SKIP LOCKED
`
: await tx.$queryRaw<
Array<{ id: string; implementation_plan: string | null; sprint_run_id: string | null }>
>`
SELECT cj.id, t.implementation_plan, cj.sprint_run_id
: await tx.$queryRaw<Array<{ id: string; implementation_plan: string | null }>>`
SELECT cj.id, t.implementation_plan
FROM claude_jobs cj
LEFT JOIN tasks t ON t.id = cj.task_id
LEFT JOIN sprint_runs sr ON sr.id = cj.sprint_run_id
JOIN tasks t ON t.id = cj.task_id
WHERE cj.user_id = ${userId}
AND cj.status = 'QUEUED'
AND (
cj.kind IN ('IDEA_GRILL', 'IDEA_MAKE_PLAN', 'PLAN_CHAT')
OR (cj.kind IN ('TASK_IMPLEMENTATION', 'SPRINT_IMPLEMENTATION')
AND cj.sprint_run_id IS NOT NULL
AND sr.status IN ('QUEUED', 'RUNNING'))
)
ORDER BY cj.created_at ASC
LIMIT 1
FOR UPDATE OF cj SKIP LOCKED
@ -423,36 +234,21 @@ export async function tryClaimJob(
const jobId = found[0].id
const snapshot = found[0].implementation_plan ?? ''
const sprintRunId = found[0].sprint_run_id
await tx.$executeRaw`
UPDATE claude_jobs
SET status = 'CLAIMED',
claimed_by_token_id = ${tokenId},
claimed_at = NOW(),
plan_snapshot = ${snapshot},
lease_until = NOW() + INTERVAL '5 minutes'
plan_snapshot = ${snapshot}
WHERE id = ${jobId}
`
// SprintRun QUEUED → RUNNING bij eerste claim, in dezelfde tx zodat
// concurrent claims dezelfde overgang niet dubbel doen (UPDATE skipt
// rows die al RUNNING zijn).
if (sprintRunId) {
await tx.$executeRaw`
UPDATE sprint_runs
SET status = 'RUNNING',
started_at = COALESCE(started_at, NOW()),
updated_at = NOW()
WHERE id = ${sprintRunId} AND status = 'QUEUED'
`
}
return [{ id: jobId }]
})
return rows.length > 0 ? rows[0].id : null
}
export async function getFullJobContext(jobId: string) {
async function getFullJobContext(jobId: string) {
const job = await prisma.claudeJob.findUnique({
where: { id: jobId },
include: {
@ -466,312 +262,24 @@ export async function getFullJobContext(jobId: string) {
},
},
},
idea: {
include: {
pbi: { select: { id: true, code: true, title: true } },
secondary_products: {
include: { product: { select: { id: true, repo_url: true } } },
},
},
},
product: {
select: {
id: true,
name: true,
repo_url: true,
definition_of_done: true,
preferred_model: true,
thinking_budget_default: true,
preferred_permission_mode: true,
},
},
product: { select: { id: true, name: true, repo_url: true } },
},
})
if (!job) return null
// PBI-67: model + mode-selectie. Resolved op claim-moment; override-cascade
// task.requires_opus → job.requested_* → product.preferred_* → kind-default.
const config = resolveJobConfig(
{
kind: job.kind,
requested_model: job.requested_model,
requested_thinking_budget: job.requested_thinking_budget,
requested_permission_mode: job.requested_permission_mode,
},
{
preferred_model: job.product.preferred_model,
thinking_budget_default: job.product.thinking_budget_default,
preferred_permission_mode: job.product.preferred_permission_mode,
},
job.task ? { requires_opus: job.task.requires_opus } : undefined,
)
// M12: branch on kind. Idea-jobs hebben geen task/story/pbi/sprint; ze
// hebben in plaats daarvan idea + embedded prompt_text.
if (job.kind === 'IDEA_GRILL' || job.kind === 'IDEA_MAKE_PLAN' || job.kind === 'IDEA_REVIEW_PLAN') {
if (!job.idea) return null
const { idea } = job
const { getIdeaPromptText } = await import('../lib/kind-prompts.js')
// Setup persistent product-worktrees for this idea-job (PBI-9).
// Primary product is gated by repo_url via resolveRepoRoot returning null.
// Secondary products from IdeaProduct[] need explicit repo_url filter.
const involvedProductIds: string[] = []
if (idea.product_id) involvedProductIds.push(idea.product_id)
for (const ip of idea.secondary_products ?? []) {
if (ip.product?.repo_url && !involvedProductIds.includes(ip.product_id)) {
involvedProductIds.push(ip.product_id)
}
}
// PBI-49 P1: rollback the claim if worktree setup fails so the job
// doesn't hang in CLAIMED until the 30-min stale-reset, and any partial
// locks are released. Mirrors attachWorktreeToJob's task-pad behaviour.
let worktrees: Array<{ productId: string; worktreePath: string }> = []
if (involvedProductIds.length > 0) {
try {
worktrees = await setupProductWorktrees(
job.id,
involvedProductIds,
(pid) => resolveRepoRoot(pid),
)
} catch (err) {
console.warn(
`[wait-for-job] product-worktree setup failed for idea-job ${job.id}; rolling back claim:`,
err,
)
await releaseLocksOnTerminal(job.id)
await rollbackClaim(job.id)
return null
}
}
return {
job_id: job.id,
kind: job.kind,
status: 'claimed',
config,
idea: {
id: idea.id,
code: idea.code,
title: idea.title,
description: idea.description,
grill_md: idea.grill_md,
plan_md: idea.plan_md,
status: idea.status,
product_id: idea.product_id,
},
product: {
id: job.product.id,
name: job.product.name,
repo_url: job.product.repo_url,
definition_of_done: job.product.definition_of_done,
},
pbi: idea.pbi,
repo_url: job.product.repo_url,
prompt_text: getIdeaPromptText(job.kind),
branch_suggestion: `feat/idea-${idea.code.toLowerCase()}-${(() => {
if (job.kind === 'IDEA_GRILL') return 'grill'
if (job.kind === 'IDEA_REVIEW_PLAN') return 'review'
return 'plan'
})()}`,
product_worktrees: worktrees.map((w) => ({
product_id: w.productId,
worktree_path: w.worktreePath,
})),
primary_worktree_path: worktrees[0]?.worktreePath ?? null,
}
}
// PBI-50: SPRINT_IMPLEMENTATION — single-session sprint runner.
// Eén ClaudeJob per SprintRun handelt sequentieel alle TO_DO-tasks af.
// Bij claim: maak frozen scope-snapshot via SprintTaskExecution-rows,
// resolve worktree (verse branch of hergebruikt via previous_run_id),
// capture base_sha. Worker werkt uitsluitend op deze frozen snapshot.
if (job.kind === 'SPRINT_IMPLEMENTATION') {
if (!job.sprint_run_id) {
await rollbackClaim(job.id)
return null
}
const sprintRun = await prisma.sprintRun.findUnique({
where: { id: job.sprint_run_id },
include: {
sprint: {
include: {
product: true,
stories: {
where: { status: { not: 'DONE' } },
include: {
pbi: {
select: { id: true, code: true, title: true, priority: true, sort_order: true, status: true },
},
tasks: {
where: { status: 'TO_DO' },
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
},
},
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
},
},
},
},
})
if (!sprintRun) {
await rollbackClaim(job.id)
return null
}
const repoRoot = await resolveRepoRoot(sprintRun.sprint.product_id)
if (!repoRoot) {
await rollbackClaim(job.id)
return null
}
// Branch resolution: previous_run_id + branch → reuse; anders verse.
const isResume = !!(sprintRun.previous_run_id && sprintRun.branch)
const branchName = isResume
? sprintRun.branch!
: `feat/sprint-${job.sprint_run_id.slice(-8)}`
let worktreePath: string
let baseSha: string
try {
const wt = await createWorktreeForJob({
repoRoot,
jobId: job.id,
branchName,
reuseBranch: isResume,
})
worktreePath = wt.worktreePath
const { stdout: headSha } = await execFileP('git', ['rev-parse', 'HEAD'], {
cwd: worktreePath,
})
baseSha = headSha.trim()
} catch (err) {
console.warn(`[wait-for-job] sprint-worktree setup failed for ${job.id}:`, err)
await rollbackClaim(job.id)
return null
}
// Verzamel ordered tasks in flat list, behoud volgorde
const orderedTasks = sprintRun.sprint.stories.flatMap((s) =>
s.tasks.map((t) => ({ ...t, story_pbi_id: s.pbi.id })),
)
// Persist branch + base_sha + scope-snapshot in één transactie
await prisma.$transaction([
prisma.claudeJob.update({
where: { id: job.id },
data: { branch: branchName, base_sha: baseSha },
}),
prisma.sprintTaskExecution.createMany({
data: orderedTasks.map((t, idx) => ({
sprint_job_id: job.id,
task_id: t.id,
order: idx,
plan_snapshot: t.implementation_plan ?? '',
verify_required_snapshot: t.verify_required,
verify_only_snapshot: t.verify_only,
base_sha: idx === 0 ? baseSha : null,
status: 'PENDING' as const,
})),
}),
prisma.sprintRun.update({
where: { id: job.sprint_run_id },
data: { branch: branchName },
}),
])
// Lookup execution_ids in volgorde voor de response
const executions = await prisma.sprintTaskExecution.findMany({
where: { sprint_job_id: job.id },
orderBy: { order: 'asc' },
select: { id: true, task_id: true, order: true, base_sha: true },
})
const execIdByTaskId = new Map(executions.map((e) => [e.task_id, e.id]))
// Dedupe PBIs uit de stories (één PBI kan meerdere stories hebben)
const pbiMap = new Map<string, typeof sprintRun.sprint.stories[number]['pbi']>()
for (const s of sprintRun.sprint.stories) pbiMap.set(s.pbi.id, s.pbi)
return {
job_id: job.id,
kind: job.kind,
status: 'claimed',
config,
sprint: {
id: sprintRun.sprint.id,
sprint_goal: sprintRun.sprint.sprint_goal,
status: sprintRun.sprint.status,
},
sprint_run: {
id: sprintRun.id,
pr_strategy: sprintRun.pr_strategy,
branch: branchName,
previous_run_id: sprintRun.previous_run_id,
},
product: {
id: sprintRun.sprint.product.id,
name: sprintRun.sprint.product.name,
repo_url: sprintRun.sprint.product.repo_url,
definition_of_done: sprintRun.sprint.product.definition_of_done,
auto_pr: sprintRun.sprint.product.auto_pr,
},
pbis: Array.from(pbiMap.values()).map((p) => ({
id: p.id,
code: p.code,
title: p.title,
priority: p.priority,
sort_order: p.sort_order,
status: p.status,
})),
stories: sprintRun.sprint.stories.map((s) => ({
id: s.id,
code: s.code,
title: s.title,
pbi_id: s.pbi_id,
priority: s.priority,
sort_order: s.sort_order,
status: s.status,
})),
task_executions: orderedTasks.map((t, idx) => ({
execution_id: execIdByTaskId.get(t.id)!,
task_id: t.id,
code: t.code,
title: t.title,
story_id: t.story_id,
order: idx,
plan_snapshot: t.implementation_plan ?? '',
verify_required: t.verify_required,
verify_only: t.verify_only,
base_sha: idx === 0 ? baseSha : null,
})),
worktree_path: worktreePath,
branch_name: branchName,
repo_url: sprintRun.sprint.product.repo_url,
base_sha: baseSha,
heartbeat_interval_seconds: 60,
}
}
// TASK_IMPLEMENTATION (default) — bestaande gedrag onaangetast.
const { task } = job
if (!task) return null
const { story } = task
const { pbi, sprint } = story
return {
job_id: job.id,
kind: job.kind,
status: 'claimed',
config,
task: {
id: task.id,
title: task.title,
description: task.description,
implementation_plan: task.implementation_plan,
priority: task.priority,
repo_url: task.repo_url,
},
story: {
id: story.id,
@ -826,23 +334,9 @@ export function registerWaitForJobTool(server: McpServer) {
if (jobId) {
const ctx = await getFullJobContext(jobId)
if (!ctx) return toolError('Job claimed but context fetch failed')
// M12: idee-jobs hebben geen worktree nodig — de agent werkt in de
// bestaande user-repo (geen branch/commit-flow). Alleen task-jobs
// krijgen een worktree.
if (ctx.kind === 'TASK_IMPLEMENTATION') {
if (!ctx.story || !ctx.task) {
return toolError('Task-job claimed but story/task context is incomplete')
}
const wt = await attachWorktreeToJob(
ctx.product.id,
jobId,
ctx.story.id,
ctx.task.repo_url,
)
if ('error' in wt) return toolError(wt.error)
return toolJson({ ...ctx, worktree_path: wt.worktree_path, branch_name: wt.branch_name })
}
return toolJson(ctx)
const wt = await attachWorktreeToJob(ctx.product.id, jobId, ctx.story.id)
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
@ -878,20 +372,9 @@ export function registerWaitForJobTool(server: McpServer) {
if (jobId) {
const ctx = await getFullJobContext(jobId)
if (!ctx) return toolError('Job claimed but context fetch failed')
if (ctx.kind === 'TASK_IMPLEMENTATION') {
if (!ctx.story || !ctx.task) {
return toolError('Task-job claimed but story/task context is incomplete')
}
const wt = await attachWorktreeToJob(
ctx.product.id,
jobId,
ctx.story.id,
ctx.task.repo_url,
)
if ('error' in wt) return toolError(wt.error)
return toolJson({ ...ctx, worktree_path: wt.worktree_path, branch_name: wt.branch_name })
}
return toolJson(ctx)
const wt = await attachWorktreeToJob(ctx.product.id, jobId, ctx.story.id)
if ('error' in wt) return toolError(wt.error)
return toolJson({ ...ctx, worktree_path: wt.worktree_path, branch_name: wt.branch_name })
}
}
} finally {

Some files were not shown because too many files have changed in this diff Show more