feat(models+gate): 模型按项目可配置(默认 opus-4.8) + diff 声明外文件硬闸

① 模型可配置(项目维度,默认 opus-4.8)
- 新增 projects.models(JSON,按角色 executor/planner/reviewer/conflict
  覆盖,值可为字符串=全复杂度统一 或 {easy,medium,hard} 分档)
- models.ts 重构 resolveModel:优先级 项目级 models > 旧 project.model
  (仅 executor/planner) > env > 默认 DEFAULT_MODEL(opus-4.8)
- 取消内置 fable/sonnet 分档默认:所有角色默认 opus-4.8(彻底回避 fable-5
  不可用问题,需要时项目级显式配置即可);回退链改 opus→sonnet→fable
- API PATCH /projects 透传 models;sanitizeModels 落库校验

② diff 声明外文件闸(task.scopeFiles)
- 新增 tasks.scope_files(JSON glob/路径数组)
- checks.ts: globToRegExp/matchesAnyGlob + scopeFileGate(改动文件越界=硬闸,
  空声明跳过,git 出错不拦截);pipeline runApproveGates 接入
- planner 拆解新增每子任务 files 字段:prompt 要求 + parseDecompose 解析 +
  ingest 落 scopeFiles,自动填充声明范围
- executor prompt 注入「声明文件范围约束」,让 agent 知边界(gate 才公平)

迁移:projects.models / tasks.scope_files 走 ensureColumn 幂等迁移(旧库补列)
测试:models 默认/配置/优先级、scope glob/gate、planner files 解析、
      store 持久化往返、迁移补列 —— 237 通过

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-25 07:06:32 +08:00
parent 4c09186f36
commit 59ce391ecc
18 changed files with 431 additions and 118 deletions
+8
View File
@@ -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);
});
+4 -1
View File
@@ -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) {
+66
View File
@@ -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<ScopeGateResult>;
/**
* 把 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<ScopeGateResult> {
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 };
}
+44 -52
View File
@@ -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<ModelRole, Record<Complexity, [env: string, fallback: string]>> = {
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<ModelRole, Record<Complexity, string>> = {
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_MODELopus-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<Complexity, string> {
const out = {} as Record<Complexity, string>;
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
+14 -2
View File
@@ -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.scopeFileGatetask.scopeFiles 非空时才生效)。 */
scopeGate?: ScopeGateFn;
}
/** 执行前同步 defaultBranch 默认实现:fetch→merge origin/<branch>;失败再试本地 <branch>。 */
@@ -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 交给 outboxworker 传 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);
+2
View File
@@ -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;
}
+16 -5
View File
@@ -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
'- complexityeasy(单文件机械改动/无设计)、medium(需方案、跨几处)、hard(仍需进一步拆解,仅剩余可拆层数>0时才能用)',
'- priority0=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|harddeps 是 0-based 序号数组;除这个 JSON 块外不要再写其它 ``` 代码块。',
'subtasks 至少 1 个;complexity 只能是 easy|medium|harddeps 是 0-based 序号数组;files 是文件 glob/路径数组(可空);除这个 JSON 块外不要再写其它 ``` 代码块。',
].join('\n');
}
+14
View File
@@ -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.modelsJSON)。 */
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:改动内容 + 为什么
+2
View File
@@ -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;
+30
View File
@@ -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.modelsJSON);非法/空 → 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<string, unknown>;
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<TaskResult>;
@@ -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_filesJSON 字符串数组);非法/空 → 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'],
+4 -2
View File
@@ -23,7 +23,8 @@ CREATE TABLE IF NOT EXISTS projects (
auto_approve_exec INTEGER NOT NULL DEFAULT 0, -- 双复审 approve 后跳过 exec_review0=关)
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);
+44 -4
View File
@@ -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<string, unknown>)[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<string, unknown>)[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) {
+52 -1
View File
@@ -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> | void): Promise<void> | void {
@@ -94,3 +94,54 @@ test('diffSizeGategit 出错 → 不拦截(测不出体量)', 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('scopeFileGategit 出错 → 不拦截', async () => {
const r = await scopeFileGate('/nonexistent-repo-xyz', 'a', 'b', ['src/**']);
assert.equal(r.ok, true);
});
+4 -1
View File
@@ -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_* 列)应成功
+9
View File
@@ -112,3 +112,12 @@ test('lastRunErrorOf 取最近一次失败 run 的错误(任意 kind', () =
assert.equal(s.lastRunErrorOf(t.id), '第一次失败:编译错误');
s.close();
});
test('buildPrompttask.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, /声明的改动文件范围/);
});
+70 -50
View File
@@ -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> = {}): Project {
return { model: null, models: null, ...over } as Project;
}
/** 临时设置 env,跑完恢复(避免污染其他用例) */
@@ -31,58 +31,79 @@ function withEnv(vars: Record<string, string>, 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<string, string> => 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-5medium=opuseasy=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('pickModelproject.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('pickModelenv 覆盖默认值,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('resolveModelenv 空白字符串视为未设置', () => {
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→opusopus 置链首=默认档优先', () => {
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');
});
+7
View File
@@ -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 字段 → 不带(不限范围)');
});
+41
View File
@@ -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 scopeFilescreate + 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() });