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
+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);
});