diff --git a/src/api/server.ts b/src/api/server.ts index 8579471..852fb06 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -101,6 +101,7 @@ export function buildServer(opts: ApiOptions): FastifyInstance { if (b.autoApproveExec !== undefined) patch.autoApproveExec = Boolean(b.autoApproveExec); if (b.checks !== undefined) patch.checks = b.checks === null || b.checks === '' ? null : String(b.checks); if (b.agentRules !== undefined) patch.agentRules = b.agentRules === null || b.agentRules === '' ? null : String(b.agentRules); + if (b.models !== undefined) patch.models = b.models === null || b.models === '' ? null : (b.models as PatchProjectInput['models']); return projectOut(store.patchProject(id, patch)); }); @@ -216,6 +217,7 @@ export function buildServer(opts: ApiOptions): FastifyInstance { parentId: b.parentId ? String(b.parentId) : null, priority: b.priority === undefined ? undefined : Number(b.priority), deps: Array.isArray(b.deps) ? (b.deps as string[]) : undefined, + scopeFiles: Array.isArray(b.scopeFiles) ? (b.scopeFiles as string[]) : undefined, }); if (auto) { // 异步分类回填:classifyComplexity 自身永不抛错(失败兜底 medium),外层再兜一层防御 @@ -293,6 +295,12 @@ export function buildServer(opts: ApiOptions): FastifyInstance { } patch.deps = b.deps as string[]; } + if (b.scopeFiles !== undefined) { + if (b.scopeFiles !== null && (!Array.isArray(b.scopeFiles) || !b.scopeFiles.every((f) => typeof f === 'string'))) { + throw new StoreError('scopeFiles 必须是文件 glob 字符串数组或 null'); + } + patch.scopeFiles = b.scopeFiles as string[] | null; + } return store.patchTask(id, patch); }); diff --git a/src/daemon/ingest.ts b/src/daemon/ingest.ts index fa9bc68..2dcc7ce 100644 --- a/src/daemon/ingest.ts +++ b/src/daemon/ingest.ts @@ -131,7 +131,10 @@ function applyRecord(store: Store, log: IngestLogger, taskId: string, runId: str try { const priority = typeof (sub as { priority?: unknown }).priority === 'number' ? (sub as { priority: number }).priority : 1; - const newTask = store.createTask({ projectId: task.projectId, parentId: taskId, title: sub.title, complexity: sub.complexity, priority }); + const files = (sub as { files?: unknown }).files; + const scopeFiles = Array.isArray(files) + ? files.filter((f): f is string => typeof f === 'string' && f.trim().length > 0) : undefined; + const newTask = store.createTask({ projectId: task.projectId, parentId: taskId, title: sub.title, complexity: sub.complexity, priority, scopeFiles }); createdIds.push(newTask.id); created++; } catch (e) { diff --git a/src/executor/checks.ts b/src/executor/checks.ts index d565aa7..2fc3cc5 100644 --- a/src/executor/checks.ts +++ b/src/executor/checks.ts @@ -135,3 +135,69 @@ export async function diffSizeGate(repoPath: string, _dir: string, branch: strin } return { ok: true, files, lines }; } + +// ───────────────────────── diff 声明外文件闸 ───────────────────────── + +export interface ScopeGateResult { + ok: boolean; + outside: string[]; // 越界文件(声明范围外被改动的) + error?: string; +} + +/** 声明文件范围闸函数签名(pipeline 依赖注入点)。 */ +export type ScopeGateFn = (repoPath: string, branch: string, baseBranch: string, scopeFiles: string[]) => Promise; + +/** + * 把 glob 模式编译成锚定的正则: + * - `**` 跨目录匹配任意(含 /),`*` 匹配单层非 / 段,`?` 匹配单个非 / 字符 + * - 其余字符按字面量转义;整体首尾锚定 + * - 目录式声明(以 `/` 结尾,如 `src/`)视作其下全部文件(追加 `**`) + */ +export function globToRegExp(pattern: string): RegExp { + let p = pattern.trim(); + if (p.endsWith('/')) p += '**'; + let re = ''; + for (let i = 0; i < p.length; i++) { + const ch = p[i]; + if (ch === '*') { + if (p[i + 1] === '*') { re += '.*'; i++; } // ** → 任意(含 /) + else re += '[^/]*'; // * → 单层 + } else if (ch === '?') { + re += '[^/]'; + } else if ('\\^$.|+()[]{}'.includes(ch)) { + re += '\\' + ch; // 正则元字符转义 + } else { + re += ch; + } + } + return new RegExp(`^${re}$`); +} + +/** 路径是否落在任一声明 glob 内。 */ +export function matchesAnyGlob(path: string, patterns: string[]): boolean { + return patterns.some((g) => globToRegExp(g).test(path)); +} + +/** + * 声明外文件闸:worktree diff 改动的文件若有任一不落在 task.scopeFiles 声明的 glob 内 = 硬失败 + * (提示 agent 改了任务范围外的文件)。scopeFiles 为空 → 跳过(不限范围)。git 出错 → 不拦截。 + */ +export async function scopeFileGate(repoPath: string, branch: string, baseBranch: string, scopeFiles: string[]): Promise { + const patterns = scopeFiles.map((s) => s.trim()).filter(Boolean); + if (patterns.length === 0) return { ok: true, outside: [] }; + let out: string; + try { + out = (await git(repoPath, ['diff', '--name-only', `${baseBranch}...${branch}`])).trim(); + } catch { + return { ok: true, outside: [] }; // 测不出改动文件(git 出错)→ 不拦截 + } + if (!out) return { ok: true, outside: [] }; + const changed = out.split('\n').map((s) => s.trim()).filter(Boolean); + const outside = changed.filter((f) => !matchesAnyGlob(f, patterns)); + if (outside.length > 0) { + const shown = outside.slice(0, 10).join('、'); + const more = outside.length > 10 ? ` 等 ${outside.length} 个` : ''; + return { ok: false, outside, error: `改动触碰声明范围外文件(${shown}${more}):超出任务声明的文件范围,请仅改动 scopeFiles 内文件` }; + } + return { ok: true, outside }; +} diff --git a/src/executor/models.ts b/src/executor/models.ts index a3dcf6b..3ee81fe 100644 --- a/src/executor/models.ts +++ b/src/executor/models.ts @@ -4,71 +4,63 @@ import type { Complexity } from '../model/complexity.js'; /** 角色:executor(执行改动)/ reviewer(执行后复审)/ planner(拆解 Hard / 写方案 Medium)/ conflict(冲突裁决) */ export type ModelRole = 'executor' | 'reviewer' | 'planner' | 'conflict'; -/** 模型不可用时的回退链(按序取下一档,同一 run 内只重试一次;fable 置链首=优先用最强档) */ -export const MODEL_FALLBACK_CHAIN = ['claude-fable-5', 'claude-opus-4-8', 'claude-sonnet-4-6'] as const; +/** 内置默认模型:所有角色 × 所有复杂度的兜底档(项目级 models / 旧 project.model / env 均未配时生效)。 */ +export const DEFAULT_MODEL = 'claude-opus-4-8'; -/** 复杂度 → [env 覆盖变量名, 默认模型] */ -const MODEL_TABLE: Record> = { - executor: { - easy: ['MAESTRO_MODEL_EASY', 'claude-sonnet-4-6'], - medium: ['MAESTRO_MODEL_MEDIUM', 'claude-opus-4-8'], - hard: ['MAESTRO_MODEL_HARD', 'claude-opus-4-8'], - }, - // reviewer:复审是质量关口,统一用最强 fable-5(不受 project.model 降档,保自审严格度) - reviewer: { - easy: ['MAESTRO_MODEL_REVIEW_EASY', 'claude-fable-5'], - medium: ['MAESTRO_MODEL_REVIEW_MEDIUM', 'claude-fable-5'], - hard: ['MAESTRO_MODEL_REVIEW_HARD', 'claude-fable-5'], - }, - // planner:拆解/写方案按复杂度分化(不受 project.model 覆盖);hard 拆解最烧脑→fable-5 - planner: { - easy: ['MAESTRO_MODEL_PLAN_EASY', 'claude-sonnet-4-6'], - medium: ['MAESTRO_MODEL_PLAN_MEDIUM', 'claude-opus-4-8'], - hard: ['MAESTRO_MODEL_PLAN_HARD', 'claude-fable-5'], - }, - // conflict:固定 fable-5(解冲突最难,恒用最强档),TABLE 条目仅占位(pickModel 对此角色直接 return) - conflict: { - easy: ['', 'claude-fable-5'], - medium: ['', 'claude-fable-5'], - hard: ['', 'claude-fable-5'], - }, +/** 模型不可用时的回退链(按序取下一档,同一 run 内只重试一次;opus 为默认档,失败降 sonnet)。 */ +export const MODEL_FALLBACK_CHAIN = ['claude-opus-4-8', 'claude-sonnet-4-6', 'claude-fable-5'] as const; + +/** 角色 × 复杂度 → env 覆盖变量名(默认值统一走 DEFAULT_MODEL,不再内置分档)。 */ +const ENV_TABLE: Record> = { + executor: { easy: 'MAESTRO_MODEL_EASY', medium: 'MAESTRO_MODEL_MEDIUM', hard: 'MAESTRO_MODEL_HARD' }, + reviewer: { easy: 'MAESTRO_MODEL_REVIEW_EASY', medium: 'MAESTRO_MODEL_REVIEW_MEDIUM', hard: 'MAESTRO_MODEL_REVIEW_HARD' }, + planner: { easy: 'MAESTRO_MODEL_PLAN_EASY', medium: 'MAESTRO_MODEL_PLAN_MEDIUM', hard: 'MAESTRO_MODEL_PLAN_HARD' }, + conflict: { easy: '', medium: '', hard: '' }, }; -/** - * 按角色 + 复杂度选模型: - * 1. reviewer/conflict 不受 project.model 覆盖(保复审独立性) - * 2. conflict 固定返回 claude-opus-4-8(最强可用档) - * 3. 其他角色 project.model 设了就最优先 - * 4. 否则查 env 覆盖(MAESTRO_MODEL_* / MAESTRO_MODEL_REVIEW_*) - * 5. 否则按复杂度取默认 - */ -export function pickModel(task: Task, project: Project, role: ModelRole): string { - // reviewer/conflict 用最强档,不受 project.model 覆盖(保自审独立性) - if (role !== 'reviewer' && role !== 'conflict' && project.model) return project.model; - if (role === 'conflict') return 'claude-fable-5'; - const [env, fallback] = MODEL_TABLE[role][task.complexity]; - const override = process.env[env]?.trim(); - return override || fallback; +/** 从项目级 models 配置取某角色某复杂度的模型(字符串=全复杂度统一;对象=按复杂度分档)。未配=null。 */ +function fromProjectModels(project: Project, role: ModelRole, c: Complexity): string | null { + const m = project.models?.[role]; + if (!m) return null; + if (typeof m === 'string') return m.trim() || null; + const v = m[c]; + return typeof v === 'string' && v.trim() ? v.trim() : null; } -/** 看板展示用:当前项目 executor 各复杂度实际会用的模型(含 project.model / env 覆盖解析) */ +/** + * 解析某项目某角色某复杂度实际使用的模型,优先级(高→低): + * 1. 项目级 models[role](可全复杂度统一或按复杂度分档)——覆盖所有角色,含 reviewer/conflict + * 2. 旧 project.model(仅 executor/planner;保留向后兼容,不覆盖 reviewer/conflict 以护自审独立性) + * 3. env 覆盖(MAESTRO_MODEL_* / MAESTRO_MODEL_REVIEW_* / MAESTRO_MODEL_PLAN_*) + * 4. 内置默认 DEFAULT_MODEL(opus-4.8) + */ +export function resolveModel(project: Project, role: ModelRole, c: Complexity): string { + const fromCfg = fromProjectModels(project, role, c); + if (fromCfg) return fromCfg; + if ((role === 'executor' || role === 'planner') && project.model) return project.model; + const env = ENV_TABLE[role][c]; + const override = env ? process.env[env]?.trim() : ''; + return override || DEFAULT_MODEL; +} + +/** 按角色 + 复杂度选模型(见 resolveModel 优先级)。 */ +export function pickModel(task: Task, project: Project, role: ModelRole): string { + return resolveModel(project, role, task.complexity); +} + +/** 看板展示用:当前项目 executor 各复杂度实际会用的模型(含 models / project.model / env 解析)。 */ export function resolvedExecutorModels(project: Project): Record { const out = {} as Record; - for (const c of ['easy', 'medium', 'hard'] as Complexity[]) { - if (project.model) { out[c] = project.model; continue; } - const [env, fallback] = MODEL_TABLE.executor[c]; - out[c] = process.env[env]?.trim() || fallback; - } + for (const c of ['easy', 'medium', 'hard'] as Complexity[]) out[c] = resolveModel(project, 'executor', c); return out; } /** - * AUTO 复杂度分类器用的模型:复用 executor easy 档(claude-sonnet,省额度), - * env MAESTRO_MODEL_EASY 可覆盖。单次轻量调用,不走 project.model。 + * AUTO 复杂度分类器用的模型:单次轻量调用,固定用便宜档(claude-sonnet,省额度), + * env MAESTRO_MODEL_EASY 可覆盖。不走 project.model / project.models(与具体任务无关)。 */ export function pickClassifierModel(): string { - const [env, fallback] = MODEL_TABLE.executor.easy; - return process.env[env]?.trim() || fallback; + return process.env.MAESTRO_MODEL_EASY?.trim() || 'claude-sonnet-4-6'; } /** 错误信息是否像“模型不可用”(not_found / invalid / permission 等模式 + 提到 model) diff --git a/src/executor/pipeline.ts b/src/executor/pipeline.ts index 2a12f9f..305cf8f 100644 --- a/src/executor/pipeline.ts +++ b/src/executor/pipeline.ts @@ -19,7 +19,7 @@ import { createWorktree, worktreeDiff, git, type WorktreeDiff, type WorktreeInfo import { runTask, runPlanner, runConflict, type RunnerFn, type PlannerFn } from './runner.js'; import { runVerify, type VerifyFn } from './verify.js'; import { reviewCode, reviewSecurity, type ReviewerFn } from './reviewer.js'; -import { runChecks, diffSizeGate, type ChecksFn, type DiffGateFn } from './checks.js'; +import { runChecks, diffSizeGate, scopeFileGate, type ChecksFn, type DiffGateFn, type ScopeGateFn } from './checks.js'; /** pipeline 的依赖注入面(便于单测 mock);默认值取真实现。 */ export interface PipelineDeps { @@ -37,6 +37,8 @@ export interface PipelineDeps { checks?: ChecksFn; /** diff 体量闸,默认 checks.diffSizeGate。 */ diffGate?: DiffGateFn; + /** diff 声明外文件闸,默认 checks.scopeFileGate(task.scopeFiles 非空时才生效)。 */ + scopeGate?: ScopeGateFn; } /** 执行前同步 defaultBranch 默认实现:fetch→merge origin/;失败再试本地 。 */ @@ -67,6 +69,11 @@ async function runApproveGates(job: JobSpec, wt: WorktreeInfo, deps: PipelineDep if (!cr.ok) return cr.error ?? `分项检查失败:${cr.failed}`; const dg = await (deps.diffGate ?? diffSizeGate)(job.project.repoPath, wt.dir, wt.branch, job.project.defaultBranch); if (!dg.ok) return dg.error ?? 'diff 体量超阈值'; + const scope = job.task.scopeFiles; + if (scope && scope.length > 0) { + const sg = await (deps.scopeGate ?? scopeFileGate)(job.project.repoPath, wt.branch, job.project.defaultBranch, scope); + if (!sg.ok) return sg.error ?? 'diff 触碰声明范围外文件'; + } return null; } @@ -82,6 +89,7 @@ export const realDeps: PipelineDeps = { runConflict, checks: runChecks, diffGate: diffSizeGate, + scopeGate: scopeFileGate, }; /** emit:把一条 OutboxPayload 交给 outbox(worker 传 appendOutbox 包装,测试传收集器)。 */ @@ -330,7 +338,10 @@ export function parseDecompose(text: string): DecomposeResult | null { !!s && typeof (s as { title?: unknown }).title === 'string' && ['easy', 'medium', 'hard'].includes((s as { complexity?: unknown }).complexity as string)) .map((s) => { - const raw = s as unknown as { priority?: unknown; deps?: unknown }; + const raw = s as unknown as { priority?: unknown; deps?: unknown; files?: unknown }; + const files = Array.isArray(raw.files) + ? (raw.files as unknown[]).filter((f): f is string => typeof f === 'string' && f.trim().length > 0).map((f) => f.trim()) + : undefined; return { title: String(s.title).trim(), complexity: s.complexity, @@ -338,6 +349,7 @@ export function parseDecompose(text: string): DecomposeResult | null { deps: Array.isArray(raw.deps) ? (raw.deps as unknown[]).filter((d): d is number => typeof d === 'number') : [] as number[], + ...(files && files.length ? { files } : {}), }; }) .filter((s) => s.title.length > 0); diff --git a/src/executor/protocol.ts b/src/executor/protocol.ts index 010923b..4ce4619 100644 --- a/src/executor/protocol.ts +++ b/src/executor/protocol.ts @@ -89,6 +89,7 @@ export interface DecomposeResult { complexity: 'easy' | 'medium' | 'hard'; priority?: number; // 0=P0最高/1=P1中/2=P2最低,缺省=1 deps?: number[]; // 引用同列表其他子任务的 0-based 序号,缺省=[] + files?: string[]; // 声明的改动文件范围(glob/路径),缺省=不限(落 task.scopeFiles) }>; } @@ -138,6 +139,7 @@ export type OutboxRecord = complexity: 'easy' | 'medium' | 'hard'; priority?: number; deps?: number[]; + files?: string[]; }>; transcriptRef: string | null; sessionId: string | null; } diff --git a/src/executor/runner.ts b/src/executor/runner.ts index 21eecd6..723f941 100644 --- a/src/executor/runner.ts +++ b/src/executor/runner.ts @@ -77,6 +77,15 @@ function lastErrorLines(task: Task): string[] { ]; } +/** 声明文件范围约束(task.scopeFiles 非空时注入):改动越界文件会被硬闸拦截、任务打回。 */ +function scopeConstraintLines(task: Task): string[] { + const scope = task.scopeFiles?.filter((s) => s.trim()); + if (!scope || scope.length === 0) return []; + return [ + `- 本任务声明的改动文件范围(仅允许改动这些 glob/路径,越界会被硬闸拦截并打回):${scope.map((s) => `\`${s}\``).join('、')}`, + ]; +} + /** 组装任务提示词:标题 + 全局/项目规范(L4/L2) + operations/spec 全文 + 上次失败(L1) + 执行约束 */ export function buildPrompt(task: Task, project?: Project, globalRules?: string | null): string { const body = task.operations ?? task.spec ?? task.plan ?? ''; @@ -92,6 +101,7 @@ export function buildPrompt(task: Task, project?: Project, globalRules?: string ...lastErrorLines(task), '## 执行约束(必须遵守)', '- 只在当前工作目录(git worktree)内改动文件,不得读写或修改 worktree 之外的任何文件。', + ...scopeConstraintLines(task), `- 完成后用 \`git add -A && git commit\` 提交全部改动,commit message 必须包含任务 ID「${task.id}」。`, '- 不得执行 git push / git merge / 切换分支,当前分支就是你的工作分支。', '- 不要发布、部署或执行任何有外部副作用的操作。', @@ -198,20 +208,21 @@ export function buildPlannerPrompt(task: Task, kind: PlanKind, project?: Project '- complexity:easy(单文件机械改动/无设计)、medium(需方案、跨几处)、hard(仍需进一步拆解,仅剩余可拆层数>0时才能用)', '- priority:0=P0最紧急 / 1=P1中 / 2=P2最低', '- deps:依赖本列表中其他子任务的 0-based 序号(无依赖填 [])', + '- files:该子任务预计改动的文件范围(glob/路径,如 ["src/foo/**","lib/a.ts"])。尽量精确——执行时改动越界文件会被硬闸拦截。无法确定就留空数组(不限范围)。', '', '## 输出格式(重要,必须严格遵守)', '**第一步**:先输出一个 Markdown 表格总览:', - '| # | 子任务标题 | 复杂度 | 优先级 | 依赖序号 |', - '|---|---|---|---|---|', - '(每行一个子任务,依赖序号填 0-based 序号列表如 "0,1" 或留空)', + '| # | 子任务标题 | 复杂度 | 优先级 | 依赖序号 | 文件范围 |', + '|---|---|---|---|---|---|', + '(每行一个子任务,依赖序号填 0-based 序号列表如 "0,1" 或留空;文件范围填预计改动的路径/glob 或留空)', '', '**第二步**:再写一段分析与拆解理由。', '', '**第三步**:在【最后】输出唯一一个 ```json 代码块,严格格式:', '```json', - '{"plan":"一句话分析与拆解理由","subtasks":[{"title":"子任务标题","complexity":"easy","priority":1,"deps":[]}]}', + '{"plan":"一句话分析与拆解理由","subtasks":[{"title":"子任务标题","complexity":"easy","priority":1,"deps":[],"files":["src/foo/**"]}]}', '```', - 'subtasks 至少 1 个;complexity 只能是 easy|medium|hard;deps 是 0-based 序号数组;除这个 JSON 块外不要再写其它 ``` 代码块。', + 'subtasks 至少 1 个;complexity 只能是 easy|medium|hard;deps 是 0-based 序号数组;files 是文件 glob/路径数组(可空);除这个 JSON 块外不要再写其它 ``` 代码块。', ].join('\n'); } diff --git a/src/model/types.ts b/src/model/types.ts index b409b09..8d54ade 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -13,6 +13,17 @@ export const HARD_MAX_DEPTH = 4; /** 自动执行边界(Easy 任务由后台执行器自动跑到什么程度) */ export type Autonomy = 'manual' | 'auto-easy' | 'auto-approved'; +/** 单角色模型配置:字符串=全复杂度统一;对象=按复杂度(easy/medium/hard)分档。缺项回落默认。 */ +export type ModelTierConfig = string | { easy?: string; medium?: string; hard?: string }; + +/** 项目级可配置模型(按场景/角色覆盖默认 opus-4.8;缺省角色走默认)。存 projects.models(JSON)。 */ +export interface ProjectModels { + executor?: ModelTierConfig; // 执行改动 + planner?: ModelTierConfig; // 拆解 Hard / 写方案 Medium + reviewer?: ModelTierConfig; // 执行后双复审(code + security) + conflict?: ModelTierConfig; // 解合并冲突 +} + export interface Project { id: Id; name: string; @@ -39,6 +50,8 @@ export interface Project { budgetPeriod?: 'day' | 'month'; // 预算周期(默认 month) /** L2 项目级 agent 执行规范(注入 executor/planner/reviewer/conflict 的 prompt 头部)*/ agentRules?: string | null; + /** 项目级可配置模型(按角色/复杂度覆盖默认 opus-4.8;null=全用默认)*/ + models?: ProjectModels | null; } export interface ApprovalRecord { @@ -81,6 +94,7 @@ export interface Task { status: TaskStatus; priority: number; deps: Id[]; // 兄弟依赖(同层) + scopeFiles?: string[] | null; // 声明的改动文件范围(glob/路径);非空时 diff 触碰范围外文件 = 硬闸拦截 // 三选一产出(按复杂度) plan: string | null; // Hard:分析 + 拆解说明 spec: string | null; // Medium:改动内容 + 为什么 diff --git a/src/store/db.ts b/src/store/db.ts index 7be1c14..c357c09 100644 --- a/src/store/db.ts +++ b/src/store/db.ts @@ -43,6 +43,8 @@ export function openDb(file: string): Database.Database { ensureColumn(db, 'projects', 'auto_approve_plan', 'auto_approve_plan INTEGER NOT NULL DEFAULT 0'); // 拆解全 easy 时跳过 plan_review ensureColumn(db, 'projects', 'auto_approve_exec', 'auto_approve_exec INTEGER NOT NULL DEFAULT 0'); // 双复审 approve 后跳过 exec_review 自动合并 ensureColumn(db, 'projects', 'agent_rules', 'agent_rules TEXT'); // L2 项目级 agent 执行规范(注入 worker prompt) + ensureColumn(db, 'projects', 'models', 'models TEXT'); // 项目级可配置模型 JSON(按角色/复杂度覆盖默认 opus-4.8) + ensureColumn(db, 'tasks', 'scope_files', 'scope_files TEXT'); // 声明的改动文件范围 JSON(diff 越界硬闸) const schema = readFileSync(join(HERE, 'schema.sql'), 'utf8'); db.exec(schema); return db; diff --git a/src/store/mappers.ts b/src/store/mappers.ts index a63e5c8..23238ff 100644 --- a/src/store/mappers.ts +++ b/src/store/mappers.ts @@ -13,6 +13,7 @@ export interface ProjectRow { checks: string | null; auto_approve_plan: number; auto_approve_exec: number; budget_usd: number | null; budget_period: string | null; agent_rules: string | null; + models: string | null; } export interface TaskRow { id: string; project_id: string; parent_id: string | null; depth: number; @@ -23,6 +24,7 @@ export interface TaskRow { created_at: string; updated_at: string; source_ref: string | null; attachments: string | null; + scope_files: string | null; last_run_error?: string | null; // pendingApprovals 扩展字段(subquery) } export interface ApprovalRow { @@ -53,9 +55,25 @@ export function rowToProject(r: ProjectRow): Project { budgetUsd: r.budget_usd ?? null, budgetPeriod: (r.budget_period === 'day' ? 'day' : 'month'), agentRules: r.agent_rules ?? null, + models: parseModels(r.models), }; } +/** 解析 projects.models(JSON);非法/空 → null。仅取 4 个已知角色键。 */ +function parseModels(json: string | null): import('../model/types.js').ProjectModels | null { + if (!json?.trim()) return null; + try { + const obj = JSON.parse(json) as Record; + const out: import('../model/types.js').ProjectModels = {}; + for (const role of ['executor', 'planner', 'reviewer', 'conflict'] as const) { + const v = obj[role]; + if (typeof v === 'string' && v.trim()) out[role] = v.trim(); + else if (v && typeof v === 'object') out[role] = v as { easy?: string; medium?: string; hard?: string }; + } + return Object.keys(out).length ? out : null; + } catch { return null; } +} + /** 旧 result JSON 兜底:summary/verdict/securitySummary/securityVerdict 是后加字段,老数据缺省补 null */ function parseResult(json: string): TaskResult { const raw = JSON.parse(json) as Partial; @@ -79,6 +97,7 @@ export function rowToTask(r: TaskRow, approvals: ApprovalRecord[] = []): Task { id: r.id, projectId: r.project_id, parentId: r.parent_id, depth: r.depth, title: r.title, complexity: r.complexity as Complexity, status: r.status as TaskStatus, priority: r.priority, deps: JSON.parse(r.deps) as string[], + scopeFiles: parseScopeFiles(r.scope_files), plan: r.plan, spec: r.spec, operations: r.operations, approvals, result: r.result ? parseResult(r.result) : null, assignee: r.assignee as Task['assignee'], retryBaseline: r.retry_baseline ?? 0, @@ -89,6 +108,17 @@ export function rowToTask(r: TaskRow, approvals: ApprovalRecord[] = []): Task { }; } +/** 解析 tasks.scope_files(JSON 字符串数组);非法/空 → null。 */ +function parseScopeFiles(json: string | null): string[] | null { + if (!json?.trim()) return null; + try { + const arr = JSON.parse(json) as unknown; + if (!Array.isArray(arr)) return null; + const out = arr.filter((x): x is string => typeof x === 'string' && x.trim().length > 0).map((s) => s.trim()); + return out.length ? out : null; + } catch { return null; } +} + export function rowToApproval(r: ApprovalRow): ApprovalRecord { return { gate: r.gate as GateKind, action: r.action as ApprovalRecord['action'], diff --git a/src/store/schema.sql b/src/store/schema.sql index 8bd7307..8baa453 100644 --- a/src/store/schema.sql +++ b/src/store/schema.sql @@ -23,7 +23,8 @@ CREATE TABLE IF NOT EXISTS projects ( auto_approve_exec INTEGER NOT NULL DEFAULT 0, -- 双复审 approve 后跳过 exec_review(0=关) budget_usd REAL, -- 项目当期预算上限 USD(null=不限) budget_period TEXT NOT NULL DEFAULT 'month', -- 预算周期 day | month - agent_rules TEXT -- L2 项目级 agent 执行规范(注入 worker prompt) + agent_rules TEXT, -- L2 项目级 agent 执行规范(注入 worker prompt) + models TEXT -- 项目级可配置模型 JSON(按角色/复杂度覆盖默认 opus-4.8) ); CREATE TABLE IF NOT EXISTS tasks ( @@ -46,7 +47,8 @@ CREATE TABLE IF NOT EXISTS tasks ( created_at TEXT NOT NULL, updated_at TEXT NOT NULL, source_ref TEXT, -- 旧 todo 来源标识(todo:17 / todo:17/1A),项目内唯一 - attachments TEXT -- 附件 JSON [{name,type,path}](图片/文件随任务提交) + attachments TEXT, -- 附件 JSON [{name,type,path}](图片/文件随任务提交) + scope_files TEXT -- 声明的改动文件范围 JSON(glob/路径);diff 越界=硬闸 ); CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id, status); CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id); diff --git a/src/store/store.ts b/src/store/store.ts index d4e952d..131b439 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -4,7 +4,7 @@ import { rowToProject, rowToTask, rowToApproval, rowToRun, rowToEvent, type ProjectRow, type TaskRow, type ApprovalRow, type RunRow, type EventRow, } from './mappers.js'; -import type { Project, Task, ApprovalRecord, Run, Event, TaskResult, Autonomy, EventType, Attachment } from '../model/types.js'; +import type { Project, Task, ApprovalRecord, Run, Event, TaskResult, Autonomy, EventType, Attachment, ProjectModels } from '../model/types.js'; import { DEFAULT_MAX_DEPTH, HARD_MAX_DEPTH } from '../model/types.js'; import type { Complexity } from '../model/complexity.js'; import { rankByScore } from '../model/scoring.js'; @@ -31,6 +31,33 @@ function periodStartISO(period: 'day' | 'month'): string { export class StoreError extends Error {} +const MODEL_ROLES = ['executor', 'planner', 'reviewer', 'conflict'] as const; +/** 规范化项目级模型配置:仅保留 4 个已知角色,值为非空字符串或 {easy/medium/hard} 子集;非法值丢弃。 */ +function sanitizeModels(input: ProjectModels): ProjectModels { + const out: ProjectModels = {}; + for (const role of MODEL_ROLES) { + const v = (input as Record)[role]; + if (typeof v === 'string') { + if (v.trim()) out[role] = v.trim(); + } else if (v && typeof v === 'object') { + const tier: { easy?: string; medium?: string; hard?: string } = {}; + for (const c of ['easy', 'medium', 'hard'] as const) { + const m = (v as Record)[c]; + if (typeof m === 'string' && m.trim()) tier[c] = m.trim(); + } + if (Object.keys(tier).length) out[role] = tier; + } + } + return out; +} + +/** 规范化声明文件范围:去空白/空项;空数组或非数组 → null(落库存 JSON 文本或 null)。 */ +function normalizeScopeFiles(input: string[] | null | undefined): string | null { + if (!Array.isArray(input)) return null; + const out = input.filter((s): s is string => typeof s === 'string' && s.trim().length > 0).map((s) => s.trim()); + return out.length ? JSON.stringify(out) : null; +} + export interface CreateProjectInput { name: string; repoPath: string; defaultBranch?: string; verifyCmd?: string | null; autonomy?: Autonomy; model?: string | null; concurrency?: number; @@ -39,6 +66,7 @@ export interface CreateProjectInput { export interface CreateTaskInput { projectId: string; title: string; complexity: Complexity; parentId?: string | null; priority?: number; deps?: string[]; + scopeFiles?: string[] | null; } export interface PatchProjectInput { autonomy?: Autonomy; concurrency?: number; verifyCmd?: string | null; @@ -47,9 +75,11 @@ export interface PatchProjectInput { budgetUsd?: number | null; budgetPeriod?: 'day' | 'month'; autoApprovePlan?: boolean; autoApproveExec?: boolean; checks?: string | null; agentRules?: string | null; + models?: ProjectModels | null; } export interface PatchTaskInput { title?: string; priority?: number; complexity?: Complexity; deps?: string[]; + scopeFiles?: string[] | null; } /** 成本明细(按项目 / 按模型 / 总计),供 GET /api/usage 与预算判定 */ export interface CostSummary { @@ -132,7 +162,7 @@ export class Store { status: 'active', created_at: now(), last_sync_at: null, logo: null, sort_order: maxOrder + 1, checks: null, auto_approve_plan: 0, auto_approve_exec: 0, - budget_usd: null, budget_period: 'month', agent_rules: null, + budget_usd: null, budget_period: 'month', agent_rules: null, models: null, }; this.db.prepare( `INSERT INTO projects (id,name,repo_path,default_branch,verify_cmd,autonomy,model,concurrency,max_retries,timeout_ms,status,created_at,last_sync_at,logo,sort_order,checks,auto_approve_plan,auto_approve_exec) @@ -240,6 +270,10 @@ export class Store { } if (patch.checks !== undefined) { sets.push('checks = @checks'); args.checks = patch.checks; } if (patch.agentRules !== undefined) { sets.push('agent_rules = @agent_rules'); args.agent_rules = patch.agentRules; } + if (patch.models !== undefined) { + sets.push('models = @models'); + args.models = patch.models == null ? null : JSON.stringify(sanitizeModels(patch.models)); + } if (sets.length > 0) { this.db.prepare(`UPDATE projects SET ${sets.join(', ')} WHERE id = @id`).run(args); @@ -288,10 +322,11 @@ export class Store { deps: JSON.stringify(input.deps ?? []), plan: null, spec: null, operations: null, result: null, assignee: null, retry_baseline: 0, next_eligible_at: null, created_at: now(), updated_at: now(), source_ref: null, attachments: null, + scope_files: normalizeScopeFiles(input.scopeFiles), }; this.db.prepare( - `INSERT INTO tasks (id,project_id,parent_id,depth,title,complexity,status,priority,deps,plan,spec,operations,result,assignee,retry_baseline,created_at,updated_at,source_ref) - VALUES (@id,@project_id,@parent_id,@depth,@title,@complexity,@status,@priority,@deps,@plan,@spec,@operations,@result,@assignee,@retry_baseline,@created_at,@updated_at,@source_ref)`, + `INSERT INTO tasks (id,project_id,parent_id,depth,title,complexity,status,priority,deps,plan,spec,operations,result,assignee,retry_baseline,created_at,updated_at,source_ref,scope_files) + VALUES (@id,@project_id,@parent_id,@depth,@title,@complexity,@status,@priority,@deps,@plan,@spec,@operations,@result,@assignee,@retry_baseline,@created_at,@updated_at,@source_ref,@scope_files)`, ).run(row); this.emit(input.projectId, row.id, 'task.created', { title: row.title, complexity: row.complexity, status }); return rowToTask(row); @@ -477,6 +512,11 @@ export class Store { this.db.prepare(`UPDATE tasks SET priority = ?, updated_at = ? WHERE id = ?`).run(patch.priority, now(), taskId); fields.push('priority'); } + if (patch.scopeFiles !== undefined) { + this.db.prepare(`UPDATE tasks SET scope_files = ?, updated_at = ? WHERE id = ?`) + .run(normalizeScopeFiles(patch.scopeFiles), now(), taskId); + fields.push('scopeFiles'); + } let statusChange: { from: TaskStatus; to: TaskStatus } | null = null; if (patch.complexity !== undefined && patch.complexity !== row.complexity) { diff --git a/test/checks.test.ts b/test/checks.test.ts index 54285c3..33a8b84 100644 --- a/test/checks.test.ts +++ b/test/checks.test.ts @@ -4,7 +4,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { execFileSync } from 'node:child_process'; -import { parseChecks, runChecks, diffSizeGate } from '../src/executor/checks.js'; +import { parseChecks, runChecks, diffSizeGate, globToRegExp, matchesAnyGlob, scopeFileGate } from '../src/executor/checks.js'; import type { Project } from '../src/model/types.js'; function withTmpData(cb: (dir: string) => Promise | void): Promise | void { @@ -94,3 +94,54 @@ test('diffSizeGate:git 出错 → 不拦截(测不出体量)', async () => const r = await diffSizeGate('/nonexistent-repo-xyz', '/tmp', 'a', 'b'); assert.equal(r.ok, true); }); + +test('globToRegExp / matchesAnyGlob:* 单层、** 跨层、? 单字符、目录式', () => { + assert.ok(globToRegExp('src/*.ts').test('src/a.ts')); + assert.ok(!globToRegExp('src/*.ts').test('src/sub/a.ts')); // * 不跨 / + assert.ok(globToRegExp('src/**').test('src/sub/deep/a.ts')); // ** 跨 / + assert.ok(globToRegExp('src/').test('src/anything/x.ts')); // 目录式 → 其下全部 + assert.ok(globToRegExp('a?.ts').test('ab.ts')); + assert.ok(!globToRegExp('a?.ts').test('abc.ts')); + assert.ok(globToRegExp('file.name.js').test('file.name.js')); // . 字面量 + assert.ok(!globToRegExp('file.name.js').test('fileXname.js')); + assert.ok(matchesAnyGlob('lib/x.ts', ['src/**', 'lib/*.ts'])); + assert.ok(!matchesAnyGlob('test/x.ts', ['src/**', 'lib/*.ts'])); +}); + +test('scopeFileGate:改动越界文件 → 拦截;范围内 → 通过;空声明 → 跳过', async () => { + const repo = mkdtempSync(join(tmpdir(), 'maestro-scope-')); + const g = (args: string[]) => execFileSync('git', args, { cwd: repo }).toString(); + g(['init', '-q', '-b', 'main']); + g(['config', 'user.email', 't@t']); g(['config', 'user.name', 't']); + writeFileSync(join(repo, 'base.txt'), 'base\n'); + g(['add', '-A']); g(['commit', '-qm', 'base']); + g(['checkout', '-q', '-b', 'feature']); + execFileSync('mkdir', ['-p', join(repo, 'src')]); + writeFileSync(join(repo, 'src', 'a.ts'), 'a\n'); + writeFileSync(join(repo, 'other.txt'), 'o\n'); // 越界文件 + g(['add', '-A']); g(['commit', '-qm', 'feat']); + + try { + // 声明只允许 src/**,但改了 other.txt → 拦截 + const over = await scopeFileGate(repo, 'feature', 'main', ['src/**']); + assert.equal(over.ok, false); + assert.deepEqual(over.outside, ['other.txt']); + assert.match(over.error!, /声明范围外/); + + // 声明覆盖全部改动 → 通过 + const ok = await scopeFileGate(repo, 'feature', 'main', ['src/**', 'other.txt']); + assert.equal(ok.ok, true); + assert.deepEqual(ok.outside, []); + + // 空声明 → 跳过(不限范围) + const skip = await scopeFileGate(repo, 'feature', 'main', []); + assert.equal(skip.ok, true); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +}); + +test('scopeFileGate:git 出错 → 不拦截', async () => { + const r = await scopeFileGate('/nonexistent-repo-xyz', 'a', 'b', ['src/**']); + assert.equal(r.ok, true); +}); diff --git a/test/db-migration.test.ts b/test/db-migration.test.ts index cca9a92..3ff2f8e 100644 --- a/test/db-migration.test.ts +++ b/test/db-migration.test.ts @@ -31,11 +31,14 @@ test('迁移:旧 projects 表缺新列 → openDb 补齐 + createProject 不 const db = openDb(file); const cols = (db.prepare(`PRAGMA table_info(projects)`).all() as Array<{ name: string }>).map((c) => c.name); for (const c of [ - 'checks', 'auto_approve_plan', 'auto_approve_exec', 'agent_rules', + 'checks', 'auto_approve_plan', 'auto_approve_exec', 'agent_rules', 'models', 'budget_usd', 'budget_period', 'max_retries', 'timeout_ms', 'last_sync_at', 'logo', 'sort_order', ]) { assert.ok(cols.includes(c), `应补列 ${c}`); } + // tasks.scope_files 也走 ensureColumn 迁移 + const taskCols = (db.prepare(`PRAGMA table_info(tasks)`).all() as Array<{ name: string }>).map((c) => c.name); + assert.ok(taskCols.includes('scope_files'), '应补 tasks.scope_files 列'); db.close(); // Store 在该旧库上建项目(INSERT 引用 checks/auto_approve_* 列)应成功 diff --git a/test/memory-inject.test.ts b/test/memory-inject.test.ts index 96ddc85..5757f5b 100644 --- a/test/memory-inject.test.ts +++ b/test/memory-inject.test.ts @@ -112,3 +112,12 @@ test('lastRunErrorOf 取最近一次失败 run 的错误(任意 kind)', () = assert.equal(s.lastRunErrorOf(t.id), '第一次失败:编译错误'); s.close(); }); + +test('buildPrompt:task.scopeFiles 非空 → 注入声明文件范围约束;为空 → 不注入', () => { + const withScope = buildPrompt(fakeTask({ scopeFiles: ['src/foo/**', 'lib/a.ts'] })); + assert.match(withScope, /声明的改动文件范围/); + assert.match(withScope, /src\/foo\/\*\*/); + assert.match(withScope, /越界会被硬闸拦截/); + const noScope = buildPrompt(fakeTask({ scopeFiles: null })); + assert.doesNotMatch(noScope, /声明的改动文件范围/); +}); diff --git a/test/models.test.ts b/test/models.test.ts index c84195f..89b261f 100644 --- a/test/models.test.ts +++ b/test/models.test.ts @@ -1,14 +1,14 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { pickModel, isModelError, pickFallbackModel, MODEL_FALLBACK_CHAIN } from '../src/executor/models.js'; -import type { Project, Task } from '../src/model/types.js'; +import { pickModel, resolveModel, isModelError, pickFallbackModel, MODEL_FALLBACK_CHAIN, DEFAULT_MODEL } from '../src/executor/models.js'; +import type { Project, Task, ProjectModels } from '../src/model/types.js'; import type { Complexity } from '../src/model/complexity.js'; function fakeTask(complexity: Complexity): Task { return { complexity } as Task; } -function fakeProject(model: string | null = null): Project { - return { model } as Project; +function fakeProject(over: Partial = {}): Project { + return { model: null, models: null, ...over } as Project; } /** 临时设置 env,跑完恢复(避免污染其他用例) */ @@ -31,58 +31,79 @@ function withEnv(vars: Record, fn: () => void): void { const ENV_KEYS = [ 'MAESTRO_MODEL_EASY', 'MAESTRO_MODEL_MEDIUM', 'MAESTRO_MODEL_HARD', 'MAESTRO_MODEL_REVIEW_EASY', 'MAESTRO_MODEL_REVIEW_MEDIUM', 'MAESTRO_MODEL_REVIEW_HARD', + 'MAESTRO_MODEL_PLAN_EASY', 'MAESTRO_MODEL_PLAN_MEDIUM', 'MAESTRO_MODEL_PLAN_HARD', ]; +const clearEnv = (): Record => Object.fromEntries(ENV_KEYS.map((k) => [k, ''])); -test('pickModel:复杂度映射默认值(executor / reviewer)', () => { - // 防止外部 env 干扰:先清空再断言默认 - withEnv(Object.fromEntries(ENV_KEYS.map((k) => [k, ''])), () => { +test('默认档:所有角色 × 所有复杂度统一 opus-4.8(无 project/env 配置)', () => { + withEnv(clearEnv(), () => { const p = fakeProject(); - assert.equal(pickModel(fakeTask('easy'), p, 'executor'), 'claude-sonnet-4-6'); - assert.equal(pickModel(fakeTask('medium'), p, 'executor'), 'claude-opus-4-8'); - assert.equal(pickModel(fakeTask('hard'), p, 'executor'), 'claude-opus-4-8'); - // reviewer 三档统一 fable-5(复审是质量关口,用最强档保严格度) - assert.equal(pickModel(fakeTask('easy'), p, 'reviewer'), 'claude-fable-5'); - assert.equal(pickModel(fakeTask('medium'), p, 'reviewer'), 'claude-fable-5'); - assert.equal(pickModel(fakeTask('hard'), p, 'reviewer'), 'claude-fable-5'); - // planner hard 拆解最烧脑 → fable-5;medium=opus;easy=sonnet - assert.equal(pickModel(fakeTask('hard'), p, 'planner'), 'claude-fable-5'); - assert.equal(pickModel(fakeTask('medium'), p, 'planner'), 'claude-opus-4-8'); - assert.equal(pickModel(fakeTask('easy'), p, 'planner'), 'claude-sonnet-4-6'); - }); -}); - -test('pickModel:project.model executor 最优先,但 reviewer/conflict 不受 project.model 覆盖', () => { - withEnv(Object.fromEntries(ENV_KEYS.map((k) => [k, ''])), () => { - const p = fakeProject('claude-opus-4-6'); - for (const c of ['easy', 'medium', 'hard'] as const) { - // executor 受 project.model 覆盖 - assert.equal(pickModel(fakeTask(c), p, 'executor'), 'claude-opus-4-6'); - // reviewer 不受 project.model 覆盖,始终用 fable-5(保自审独立性) - assert.equal(pickModel(fakeTask(c), p, 'reviewer'), 'claude-fable-5'); - // conflict 固定 fable-5(解冲突恒用最强档) - assert.equal(pickModel(fakeTask(c), p, 'conflict'), 'claude-fable-5'); + assert.equal(DEFAULT_MODEL, 'claude-opus-4-8'); + for (const role of ['executor', 'planner', 'reviewer', 'conflict'] as const) { + for (const c of ['easy', 'medium', 'hard'] as Complexity[]) { + assert.equal(pickModel(fakeTask(c), p, role), 'claude-opus-4-8', `${role}/${c}`); + } } }); }); -test('pickModel:env 覆盖默认值,executor/reviewer 各用各的 env', () => { - withEnv({ - MAESTRO_MODEL_EASY: 'claude-haiku-4-5', - MAESTRO_MODEL_REVIEW_EASY: 'claude-sonnet-4-6', // reviewer env 可单独覆盖 - }, () => { - const p = fakeProject(); - assert.equal(pickModel(fakeTask('easy'), p, 'executor'), 'claude-haiku-4-5'); - // reviewer easy 被 MAESTRO_MODEL_REVIEW_EASY 覆盖 - assert.equal(pickModel(fakeTask('easy'), p, 'reviewer'), 'claude-sonnet-4-6'); - // reviewer hard 无 env → 默认 fable-5 - assert.equal(pickModel(fakeTask('hard'), p, 'reviewer'), 'claude-fable-5'); - assert.equal(pickModel(fakeTask('hard'), p, 'executor'), 'claude-opus-4-8'); +test('project.models:按角色覆盖默认(字符串=全复杂度统一)', () => { + withEnv(clearEnv(), () => { + const models: ProjectModels = { reviewer: 'claude-fable-5', conflict: 'claude-fable-5' }; + const p = fakeProject({ models }); + // reviewer/conflict 被项目级 models 覆盖成 fable-5(含三档) + for (const c of ['easy', 'medium', 'hard'] as Complexity[]) { + assert.equal(pickModel(fakeTask(c), p, 'reviewer'), 'claude-fable-5'); + assert.equal(pickModel(fakeTask(c), p, 'conflict'), 'claude-fable-5'); + } + // executor 未配 → 仍默认 opus + assert.equal(pickModel(fakeTask('easy'), p, 'executor'), 'claude-opus-4-8'); }); }); -test('pickModel:env 为空白字符串视为未设置', () => { - withEnv({ MAESTRO_MODEL_MEDIUM: ' ' }, () => { - assert.equal(pickModel(fakeTask('medium'), fakeProject(), 'executor'), 'claude-opus-4-8'); +test('project.models:按复杂度分档(对象形式),缺档回落默认', () => { + withEnv(clearEnv(), () => { + const models: ProjectModels = { executor: { easy: 'claude-sonnet-4-6', hard: 'claude-fable-5' } }; + const p = fakeProject({ models }); + assert.equal(pickModel(fakeTask('easy'), p, 'executor'), 'claude-sonnet-4-6'); + assert.equal(pickModel(fakeTask('hard'), p, 'executor'), 'claude-fable-5'); + // medium 未在对象里 → 回落默认 opus + assert.equal(pickModel(fakeTask('medium'), p, 'executor'), 'claude-opus-4-8'); + }); +}); + +test('优先级:project.models > 旧 project.model > env > 默认', () => { + withEnv({ ...clearEnv(), MAESTRO_MODEL_MEDIUM: 'claude-haiku-4-5' }, () => { + // models.executor 覆盖一切 + const p1 = fakeProject({ model: 'claude-opus-4-6', models: { executor: 'claude-sonnet-4-6' } }); + assert.equal(pickModel(fakeTask('medium'), p1, 'executor'), 'claude-sonnet-4-6'); + // 无 models → 旧 project.model 覆盖 env + const p2 = fakeProject({ model: 'claude-opus-4-6' }); + assert.equal(pickModel(fakeTask('medium'), p2, 'executor'), 'claude-opus-4-6'); + // 无 models / 无 project.model → env 覆盖默认 + const p3 = fakeProject(); + assert.equal(pickModel(fakeTask('medium'), p3, 'executor'), 'claude-haiku-4-5'); + }); +}); + +test('旧 project.model 不覆盖 reviewer/conflict(保自审独立性),但 project.models 可以', () => { + withEnv(clearEnv(), () => { + const p = fakeProject({ model: 'claude-opus-4-6' }); + // project.model 只作用 executor/planner + assert.equal(pickModel(fakeTask('hard'), p, 'executor'), 'claude-opus-4-6'); + assert.equal(pickModel(fakeTask('hard'), p, 'planner'), 'claude-opus-4-6'); + // reviewer/conflict 不受 project.model 影响 → 仍默认 opus + assert.equal(pickModel(fakeTask('hard'), p, 'reviewer'), 'claude-opus-4-8'); + assert.equal(pickModel(fakeTask('hard'), p, 'conflict'), 'claude-opus-4-8'); + // 但显式 project.models.reviewer 可以覆盖 + const p2 = fakeProject({ model: 'claude-opus-4-6', models: { reviewer: 'claude-sonnet-4-6' } }); + assert.equal(pickModel(fakeTask('hard'), p2, 'reviewer'), 'claude-sonnet-4-6'); + }); +}); + +test('resolveModel:env 空白字符串视为未设置', () => { + withEnv({ ...clearEnv(), MAESTRO_MODEL_MEDIUM: ' ' }, () => { + assert.equal(resolveModel(fakeProject(), 'executor', 'medium'), 'claude-opus-4-8'); }); }); @@ -103,10 +124,9 @@ test('isModelError:模型不可用类错误才触发回退', () => { assert.ok(!isModelError('model produced empty output')); // 提到 model 但不是可用性错误 }); -test('pickFallbackModel:回退链取下一档(循环)', () => { - // 链:fable → opus → sonnet → fable(循环,fable 置链首=优先最强档) - assert.deepEqual([...MODEL_FALLBACK_CHAIN], ['claude-fable-5', 'claude-opus-4-8', 'claude-sonnet-4-6']); - assert.equal(pickFallbackModel('claude-fable-5'), 'claude-opus-4-8'); +test('pickFallbackModel:回退链取下一档(opus→sonnet→fable→opus,opus 置链首=默认档优先)', () => { + assert.deepEqual([...MODEL_FALLBACK_CHAIN], ['claude-opus-4-8', 'claude-sonnet-4-6', 'claude-fable-5']); assert.equal(pickFallbackModel('claude-opus-4-8'), 'claude-sonnet-4-6'); assert.equal(pickFallbackModel('claude-sonnet-4-6'), 'claude-fable-5'); + assert.equal(pickFallbackModel('claude-fable-5'), 'claude-opus-4-8'); }); diff --git a/test/pipeline.test.ts b/test/pipeline.test.ts index 7b68055..37e374f 100644 --- a/test/pipeline.test.ts +++ b/test/pipeline.test.ts @@ -272,3 +272,10 @@ test('parseDecompose:取最后一个 json 块;非法/缺失 → null', () => assert.equal(ok.subtasks.length, 1, '过滤空标题与非法复杂度'); assert.equal(ok.subtasks[0].title, 'a'); }); + +test('parseDecompose:解析 files(声明文件范围),去空白/空项;缺省不带 files', () => { + const r = parseDecompose('```json\n{"plan":"p","subtasks":[{"title":"a","complexity":"easy","files":[" src/** ","","lib/x.ts"]},{"title":"b","complexity":"easy"}]}\n```'); + assert.ok(r); + assert.deepEqual(r.subtasks[0].files, ['src/**', 'lib/x.ts']); + assert.equal(r.subtasks[1].files, undefined, '无 files 字段 → 不带(不限范围)'); +}); diff --git a/test/store.test.ts b/test/store.test.ts index 510e807..5ec07a5 100644 --- a/test/store.test.ts +++ b/test/store.test.ts @@ -20,6 +20,47 @@ test('project + task creation, complexity routes initial status', () => { s.close(); }); +test('patchProject models:持久化 + 读回(字符串/对象形式,非法值丢弃,null 清空)', () => { + const s = freshStore(); + const p = s.createProject({ name: 'mdl', repoPath: '/tmp/mdl-' + Math.random() }); + assert.equal(p.models, null); + + const up = s.patchProject(p.id, { + models: { + executor: { easy: 'claude-sonnet-4-6', hard: 'claude-opus-4-8' }, + reviewer: 'claude-fable-5', + // @ts-expect-error 测试非法角色被丢弃 + bogus: 'x', + }, + }); + assert.deepEqual(up.models, { + executor: { easy: 'claude-sonnet-4-6', hard: 'claude-opus-4-8' }, + reviewer: 'claude-fable-5', + }); + // 读回(走 mapper)一致 + assert.deepEqual(s.getProject(p.id)!.models, up.models); + + // null 清空 + const cleared = s.patchProject(p.id, { models: null }); + assert.equal(cleared.models, null); + s.close(); +}); + +test('task scopeFiles:create + patch 持久化与读回,去空白/空数组→null', () => { + const s = freshStore(); + const p = s.createProject({ name: 'scp', repoPath: '/tmp/scp-' + Math.random() }); + const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy', scopeFiles: [' src/** ', '', 'lib/a.ts'] }); + assert.deepEqual(t.scopeFiles, ['src/**', 'lib/a.ts']); // 去空白 + 丢空项 + assert.deepEqual(s.getTask(t.id)!.scopeFiles, ['src/**', 'lib/a.ts']); + + const patched = s.patchTask(t.id, { scopeFiles: ['only.ts'] }); + assert.deepEqual(patched.scopeFiles, ['only.ts']); + + const emptied = s.patchTask(t.id, { scopeFiles: [] }); + assert.equal(emptied.scopeFiles, null); // 空数组 → null(不限范围) + s.close(); +}); + test('Easy 路径:写操作 → ready → nextExecutable 领取', () => { const s = freshStore(); const p = s.createProject({ name: 'e', repoPath: '/tmp/e-' + Math.random() });