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