feat: PR 审核流程 + 自动合并 + macOS 通知 + 订阅额度透传 + 归档详情

PR 流程(开发→提PR→CodeReview→安全审计→审核→通过即合并):
- reviewer 拆分为 code review 与安全审计两个独立 CC run(kind=reviewer/security),
  TaskResult 四字段(summary/verdict + securitySummary/securityVerdict),闸上两节报告两枚结论徽章
- executor/merge.ts: 通过即合并——临时 worktree 内 merge --no-ff,绝不碰用户工作区/不 push;
  冲突安全拒绝(报冲突文件);重复合并幂等;合并后回收执行 worktree + 删分支
- decide 路由 merge:false 逃生口 + 看板「仅通过」按钮(目标分支被工作区检出时用)

通知(daemon/notify.ts):
- macOS 原生通知: 进审核闸(复审建议拒绝标⚠)/连续失败需人工/合并完成
- 同任务同类型 60s 抑制、osascript 转义截断、MAESTRO_NOTIFY=0 关闭

订阅额度透传(daemon/usage.ts):
- OAuth usage API(与 Claude Code/claude-hud 同源),凭证 keychain→内存零泄漏
- 60s 成败双缓存+并发去重+5s 超时,失败降级 null
- GET /api/agents 顶层 usage 字段;Agent 面板显示 5h/周用量条+重置倒计时(>80%琥珀/>95%红)

看板与生命周期:
- 归档区: 深度1整树完成沉底,时间倒序分页(尺寸 chip 10/20/50/100 置底)
- 归档详情对话框: 全属性/执行历史与时长/审批记录/状态流转时间线(GET /api/tasks/:id/events)
- 容器收口: 已拆解 Hard 子任务全 done 自动 done(afterDone 逐级向上)
- 同步按钮收进配置面板;执行白名单扩测试命令(npm/go/shellcheck/make/pytest)
- Agent 面板显示调度模式与各复杂度模型;被依赖阻塞→被阻塞

