feat(agent): L2/L4 记忆注入——项目规范 + 全局规范文件喂进 4 处 prompt
- L2 projects.agent_rules 列(schema+迁移+类型+mapper+patchProject+API 透传) - L4 protocol.readGlobalAgentRules():worker 直接读 <data>/agent-global.md(非DB, 不读用户 ~/.claude/CLAUDE.md,避免个人习惯与受控执行冲突) - runner.rulesHeaderLines 注入 executor/planner/conflict;reviewer 同注入 - 顺序:全局规范(L4)→项目规范(L2)→任务内容→上次失败(L1)→执行约束 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.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));
|
||||
});
|
||||
|
||||
|
||||
@@ -21,6 +21,19 @@ function dataDir(): string {
|
||||
return process.env.MAESTRO_DATA_DIR ?? join(homedir(), '.maestro');
|
||||
}
|
||||
|
||||
/**
|
||||
* L4 全局 agent 规范:maestro 自维护的 <data>/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 工作目录根:<data>/runs */
|
||||
export function runsBase(): string {
|
||||
return join(dataDir(), 'runs');
|
||||
|
||||
@@ -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,
|
||||
'',
|
||||
'## 任务原始要求',
|
||||
|
||||
+26
-10
@@ -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<PlannerResult>;
|
||||
|
||||
/** 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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
+3
-1
@@ -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);
|
||||
|
||||
@@ -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> = {}): Project {
|
||||
return { defaultBranch: 'main', agentRules: null, ...over } as Project;
|
||||
}
|
||||
|
||||
function fakeTask(over: Partial<Task> = {}): 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() });
|
||||
Reference in New Issue
Block a user