From 070c03974063055f871a78f07fc274c7fd808eec Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Fri, 8 May 2026 11:03:15 +0200 Subject: [PATCH 1/3] feat(PBI-67/ST-1298): job-config resolver + kind-default-matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nieuwe centrale resolver `resolveJobConfig(job, product, task?)` die per ClaudeJob bepaalt welk model + thinking-budget + permission-mode + max_turns + allowed_tools de worker moet gebruiken. Override-cascade (eerste match wint): task.requires_opus → job.requested_* → product.preferred_* → kind-default Kind-defaults: IDEA_GRILL sonnet-4-6 thinking 12k plan IDEA_MAKE_PLAN opus-4-7 thinking 24k plan PLAN_CHAT sonnet-4-6 thinking 6k plan (max 5 turns) TASK_IMPLEMENTATION sonnet-4-6 thinking 6k bypassPermissions SPRINT_IMPLEMENTATION sonnet-4-6 thinking 6k bypassPermissions 19 unit tests (alle 5 kinds × cascade-niveaus). Geen externe deps. Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/job-config.test.ts | 97 ++++++++++++++++++++++++++++ src/lib/job-config.ts | 120 +++++++++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 __tests__/job-config.test.ts create mode 100644 src/lib/job-config.ts diff --git a/__tests__/job-config.test.ts b/__tests__/job-config.test.ts new file mode 100644 index 0000000..3a7af58 --- /dev/null +++ b/__tests__/job-config.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'vitest' +import { getKindDefault, resolveJobConfig } from '../src/lib/job-config.js' + +const KIND_EXPECTED = { + IDEA_GRILL: { model: 'claude-sonnet-4-6', thinking_budget: 12000, permission_mode: 'plan', max_turns: 15 }, + IDEA_MAKE_PLAN: { model: 'claude-opus-4-7', thinking_budget: 24000, permission_mode: 'plan', max_turns: 20 }, + PLAN_CHAT: { model: 'claude-sonnet-4-6', thinking_budget: 6000, permission_mode: 'plan', 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 en allowed_tools blijven 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) + expect(cfg.allowed_tools).toEqual(['Read', 'Grep', 'Glob', 'WebSearch', 'AskUserQuestion']) + }) +}) diff --git a/src/lib/job-config.ts b/src/lib/job-config.ts new file mode 100644 index 0000000..c615de1 --- /dev/null +++ b/src/lib/job-config.ts @@ -0,0 +1,120 @@ +// PBI-67: model + mode-selectie per ClaudeJob-kind. +// +// 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 + +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 +} + +const KIND_DEFAULTS: Record = { + IDEA_GRILL: { + model: 'claude-sonnet-4-6', + thinking_budget: 12000, + permission_mode: 'plan', + max_turns: 15, + allowed_tools: ['Read', 'Grep', 'Glob', 'WebSearch', 'AskUserQuestion'], + }, + IDEA_MAKE_PLAN: { + model: 'claude-opus-4-7', + thinking_budget: 24000, + permission_mode: 'plan', + max_turns: 20, + allowed_tools: ['Read', 'Grep', 'Glob', 'WebSearch', 'AskUserQuestion', 'Write'], + }, + PLAN_CHAT: { + model: 'claude-sonnet-4-6', + thinking_budget: 6000, + permission_mode: 'plan', + max_turns: 5, + allowed_tools: ['Read', 'Grep', 'AskUserQuestion'], + }, + TASK_IMPLEMENTATION: { + model: 'claude-sonnet-4-6', + thinking_budget: 6000, + permission_mode: 'bypassPermissions', + max_turns: 50, + allowed_tools: null, + }, + SPRINT_IMPLEMENTATION: { + model: 'claude-sonnet-4-6', + thinking_budget: 6000, + permission_mode: 'bypassPermissions', + max_turns: null, + allowed_tools: null, + }, +} + +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, + } +} From e2963d58fb70414a6e3b997086c865d0648fc5ba Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Fri, 8 May 2026 11:11:29 +0200 Subject: [PATCH 2/3] feat(PBI-67/ST-1299/T-788): wait_for_job retourneert config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roept resolveJobConfig aan na het claimen van een job en voegt het resultaat toe als `config: JobConfig` aan de response payload. Werkt voor alle 3 return-paden (IDEA_*, SPRINT_IMPLEMENTATION, default TASK_IMPLEMENTATION). Schema-velden lokaal toegevoegd ter ondersteuning van het Prisma-include (preferred_*, requires_opus, requested_*, actual_thinking_tokens). De sync-schema.sh-flow refresht ze later vanuit het scrum4me-submodule zodra PBI-67/ST-1297 in main is. Pure additief — oude clients negeren `config` en blijven werken op Claude Code defaults uit ~/.claude/settings.json. 301 tests slagen onveranderd. Co-Authored-By: Claude Opus 4.7 (1M context) --- prisma/schema.prisma | 8 ++++++++ src/tools/wait-for-job.ts | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9766b6f..0c04619 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -200,6 +200,9 @@ model Product { 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 @@ -353,6 +356,7 @@ model Task { 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 @@ -398,6 +402,10 @@ model ClaudeJob { 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? diff --git a/src/tools/wait-for-job.ts b/src/tools/wait-for-job.ts index 1323e50..99a8090 100644 --- a/src/tools/wait-for-job.ts +++ b/src/tools/wait-for-job.ts @@ -18,6 +18,7 @@ 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//(.git)?` → ``. */ export function repoNameFromUrl(repoUrl: string | null | undefined): string | null { @@ -467,11 +468,38 @@ async function getFullJobContext(jobId: string) { }, }, }, - product: { select: { id: true, name: true, repo_url: true, definition_of_done: 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, + }, + }, }, }) 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') { @@ -515,6 +543,7 @@ async function getFullJobContext(jobId: string) { job_id: job.id, kind: job.kind, status: 'claimed', + config, idea: { id: idea.id, code: idea.code, @@ -659,6 +688,7 @@ async function getFullJobContext(jobId: string) { job_id: job.id, kind: job.kind, status: 'claimed', + config, sprint: { id: sprintRun.sprint.id, sprint_goal: sprintRun.sprint.sprint_goal, @@ -724,6 +754,7 @@ async function getFullJobContext(jobId: string) { job_id: job.id, kind: job.kind, status: 'claimed', + config, task: { id: task.id, title: task.title, From 1c0f41687b8d22a25ec0db74776c2c2f8fc3e865 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Fri, 8 May 2026 11:12:31 +0200 Subject: [PATCH 3/3] feat(PBI-67/ST-1300/T-791): persist actual_thinking_tokens in update_job_status Workers kunnen voortaan het werkelijk verbruikte thinking-budget meegeven via `actual_thinking_tokens`. Identiek aan de bestaande input/output/cache_*-velden: optioneel + conditional update. Backwards-compatible: oude workers zonder deze veld blijven werken. 57 update-job-status tests groen. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tools/update-job-status.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/tools/update-job-status.ts b/src/tools/update-job-status.ts index 8fcb83e..5a75a7d 100644 --- a/src/tools/update-job-status.ts +++ b/src/tools/update-job-status.ts @@ -54,6 +54,7 @@ const inputSchema = z.object({ output_tokens: z.number().int().nonnegative().optional(), cache_read_tokens: z.number().int().nonnegative().optional(), cache_write_tokens: z.number().int().nonnegative().optional(), + actual_thinking_tokens: z.number().int().nonnegative().optional(), }) export async function cleanupWorktreeForTerminalStatus( @@ -539,6 +540,7 @@ export function registerUpdateJobStatusTool(server: McpServer) { output_tokens, cache_read_tokens, cache_write_tokens, + actual_thinking_tokens, }) => withToolErrors(async () => { const auth = await requireWriteAccess() @@ -707,6 +709,7 @@ export function registerUpdateJobStatusTool(server: McpServer) { ...(output_tokens !== undefined ? { output_tokens } : {}), ...(cache_read_tokens !== undefined ? { cache_read_tokens } : {}), ...(cache_write_tokens !== undefined ? { cache_write_tokens } : {}), + ...(actual_thinking_tokens !== undefined ? { actual_thinking_tokens } : {}), }, select: { id: true,