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:
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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_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<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)
|
||||
|
||||
@@ -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/<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 交给 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);
|
||||
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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:改动内容 + 为什么
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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_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'],
|
||||
|
||||
@@ -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);
|
||||
|
||||
+44
-4
@@ -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
@@ -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('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);
|
||||
});
|
||||
|
||||
@@ -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_* 列)应成功
|
||||
|
||||
@@ -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, /声明的改动文件范围/);
|
||||
});
|
||||
|
||||
+70
-50
@@ -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-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');
|
||||
});
|
||||
|
||||
@@ -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 字段 → 不带(不限范围)');
|
||||
});
|
||||
|
||||
@@ -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() });
|
||||
|
||||
Reference in New Issue
Block a user