测试: 74/74(新增 merge 6/notify 10/usage 7/容器收口/双复审适配)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-13 03:22:30 +08:00
parent f18db021c3
commit 740d2c2637
18 changed files with 1102 additions and 98 deletions
+168
View File
@@ -0,0 +1,168 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Store } from '../src/store/index.js';
import { createNotifier, startNotifier, sanitizeForOsascript, NOTIFY_SUPPRESS_MS } from '../src/daemon/notify.js';
const noopLog = { info: (): void => undefined, error: (): void => undefined };
function setup(): { store: Store; projectId: string } {
const store = new Store(':memory:');
const p = store.createProject({ name: 'notify', repoPath: '/tmp/notify-repo-' + Math.random() });
return { store, projectId: p.id };
}
/** 注入假 osascript:捕获脚本文本,不真发通知 */
function capture(store: Store, now?: () => number): { scripts: string[]; stop: () => void } {
const scripts: string[] = [];
const stop = createNotifier(store, noopLog, { osascript: (s) => scripts.push(s), ...(now ? { now } : {}) });
return { scripts, stop };
}
const emptyResult = {
branch: 'maestro/x', worktree: '/wt', diffSummary: null, commits: [], prUrl: null,
summary: null, verdict: null, securitySummary: null, securityVerdict: null,
} as const;
test('notify:进入 spec_review 闸 →「等待审核(方案)」;与任务名一致', () => {
const { store, projectId } = setup();
const t = store.createTask({ projectId, title: '写登录方案', complexity: 'medium' });
const { scripts } = capture(store);
store.setSpec(t.id, '方案');
store.transition(t.id, 'spec_review');
assert.equal(scripts.length, 1);
assert.match(scripts[0], /display notification "⏳ 写登录方案 等待审核(方案)" with title "maestro"/);
store.close();
});
test('notifyexec_review 且任一复审 verdict=reject → 文案改「复审建议拒绝」', () => {
const { store, projectId } = setup();
const t = store.createTask({ projectId, title: '高危改动', complexity: 'easy' });
store.transition(t.id, 'queued');
store.transition(t.id, 'executing');
store.setResult(t.id, { ...emptyResult, securityVerdict: 'reject' });
const { scripts } = capture(store);
store.transition(t.id, 'exec_review');
assert.equal(scripts.length, 1);
assert.match(scripts[0], /⚠ 高危改动 复审建议拒绝/);
store.close();
});
test('notifyexec_review 复审均通过 →「等待审核(PR)」', () => {
const { store, projectId } = setup();
const t = store.createTask({ projectId, title: '普通改动', complexity: 'easy' });
store.transition(t.id, 'queued');
store.transition(t.id, 'executing');
store.setResult(t.id, { ...emptyResult, verdict: 'approve', securityVerdict: 'approve' });
const { scripts } = capture(store);
store.transition(t.id, 'exec_review');
assert.equal(scripts.length, 1);
assert.match(scripts[0], /⏳ 普通改动 等待审核(PR/);
store.close();
});
test('notifyneeds_attention →「连续失败需人工」', () => {
const { store, projectId } = setup();
const t = store.createTask({ projectId, title: '坏任务', complexity: 'easy' });
store.transition(t.id, 'queued');
store.transition(t.id, 'executing');
store.transition(t.id, 'failed');
const { scripts } = capture(store);
store.transition(t.id, 'needs_attention');
assert.equal(scripts.length, 1);
assert.match(scripts[0], /❌ 坏任务 连续失败需人工/);
store.close();
});
test('notifyapproval.granted 且 gate=exec →「已合并完成」;plan/spec 闸通过不通知', () => {
const { store, projectId } = setup();
// spec 闸 accept:不触发"已合并完成"
const m = store.createTask({ projectId, title: '方案任务', complexity: 'medium' });
store.setSpec(m.id, 'spec');
store.transition(m.id, 'spec_review');
// exec 闸 accept:触发
const e = store.createTask({ projectId, title: '执行任务', complexity: 'easy' });
store.transition(e.id, 'queued');
store.transition(e.id, 'executing');
store.setResult(e.id, { ...emptyResult });
store.transition(e.id, 'exec_review');
const { scripts } = capture(store);
store.decide(m.id, 'accept', 'user'); // spec 闸 → 无"合并完成"通知
store.decide(e.id, 'accept', 'user'); // exec 闸 → 通知
const merged = scripts.filter((s) => s.includes('已合并完成'));
assert.equal(merged.length, 1);
assert.match(merged[0], /✅ 执行任务 已合并完成/);
store.close();
});
test('notify:事件过滤——ready/queued/executing 等普通流转不通知', () => {
const { store, projectId } = setup();
const t = store.createTask({ projectId, title: '安静任务', complexity: 'easy' });
const { scripts } = capture(store);
store.transition(t.id, 'queued');
store.transition(t.id, 'executing');
store.transition(t.id, 'failed');
store.transition(t.id, 'queued');
assert.equal(scripts.length, 0);
store.close();
});
test('notify:同任务同类型 60s 抑制;超窗后再发;不同任务不互相抑制', () => {
const { store, projectId } = setup();
const t = store.createTask({ projectId, title: '反复任务', complexity: 'medium' });
const t2 = store.createTask({ projectId, title: '另一个', complexity: 'medium' });
let fakeNow = 1_000_000;
const { scripts } = capture(store, () => fakeNow);
store.setSpec(t.id, 's1');
store.transition(t.id, 'spec_review'); // 第 1 次:发
assert.equal(scripts.length, 1);
store.decide(t.id, 'reject', 'user', '再改改'); // 回 speccingapproval.rejected 不通知)
fakeNow += 30_000;
store.transition(t.id, 'spec_review'); // 30s 内同类型:抑制
assert.equal(scripts.length, 1);
store.setSpec(t2.id, 's2');
store.transition(t2.id, 'spec_review'); // 不同任务:不受抑制
assert.equal(scripts.length, 2);
store.decide(t.id, 'reject', 'user', '还得改');
fakeNow += NOTIFY_SUPPRESS_MS; // 距上次该任务通知已 >60s
store.transition(t.id, 'spec_review');
assert.equal(scripts.length, 3);
store.close();
});
test('notify:取消订阅后不再收到通知', () => {
const { store, projectId } = setup();
const t = store.createTask({ projectId, title: '退订', complexity: 'medium' });
const { scripts, stop } = capture(store);
stop();
store.setSpec(t.id, 's');
store.transition(t.id, 'spec_review');
assert.equal(scripts.length, 0);
store.close();
});
test('notifyMAESTRO_NOTIFY=0 时 startNotifier 不启用', () => {
const { store } = setup();
const prev = process.env.MAESTRO_NOTIFY;
process.env.MAESTRO_NOTIFY = '0';
try {
assert.equal(startNotifier(store, { log: noopLog }), null);
} finally {
if (prev === undefined) delete process.env.MAESTRO_NOTIFY;
else process.env.MAESTRO_NOTIFY = prev;
store.close();
}
});
test('sanitizeForOsascript:转义引号反斜杠、去换行、截断 80 字符', () => {
assert.equal(sanitizeForOsascript('say "hi" \\ ok'), 'say \\"hi\\" \\\\ ok');
assert.equal(sanitizeForOsascript('一行\n两行\r\n三行'), '一行 两行 三行');
const long = 'x'.repeat(200);
const cut = sanitizeForOsascript(long);
assert.equal(cut.length, 80);
assert.ok(cut.endsWith('…'));
});