# Queue-loop verplaatsen van Claude naar runner ## Context Vandaag draait `scrum4me-docker/bin/run-agent.sh` één lange `claude -p`-sessie met de seed-prompt _"draai queue leeg"_. Claude roept zelf herhaaldelijk `wait_for_job` aan binnen die ene CLI-invocation. Het probleem: alle jobs in zo'n run gebruiken dezelfde CLI-flags — terwijl PBI-67 (`lib/job-config.ts`) per job een ander model, permission-mode, thinking-budget en allowed-tools voorschrijft. Een `IDEA_MAKE_PLAN` job moet met Opus + plan-mode draaien, een `TASK_IMPLEMENTATION` met Sonnet + bypassPermissions; binnen één Claude-proces is die switch niet te maken. Doel: **één Claude-invocation per geclaimde job**. De runner (buiten Claude) claimt, leest `job.config`, bouwt de juiste CLI-flags, spawnt `claude -p` voor precies die ene job, wacht op exit, claimt de volgende. Claude zelf doet niet meer aan claim-management. ## Kritische CLI-correctie (vóór alles) `claude --version` op het host-systeem en in de Docker base = **2.1.132**. Geverifieerde flag-set: | Beschreven in runbook | Bestaat | Vervanging | |---|---|---| | `--thinking-budget ` | ❌ | `--effort {low,medium,high,xhigh,max}` | | `--max-turns ` | ❌ | géén equivalent (gebruik `--max-budget-usd` als budget-cap of laat cosmetisch) | | `--model`, `--permission-mode`, `--allowedTools`, `--mcp-config`, `--output-format` | ✅ | ongewijzigd | `docs/runbooks/worker-idempotency.md:113-150` documenteert flags die niet bestaan. PBI-67-velden `thinking_budget` en `max_turns` zijn vandaag al cosmetisch (de seed-prompt-loop geeft ze ook niet door). Deze refactor is het natuurlijke moment om dat goed te zetten. **Beslissing**: voeg `mapBudgetToEffort(budget: number): string | null` toe in beide `job-config.ts`-spiegels: - `0` → `null` (flag niet meegeven) - `1..6000` → `"medium"` - `6001..12000` → `"high"` - `12001..24000` → `"xhigh"` - `>24000` → `"max"` `max_turns` blijft audit-only — comment in `KIND_DEFAULTS` toevoegen, runner negeert het. ## Architectuur in één pagina ``` run-agent.sh (daemon, backoff, health, log-rotation, token-expiry detectie) └── tsx /opt/agent/bin/run-one-job.ts ← één iteratie = één job ├── 1. quota-probe (was Claude's verantwoordelijkheid) ├── 2. resetStaleClaimedJobs(userId) ├── 3. tryClaimJob(userId, tokenId) │ └── null? LISTEN scrum4me_changes met deadline 270s; bij timeout → exit 0 ├── 4. getFullJobContext(jobId) ← public export uit scrum4me-mcp ├── 5. attachWorktreeToJob (alleen TASK_IMPLEMENTATION) ├── 6. Schrijf payload → /tmp/job-/payload.json ├── 7. Bouw CLI-args uit config + map effort ├── 8. setInterval(60s) lease-renewal ← alleen SPRINT_IMPLEMENTATION ├── 9. spawnSync('claude', [...]); cwd = worktree_path ├── 10. try/finally rollbackClaim + releaseLocksOnTerminal als spawn faalt ├── 11. clearInterval; await prisma.$disconnect() └── 12. exit met claude's code (3 = TOKEN_EXPIRED) ``` Claude zelf: - krijgt **geen** `wait_for_job` in `--allowedTools` — vangrail tegen recursieve claims. - krijgt **geen** "draai queue leeg"-prompt meer — per kind een eigen prompt-template. - doet alleen job-uitvoering: tool-calls voor logging, status-updates, verify, en `update_job_status` aan het einde. ## Hoe `run-one-job.ts` aan jobId + config komt Vier stappen, allemaal binnen het Node-proces — geen aparte CLI-call, geen MCP-stdio-roundtrip. ### 1. Wie ben ik (auth) ```ts import { getAuth } from '/opt/scrum4me-mcp/src/auth.js' const { userId, tokenId } = await getAuth() ``` `getAuth()` (scrum4me-mcp/src/auth.ts:11-40) hashed `process.env.SCRUM4ME_TOKEN` (SHA-256), zoekt in `ApiToken` op `token_hash`, en returnt `{ userId, tokenId, username, isDemo }`. Token komt uit Docker compose `.env`. ### 2. Welke job (claim) ```ts import { tryClaimJob, resetStaleClaimedJobs } from '/opt/scrum4me-mcp/src/tools/wait-for-job.js' await resetStaleClaimedJobs(userId) // requeu/FAIL stale CLAIMED-jobs (lease verlopen) const jobId: string | null = await tryClaimJob(userId, tokenId) ``` `tryClaimJob` (scrum4me-mcp/src/tools/wait-for-job.ts:358-447) doet één atomic transactie: 1. `SELECT cj.id FROM claude_jobs cj LEFT JOIN tasks t ... LEFT JOIN sprint_runs sr ... WHERE user_id = $userId AND status = 'QUEUED' AND (kind IN idea-kinds OR (kind IN task/sprint AND sprint_run.status IN QUEUED|RUNNING)) ORDER BY created_at ASC LIMIT 1 FOR UPDATE OF cj SKIP LOCKED` 2. `UPDATE claude_jobs SET status='CLAIMED', claimed_by_token_id=$tokenId, claimed_at=NOW(), plan_snapshot=..., lease_until=NOW()+INTERVAL '5 minutes' WHERE id=$jobId` 3. Optioneel: SprintRun QUEUED → RUNNING bij eerste claim. `FOR UPDATE SKIP LOCKED` garandeert dat parallelle workers nooit dezelfde job pakken — concurrency-safety op DB-niveau. Bij `null`: `LISTEN scrum4me_changes` met deadline 270s; bij notify op `claude_job_enqueued`-event opnieuw `tryClaimJob`. Bij timeout exit 0 (run-agent.sh sleept 2s en herstart). ### 3. Welke config (resolve op claim-moment) ```ts import { getFullJobContext } from '/opt/scrum4me-mcp/src/tools/wait-for-job.js' // export-fix in Fase 1 const ctx = await getFullJobContext(jobId) ``` `getFullJobContext` (scrum4me-mcp/src/tools/wait-for-job.ts:449-788) doet **één Prisma-findUnique met joins** (task → story → pbi/sprint, idea, product met `preferred_*`-velden), en roept dan `resolveJobConfig(...)` aan: ```ts const config = resolveJobConfig( { kind: job.kind, requested_model: job.requested_model, // snapshot bij enqueue requested_thinking_budget: job.requested_thinking_budget, requested_permission_mode: job.requested_permission_mode, }, { preferred_model: job.product.preferred_model, // product-override 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, ) ``` `resolveJobConfig` ([lib/job-config.ts:97-124](../../lib/job-config.ts)) past de override-cascade toe (eerste match wint): 1. `task.requires_opus === true` → `model = 'claude-opus-4-7'` 2. `job.requested_*` (al ingevuld bij enqueue door [lib/job-config-snapshot.ts](../../lib/job-config-snapshot.ts) in de Next.js webapp) 3. `product.preferred_*` 4. `KIND_DEFAULTS[kind]` Resultaat zit in `ctx.config`: ```ts ctx.config = { model: 'claude-sonnet-4-6', thinking_budget: 6000, permission_mode: 'bypassPermissions', max_turns: 50, // audit-only, geen CLI flag allowed_tools: ['Read','Edit','Write','Bash','Grep','Glob','mcp__scrum4me__update_task_status', ...], } ``` Plus de kind-specifieke velden: `task`, `story`, `pbi`, `sprint`, `idea`, `product`, `worktree_path`, `branch_name`, `task_executions[]` (sprint), `prompt_text` (idea). ### 4. Bouw CLI-args en spawn ```ts import { mapBudgetToEffort } from '/opt/scrum4me-mcp/src/lib/job-config.js' import { getKindPromptText } from '/opt/scrum4me-mcp/src/lib/kind-prompts.js' const promptText = getKindPromptText(ctx.kind).replace('$PAYLOAD_PATH', payloadPath) const args = [ '-p', promptText, '--model', ctx.config.model, '--permission-mode', ctx.config.permission_mode, '--allowedTools', ctx.config.allowed_tools.join(','), '--mcp-config', '/opt/agent/mcp-config.json', '--add-dir', '/opt/agent', '--output-format', 'text', ] const effort = mapBudgetToEffort(ctx.config.thinking_budget) if (effort) args.push('--effort', effort) spawnSync('claude', args, { stdio: 'inherit', cwd: ctx.worktree_path ?? ctx.primary_worktree_path ?? '/opt/agent', }) ``` ### Twee resolver-passages: bewust ontwerp ``` [Webapp enqueue] [Runner claim] actions/createJob tryClaimJob(jobId) ↓ ↓ snapshotFromConfig (lib/job-config-snapshot.ts) getFullJobContext ↓ resolveJobConfig (leest requested_* terug) ↓ ↓ DB: claude_jobs.requested_* ctx.config → CLI flags ``` De **enqueue-resolver** legt de keuze vast als snapshot in `ClaudeJob.requested_*` (audittrail). De **claim-resolver** leest die snapshot terug — als een operator handmatig `requested_model` heeft gewijzigd tussen enqueue en claim (bv. "ad-hoc Opus voor deze ene story"), wint die wijziging. Bewust ontwerp. ## Implementatie — 3 fases, 3 PR's ### Fase 1 — `scrum4me-mcp` (publieke API + prompts + KIND_DEFAULTS) **Bestanden:** - `scrum4me-mcp/src/tools/wait-for-job.ts` — regel 449 `async function getFullJobContext` → `export async function getFullJobContext`. Niets anders aan de body wijzigen. - `scrum4me-mcp/src/lib/idea-prompts.ts` → hernoemen naar `src/lib/kind-prompts.ts`. Nieuwe export `getKindPromptText(kind: ClaudeJobKind): string` met cases voor alle vijf kinds. Behoud `getIdeaPromptText` als re-export voor back-compat (wait-for-job.ts roept 'm aan). - `scrum4me-mcp/src/lib/job-config.ts`: - Voeg `mapBudgetToEffort(budget: number): string | null` toe. - Update `KIND_DEFAULTS`: - `TASK_IMPLEMENTATION.allowed_tools` = expliciete lijst zonder `wait_for_job`/`check_queue_empty`/`get_idea_context`. Inhoud: `['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']` - `SPRINT_IMPLEMENTATION.allowed_tools` = bovenstaande + `mcp__scrum4me__update_task_execution`, `mcp__scrum4me__verify_sprint_task`. **Géén** `mcp__scrum4me__job_heartbeat` — de runner verlengt de lease (zie Fase 2). Claude hoeft hier niet aan te denken. - `IDEA_GRILL.allowed_tools` = bestaand + `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.allowed_tools` = bestaand + `mcp__scrum4me__update_idea_plan_md`, `mcp__scrum4me__log_idea_decision`, `mcp__scrum4me__update_job_status`. - Comment toevoegen: `max_turns` is audit-only (Claude CLI 2.1.x mist `--max-turns`). `thinking_budget` mapt via `mapBudgetToEffort`. - **Nieuwe prompts** in `src/prompts/`: - `task/implementation.md` — single-task workflow, payload via `$PAYLOAD_PATH`, expliciet géén `wait_for_job`, instructie voor verify-gate + `update_job_status` aan het einde. - `sprint/implementation.md` — sprint-workflow, Claude verwerkt `task_executions[]` sequentieel. Géén heartbeat-instructie nodig: de runner verlengt de lease via setInterval. - `plan-chat/chat.md` — placeholder voor PLAN_CHAT. - **Tests** in `__tests__/` of vergelijkbaar: snapshot-test voor `mapBudgetToEffort` en de nieuwe `KIND_DEFAULTS.allowed_tools`-lijsten. **Verificatie van Fase 1:** ```bash cd /Users/janpetervisser/Development/scrum4me-mcp npm run typecheck && npm test ``` ### Fase 2 — `scrum4me-docker` (de runner) **Bestanden:** - **Nieuw**: `scrum4me-docker/bin/run-one-job.ts` — implementeert de stappen uit het architectuur-diagram. Imports uit `/opt/scrum4me-mcp/src/`: - `getAuth` (auth.ts) - `tryClaimJob`, `resetStaleClaimedJobs`, `attachWorktreeToJob`, `releaseLocksOnTerminal`, `rollbackClaim`, `getFullJobContext` (tools/wait-for-job.ts) - `prisma` (prisma.ts) - `mapBudgetToEffort`, type `JobConfig` (lib/job-config.ts) - `getKindPromptText` (lib/kind-prompts.ts) - LISTEN-loop: kopie van `wait-for-job.ts:838-889` (270s deadline ipv 300s — ruim binnen `MAX_WAIT_SECONDS`). - Quota-probe verhuist hierheen: roep `bin/worker-quota-probe.sh` (bestaat al) en `getWorkerSettings` direct via prisma; sleep tot reset bij beneden-quota. Was voorheen Claude's verantwoordelijkheid in CLAUDE.md stappen 0.1-0.5. - **Lease-renewal voor SPRINT_IMPLEMENTATION**: `setInterval(60_000, () => prisma.$executeRaw\`UPDATE claude_jobs SET lease_until = NOW() + INTERVAL '5 minutes' WHERE id = ${jobId}\`)`. Stop op spawn-exit (in finally-block). Werkt onafhankelijk van Claude's tool-call-cadans, dus ook tijdens lange synchrone Bash-calls (zoals `npm install`) blijft de lease vers. - Token-expiry: detecteer Anthropic-auth-errors uit Claude's output én uit eigen Prisma-fouten; exit 3 → run-agent.sh schrijft `TOKEN_EXPIRED` marker. - Cleanup: `prisma.$disconnect()` in `process.on('SIGTERM'/'exit')` zodat connection-pool niet blijft hangen tussen iteraties. - **Refactor**: `scrum4me-docker/bin/run-agent.sh` - Verwijder regels 43-44 (SEED_PROMPT) en 46-49 (ALLOWED_TOOLS). - Vervang regels 73-79 (`claude -p ...` aanroep) door: ```bash set +e tsx /opt/agent/bin/run-one-job.ts > "${run_log}" 2>&1 exit_code=$? set -e ``` - Behoud: pre-flight token-check, exponential backoff (regels 106-126), UNHEALTHY na 5 fouten, log-rotation, state.json updates. - Pas regel 87 (token-expiry regex) aan: detecteer ook `exit_code == 3` als trigger naast de stdout-regex. - **Update**: `scrum4me-docker/CLAUDE.md` - Verwijder de "operationele loop"-sectie (Claude doet die niet meer). - Korte sectie toevoegen: "deze container draait runner-loop in run-one-job.ts; per geclaimde job spawnt 'ie één Claude-invocation met kind-specifieke flags + prompt". - Behoud: project-conventions die Claude in de worktree-cwd nodig heeft. - **Dockerfile**: niets wijzigen — `tsx@4` global is al geïnstalleerd (regel 39), scrum4me-mcp wordt al gecloned (regel 59-63). **Verificatie van Fase 2:** ```bash cd /Users/janpetervisser/Development/scrum4me-docker docker compose build docker compose up -d # Trigger een TASK_IMPLEMENTATION via de webapp (Sonnet + bypassPermissions verwacht) # Trigger een IDEA_MAKE_PLAN via de webapp (Opus + plan-mode verwacht) # Logs: docker compose logs -f # Verifieer dat per job een nieuwe Claude-invocation logt met de juiste --model en --permission-mode ``` ### Fase 3 — `Scrum4Me` web-app (lib + runbook) **Bestanden:** - [lib/job-config.ts](../../lib/job-config.ts) — spiegel `mapBudgetToEffort` en de KIND_DEFAULTS-updates uit Fase 1. Comment toevoegen over CLI-mapping. - [docs/runbooks/worker-idempotency.md](../runbooks/worker-idempotency.md) — herschrijf sectie "Config doorgeven aan Claude Code" (regels 113-150): vervang `--thinking-budget` door `--effort` met mapping-tabel, schrap `--max-turns`, voeg toe dat de runner (`bin/run-one-job.ts`) verantwoordelijk is voor flag-bouw en heartbeat (niet Claude meer). - [docs/runbooks/job-model-selection.md](../runbooks/job-model-selection.md) — voeg note toe dat `max_turns` audit-only is en dat de runner per job spawnt. - Tests: vitest snapshot voor `mapBudgetToEffort` (zelfde als in scrum4me-mcp Fase 1). **Verificatie van Fase 3:** ```bash npm run verify && npm run build ``` ## Logging-contract van `run-one-job.ts` Alle log-regels gaan naar **stdout** met format ` [run-one-job] `. `run-agent.sh` redirect dit al naar `/var/log/agent/runs/.log`. Eén regel per event, key=value-format zodat het grep-baar blijft. **Verplichte events:** | Moment | Voorbeeld-regel | |---|---| | Quota-probe start/eind | `2026-05-08T13:11:40Z [run-one-job] quota probe ok used_pct=42 limit_pct=90` | | Claim attempt start | `2026-05-08T13:11:41Z [run-one-job] claim attempt user_id=usr_… token_id=tok_…` | | Claim resultaat | `2026-05-08T13:11:42Z [run-one-job] claimed job_id=cl_abc kind=TASK_IMPLEMENTATION task_id=t_xyz product_id=prd_…` of `claim timeout after 270s — exiting 0` | | **Resolved config** (verplicht) | `2026-05-08T13:11:42Z [run-one-job] config job_id=cl_abc model=claude-sonnet-4-6 permission_mode=bypassPermissions thinking_budget=6000 effort=medium max_turns=50 allowed_tools_count=20 source=kind_default` (waar `source` ∈ `kind_default` / `product_override` / `task_requires_opus` / `job_snapshot` — bepaald door welke override-laag gewonnen heeft) | | Worktree + payload | `2026-05-08T13:11:42Z [run-one-job] worktree path=/home/agent/.scrum4me-agent-worktrees/cl_abc branch=feat/story-12345678 base_sha=abc123ef` | | Payload-pad | `2026-05-08T13:11:42Z [run-one-job] payload written path=/tmp/job-cl_abc/payload.json size_bytes=2456` | | **Claude spawn start** (verplicht) | `2026-05-08T13:11:43Z [run-one-job] spawn claude job_id=cl_abc cwd=/home/agent/.scrum4me-agent-worktrees/cl_abc args="--model claude-sonnet-4-6 --permission-mode bypassPermissions --effort medium --allowedTools <…> --mcp-config /opt/agent/mcp-config.json --add-dir /opt/agent --output-format text"` | | Lease-renewal tick (alleen SPRINT) | `2026-05-08T13:12:43Z [run-one-job] heartbeat tick job_id=cl_abc lease_until=2026-05-08T13:17:43Z` (bij errors: `heartbeat error: `) | | **Claude spawn end** (verplicht) | `2026-05-08T13:14:21Z [run-one-job] claude done job_id=cl_abc exit_code=0 duration_ms=158234 wall_clock_seconds=158` | | Cleanup | `2026-05-08T13:14:21Z [run-one-job] cleanup payload_removed=true prisma_disconnected=true heartbeat_stopped=true` | | Process exit | `2026-05-08T13:14:21Z [run-one-job] exit code=0 job_id=cl_abc` | **Foutpaden ook expliciet:** - `claim error ` (DB-fout vóór claim) - `getFullJobContext error job_id=cl_abc ` → triggert rollbackClaim + log `rollback claim job_id=cl_abc reason=context_fetch_failed` - `attachWorktreeToJob error job_id=cl_abc ` → idem - `spawn error ` → process kon `claude` niet starten - Detected token-expiry: `2026-05-08T13:11:43Z [run-one-job] TOKEN_EXPIRED detected pattern="" exiting code=3` **Implementatie-helper** in `run-one-job.ts`: ```ts const log = (msg: string) => console.log(`${new Date().toISOString()} [run-one-job] ${msg}`) const logError = (msg: string) => console.error(`${new Date().toISOString()} [run-one-job] ERROR ${msg}`) ``` Geen JSON-logger of structured logging library — `run-agent.sh` parsed niets. Plain text houdt het grep-baar en consistent met de bestaande `_lib.sh`-`log()` shell-helper. ## Crash-veiligheid | Failure-mode | Detectie | Recovery | |---|---|---| | Claim gelukt, runner crasht vóór spawn | `lease_until < NOW()` | `resetStaleClaimedJobs` (5 min) — automatisch | | Spawn faalt (exit ≠ 0 vóór `update_job_status`) | exit code | `try/finally rollbackClaim + releaseLocksOnTerminal` in run-one-job | | Claude crasht mid-run | exit code | rollbackClaim uit run-one-job; `update_job_status('failed')` is dan optional retry door operator | | Token-expiry tijdens run | regex op claude-stdout + exit 3 | runner exit 3 → run-agent.sh schrijft TOKEN_EXPIRED marker → container blijft hangen voor diagnose | | Runner-heartbeat faalt (DB onbereikbaar tijdens setInterval-tick) | error log + lease_until verstrijkt | resetStaleClaimedJobs requeu't (PBI-50 lease-driven recovery, 5 min). Mitigatie: log de Prisma-error in run-one-job zodat het opvalt in run-logs | ## Verificatie end-to-end ```bash # 1. Build alle drie de repos (cd ~/Development/scrum4me-mcp && npm run typecheck && npm test) (cd ~/Development/Scrum4Me/.claude/worktrees/festive-jackson-78c3ff && npm run verify && npm run build) (cd ~/Development/scrum4me-docker && docker compose build) # 2. Lokale Docker-run docker compose up -d docker compose logs -f agent & # 3. Smoke-test scenario's (via webapp of direct via prisma seed): # a. enqueue IDEA_GRILL → verifieer log toont --model=claude-sonnet-4-6 + --permission-mode=plan + --effort=high # b. enqueue IDEA_MAKE_PLAN → verifieer --model=claude-opus-4-7 + --effort=max # c. enqueue TASK_IMPLEMENTATION → verifieer --model=claude-sonnet-4-6 + --permission-mode=bypassPermissions # d. enqueue task met requires_opus=true → verifieer --model=claude-opus-4-7 # e. enqueue product met preferred_permission_mode='acceptEdits' → verifieer dat override doorkomt # 4. Verifieer in DB na elke run: # SELECT id, kind, status, requested_model, model_id, requested_permission_mode FROM claude_jobs ORDER BY created_at DESC LIMIT 5; # requested_model en model_id moeten matchen (tenzij Claude zelf een ander rapporteert) # 5. Verifieer queue-loop met meerdere jobs: # Vul de queue met 3 verschillende kinds; observeer in logs dat per job een nieuwe spawn gebeurt met andere flags. ``` ## Niet-doelen - Geen wijzigingen aan de MCP-tool-set (`wait_for_job` blijft beschikbaar voor handmatige dev-mode; alleen niet meer in Claude's `allowedTools` voor docker-runs). - Geen herstructurering van het ClaudeJob Prisma-schema. - Geen wijzigingen aan `lib/job-config-snapshot.ts` (enqueue-laag) — die werkt al goed; deze refactor zit volledig aan de claim/exec-kant. - Geen migratie van de `wait_for_job`-tool naar HTTP/REST — direct-import is voldoende. ## Critical files - /Users/janpetervisser/Development/scrum4me-mcp/src/tools/wait-for-job.ts (export-fix + ref) - /Users/janpetervisser/Development/scrum4me-mcp/src/lib/job-config.ts (KIND_DEFAULTS + mapBudgetToEffort) - /Users/janpetervisser/Development/scrum4me-mcp/src/lib/idea-prompts.ts (rename → kind-prompts.ts) - /Users/janpetervisser/Development/scrum4me-mcp/src/prompts/task/implementation.md (nieuw) - /Users/janpetervisser/Development/scrum4me-mcp/src/prompts/sprint/implementation.md (nieuw) - /Users/janpetervisser/Development/scrum4me-docker/bin/run-one-job.ts (nieuw) - /Users/janpetervisser/Development/scrum4me-docker/bin/run-agent.sh (refactor) - /Users/janpetervisser/Development/scrum4me-docker/CLAUDE.md (operationele loop sectie weg) - lib/job-config.ts (spiegel) - docs/runbooks/worker-idempotency.md (CLI-flag fix)