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) {