diff --git a/src/api/server.ts b/src/api/server.ts index 11fcc71..75d41ac 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -101,6 +101,7 @@ export function buildServer(opts: ApiOptions): FastifyInstance { if (b.autoApprovePlan !== undefined) patch.autoApprovePlan = Boolean(b.autoApprovePlan); 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); return projectOut(store.patchProject(id, patch)); }); diff --git a/src/executor/protocol.ts b/src/executor/protocol.ts index 89f606a..010923b 100644 --- a/src/executor/protocol.ts +++ b/src/executor/protocol.ts @@ -21,6 +21,19 @@ function dataDir(): string { return process.env.MAESTRO_DATA_DIR ?? join(homedir(), '.maestro'); } +/** + * L4 全局 agent 规范:maestro 自维护的 /agent-global.md(不读用户 ~/.claude/CLAUDE.md, + * 避免个人交互习惯与受控执行冲突)。worker 直接读盘注入 prompt(非 DB,不破坏进程模型)。 + * 文件不存在/空 → null。 + */ +export function readGlobalAgentRules(): string | null { + const p = join(dataDir(), 'agent-global.md'); + try { + const txt = readFileSync(p, 'utf8').trim(); + return txt || null; + } catch { return null; } +} + /** 全部 run 工作目录根:/runs */ export function runsBase(): string { return join(dataDir(), 'runs'); diff --git a/src/executor/reviewer.ts b/src/executor/reviewer.ts index 5f09c58..0e515bd 100644 --- a/src/executor/reviewer.ts +++ b/src/executor/reviewer.ts @@ -2,6 +2,8 @@ import type { Project, Task, ReviewVerdict, UsageTokens } from '../model/types.j import type { WorktreeInfo } from './worktree.js'; import { runClaude } from './cc.js'; import { pickModel } from './models.js'; +import { rulesHeaderLines } from './runner.js'; +import { readGlobalAgentRules } from './protocol.js'; /** 复审角色:code(code review)/ security(安全审计)。两者共用 models.ts 的 reviewer 模型映射。 */ export type ReviewRole = 'code' | 'security'; @@ -73,6 +75,8 @@ export function buildReviewPrompt( `# ${rt.title}:${task.title}`, `任务 ID:${task.id}`, '', + // L4+L2:注入全局/项目规范,让复审据此核对执行者是否守规 + ...rulesHeaderLines(project, readGlobalAgentRules()), rt.intro, '', '## 任务原始要求', diff --git a/src/executor/runner.ts b/src/executor/runner.ts index 4310e4d..18feb62 100644 --- a/src/executor/runner.ts +++ b/src/executor/runner.ts @@ -2,6 +2,7 @@ import type { Project, Task, UsageTokens } from '../model/types.js'; import { git, type WorktreeInfo } from './worktree.js'; import { runClaude } from './cc.js'; import { pickModel } from './models.js'; +import { readGlobalAgentRules } from './protocol.js'; export { transcriptDir } from './cc.js'; @@ -25,6 +26,19 @@ const DEFAULT_MAX_TURNS = 100; /** 若项目未配置 timeoutMs 时的全局兜底超时(30min) */ const DEFAULT_TIMEOUT_MS = 30 * 60_000; +/** + * L4+L2 记忆注入:prompt 头部规范段。全局规范(L4,maestro 自维护 agent-global.md)在前, + * 项目规范(L2,project.agentRules)在后。两者皆空 → 空数组。 + */ +export function rulesHeaderLines(project?: Project, globalRules?: string | null): string[] { + const out: string[] = []; + const g = globalRules?.trim(); + if (g) out.push('## 全局执行规范(所有任务通用,必须遵守)', g, ''); + const pr = project?.agentRules?.trim(); + if (pr) out.push('## 项目规范(本项目专属,必须遵守)', pr, ''); + return out; +} + /** * L1 记忆注入:若任务带「上次失败原因」(重试任务),生成一段提醒;否则空数组。 * 由 daemon 在 claimOne 时把 store.lastRunErrorOf 填进 task.lastRunError 下发(见 orchestrator)。 @@ -42,13 +56,14 @@ function lastErrorLines(task: Task): string[] { ]; } -/** 组装任务提示词:标题 + operations/spec 全文 + 上次失败(L1) + 执行约束 */ -export function buildPrompt(task: Task): string { +/** 组装任务提示词:标题 + 全局/项目规范(L4/L2) + operations/spec 全文 + 上次失败(L1) + 执行约束 */ +export function buildPrompt(task: Task, project?: Project, globalRules?: string | null): string { const body = task.operations ?? task.spec ?? task.plan ?? ''; return [ `# 任务:${task.title}`, `任务 ID:${task.id}`, '', + ...rulesHeaderLines(project, globalRules), '## 任务内容', body || '(无详细说明,按标题完成)', '', @@ -81,7 +96,7 @@ export async function runTask(task: Task, project: Project, worktree: WorktreeIn const model = pickModel(task, project, 'executor'); const timeoutMs = project.timeoutMs ?? DEFAULT_TIMEOUT_MS; const cc = await runClaude({ - prompt: buildPrompt(task), + prompt: buildPrompt(task, project, readGlobalAgentRules()), cwd: worktree.dir, model, runId, @@ -133,13 +148,13 @@ export interface PlannerResult { export type PlannerFn = (task: Task, project: Project, kind: PlanKind, runId: string) => Promise; /** planner 提示词:只读代码库,spec=写改动方案;decompose=拆子任务并在末尾输出 fenced JSON。 */ -export function buildPlannerPrompt(task: Task, kind: PlanKind): string { - const head = [`# 任务:${task.title}`, `任务 ID:${task.id}`]; +export function buildPlannerPrompt(task: Task, kind: PlanKind, project?: Project, globalRules?: string | null): string { + const head = [`# 任务:${task.title}`, `任务 ID:${task.id}`, '', ...rulesHeaderLines(project, globalRules)]; const errLines = lastErrorLines(task); if (kind === 'spec') { - if (task.spec) head.push('', '## 现有方案草稿(可改进/替换)', task.spec); + if (task.spec) head.push('## 现有方案草稿(可改进/替换)', task.spec, ''); return [ - ...head, '', + ...head, ...errLines, '## 你的角色:方案作者(只读,不改任何文件)', '只读这个代码库,为上述任务写一份「具体改动方案」。不要编辑文件、不要执行有副作用的命令。', @@ -186,7 +201,7 @@ export async function runPlanner(task: Task, project: Project, kind: PlanKind, r const model = pickModel(task, project, 'planner'); const timeoutMs = project.timeoutMs ?? DEFAULT_TIMEOUT_MS; const cc = await runClaude({ - prompt: buildPlannerPrompt(task, kind), + prompt: buildPlannerPrompt(task, kind, project, readGlobalAgentRules()), cwd: project.repoPath, // 只读跑在主仓(planner 不改文件,无需 worktree) model, runId, @@ -199,11 +214,12 @@ export async function runPlanner(task: Task, project: Project, kind: PlanKind, r } /** 解冲突任务的提示词:worker 已在 worktree 内触发 git merge 制造冲突态,CC 负责逐个解决。 */ -export function buildConflictPrompt(task: Task, conflictFiles: string[]): string { +export function buildConflictPrompt(task: Task, conflictFiles: string[], project?: Project, globalRules?: string | null): string { return [ `# 合并冲突解决:${task.title}`, `任务 ID:${task.id}`, '', + ...rulesHeaderLines(project, globalRules), '## 背景', '你的工作目录(git worktree)已执行 `git merge` 并产生合并冲突。你的任务是解决全部冲突,保留双方改动的意图,然后提交。', '', @@ -237,7 +253,7 @@ export async function runConflict( const model = pickModel(task, project, 'conflict' as import('./models.js').ModelRole); const timeoutMs = project.timeoutMs ?? DEFAULT_TIMEOUT_MS; const cc = await runClaude({ - prompt: buildConflictPrompt(task, conflictFiles), + prompt: buildConflictPrompt(task, conflictFiles, project, readGlobalAgentRules()), cwd: worktree.dir, model, runId, diff --git a/src/model/types.ts b/src/model/types.ts index 4ff077c..a286e27 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -37,6 +37,8 @@ export interface Project { /** 项目级成本预算(按模型×token 折算累计;超额自动暂停)*/ budgetUsd?: number | null; // 当期预算上限 USD(null=不限) budgetPeriod?: 'day' | 'month'; // 预算周期(默认 month) + /** L2 项目级 agent 执行规范(注入 executor/planner/reviewer/conflict 的 prompt 头部)*/ + agentRules?: string | null; } export interface ApprovalRecord { diff --git a/src/store/db.ts b/src/store/db.ts index abbb341..76a004c 100644 --- a/src/store/db.ts +++ b/src/store/db.ts @@ -37,6 +37,7 @@ export function openDb(file: string): Database.Database { ensureColumn(db, 'projects', 'budget_usd', 'budget_usd REAL'); // 项目当期预算上限 USD(null=不限) ensureColumn(db, 'projects', 'budget_period', "budget_period TEXT NOT NULL DEFAULT 'month'"); // 预算周期 day|month ensureColumn(db, 'tasks', 'attachments', 'attachments TEXT'); // 附件 JSON [{name,type,path}](图片/文件随任务提交) + ensureColumn(db, 'projects', 'agent_rules', 'agent_rules TEXT'); // L2 项目级 agent 执行规范(注入 worker prompt) const schema = readFileSync(join(HERE, 'schema.sql'), 'utf8'); db.exec(schema); return db; diff --git a/src/store/mappers.ts b/src/store/mappers.ts index 144cb9a..a63e5c8 100644 --- a/src/store/mappers.ts +++ b/src/store/mappers.ts @@ -12,6 +12,7 @@ export interface ProjectRow { last_sync_at: string | null; logo: string | null; sort_order: number; checks: string | null; auto_approve_plan: number; auto_approve_exec: number; budget_usd: number | null; budget_period: string | null; + agent_rules: string | null; } export interface TaskRow { id: string; project_id: string; parent_id: string | null; depth: number; @@ -51,6 +52,7 @@ export function rowToProject(r: ProjectRow): Project { autoApproveExec: Boolean(r.auto_approve_exec), budgetUsd: r.budget_usd ?? null, budgetPeriod: (r.budget_period === 'day' ? 'day' : 'month'), + agentRules: r.agent_rules ?? null, }; } diff --git a/src/store/schema.sql b/src/store/schema.sql index 33e30a7..8bd7307 100644 --- a/src/store/schema.sql +++ b/src/store/schema.sql @@ -22,7 +22,8 @@ CREATE TABLE IF NOT EXISTS projects ( auto_approve_plan INTEGER NOT NULL DEFAULT 0, -- 全 easy 子任务时跳过 plan_review(0=关) 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 + budget_period TEXT NOT NULL DEFAULT 'month', -- 预算周期 day | month + agent_rules TEXT -- L2 项目级 agent 执行规范(注入 worker prompt) ); CREATE TABLE IF NOT EXISTS tasks ( diff --git a/src/store/store.ts b/src/store/store.ts index c274e2f..483bdf4 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -46,6 +46,7 @@ export interface PatchProjectInput { maxRetries?: number; timeoutMs?: number; budgetUsd?: number | null; budgetPeriod?: 'day' | 'month'; autoApprovePlan?: boolean; autoApproveExec?: boolean; checks?: string | null; + agentRules?: string | null; } export interface PatchTaskInput { title?: string; priority?: number; complexity?: Complexity; deps?: string[]; @@ -131,7 +132,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', + budget_usd: null, budget_period: 'month', agent_rules: 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) @@ -238,6 +239,7 @@ export class Store { sets.push('auto_approve_exec = @auto_approve_exec'); args.auto_approve_exec = patch.autoApproveExec ? 1 : 0; } 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 (sets.length > 0) { this.db.prepare(`UPDATE projects SET ${sets.join(', ')} WHERE id = @id`).run(args); diff --git a/test/memory-l1.test.ts b/test/memory-inject.test.ts similarity index 62% rename from test/memory-l1.test.ts rename to test/memory-inject.test.ts index c2d4f5d..656b6b2 100644 --- a/test/memory-l1.test.ts +++ b/test/memory-inject.test.ts @@ -2,7 +2,11 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { buildPrompt, buildPlannerPrompt, buildConflictPrompt } from '../src/executor/runner.js'; import { Store } from '../src/store/index.js'; -import type { Task } from '../src/model/types.js'; +import type { Task, Project } from '../src/model/types.js'; + +function fakeProject(over: Partial = {}): Project { + return { defaultBranch: 'main', agentRules: null, ...over } as Project; +} function fakeTask(over: Partial = {}): Task { return { @@ -43,6 +47,28 @@ test('L1: 超长错误被截断到 2000 字符', () => { assert.ok(!p.includes('X'.repeat(2100))); }); +test('L2: project.agentRules 注入 executor/planner/conflict/,未设则不注入', () => { + const t = fakeTask(); + const withRules = fakeProject({ agentRules: '本项目禁止改 schema.sql' }); + assert.ok(buildPrompt(t, withRules).includes('项目规范')); + assert.ok(buildPrompt(t, withRules).includes('禁止改 schema.sql')); + assert.ok(buildPlannerPrompt(fakeTask({ complexity: 'hard' }), 'decompose', withRules).includes('禁止改 schema.sql')); + assert.ok(buildConflictPrompt(t, ['a.ts'], withRules).includes('禁止改 schema.sql')); + // 未设规范 → 不出现「项目规范」标题 + assert.ok(!buildPrompt(t, fakeProject()).includes('项目规范')); + assert.ok(!buildPrompt(t).includes('项目规范')); // 连 project 都没传 +}); + +test('L4: globalRules 注入且排在项目规范之前', () => { + const t = fakeTask(); + const p = buildPrompt(t, fakeProject({ agentRules: 'PROJ_RULE' }), 'GLOBAL_RULE'); + assert.ok(p.includes('全局执行规范')); + assert.ok(p.includes('GLOBAL_RULE')); + assert.ok(p.indexOf('GLOBAL_RULE') < p.indexOf('PROJ_RULE'), '全局规范在项目规范之前'); + // 全局规范在任务内容之前(注入顺序:L4→L2→任务内容) + assert.ok(p.indexOf('GLOBAL_RULE') < p.indexOf('任务内容')); +}); + test('lastRunErrorOf 取最近一次失败 run 的错误(任意 kind)', () => { const s = new Store(':memory:'); const p = s.createProject({ name: 'l1', repoPath: '/tmp/l1-' + Math.random() });