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
+114
View File
@@ -0,0 +1,114 @@
import { execFile } from 'node:child_process';
import type { Store } from '../store/index.js';
import type { Event } from '../model/types.js';
export interface NotifyLogger {
info(msg: string): void;
error(msg: string): void;
}
/** osascript 执行函数(测试注入假实现,不真发通知) */
export type OsascriptFn = (script: string) => void;
/** 同一任务同类型通知的抑制窗口 */
export const NOTIFY_SUPPRESS_MS = 60_000;
const MAX_LEN = 80;
/** AppleScript 字符串注入防护:去换行、转义反斜杠与双引号,截断 80 字符 */
export function sanitizeForOsascript(s: string): string {
const oneLine = String(s).replace(/[\r\n]+/g, ' ').trim();
const cut = oneLine.length > MAX_LEN ? `${oneLine.slice(0, MAX_LEN - 1)}` : oneLine;
return cut.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
const GATE_NAME: Record<string, string> = { plan_review: '拆解', spec_review: '方案', exec_review: 'PR' };
interface NotifierOptions {
osascript?: OsascriptFn;
now?: () => number;
}
/**
* 事件 → 通知文案(不触发的事件返回 null)。kind 用于同任务同类型 60s 抑制。
* - status.changed → plan/spec/exec_review:「⏳ <任务名> 等待审核(拆解/方案/PR)」;
* exec_review 且复审任一 verdict=reject:「⚠ <任务名> 复审建议拒绝(PR 待审核)」
* - status.changed → needs_attention:「❌ <任务名> 连续失败需人工」
* - approval.granted 且 gate=exec:「✅ <任务名> 已合并完成」
*/
export function buildNotification(store: Store, e: Event): { kind: string; message: string } | null {
if (!e.taskId) return null;
if (e.type === 'status.changed') {
const to = String(e.payload.to ?? '');
if (to === 'needs_attention') {
const t = store.getTask(e.taskId);
if (!t) return null;
return { kind: 'attention', message: `${t.title} 连续失败需人工` };
}
if (to === 'plan_review' || to === 'spec_review' || to === 'exec_review') {
const t = store.getTask(e.taskId);
if (!t) return null;
if (to === 'exec_review' && (t.result?.verdict === 'reject' || t.result?.securityVerdict === 'reject')) {
return { kind: 'gate', message: `${t.title} 复审建议拒绝(PR 待审核)` };
}
return { kind: 'gate', message: `${t.title} 等待审核(${GATE_NAME[to]}` };
}
return null;
}
if (e.type === 'approval.granted' && e.payload.gate === 'exec') {
const t = store.getTask(e.taskId);
if (!t) return null;
return { kind: 'merged', message: `${t.title} 已合并完成` };
}
return null;
}
/**
* 创建通知器:订阅 store 事件流,符合条件时发 macOS 原生通知(osascript display notification)。
* 同一任务同类型 60s 内只发一次;osascript 失败只记日志。返回取消订阅函数。
*/
export function createNotifier(store: Store, log: NotifyLogger, opts: NotifierOptions = {}): () => void {
const now = opts.now ?? Date.now;
const osa: OsascriptFn = opts.osascript ?? ((script) => {
execFile('osascript', ['-e', script], (err) => {
if (err) log.error(`macOS 通知发送失败:${err.message}`);
});
});
const lastSent = new Map<string, number>(); // `${taskId}:${kind}` → 最近发送时刻
return store.subscribe((e) => {
try {
const n = buildNotification(store, e);
if (!n) return;
const key = `${e.taskId}:${n.kind}`;
const t = now();
const prev = lastSent.get(key);
if (prev !== undefined && t - prev < NOTIFY_SUPPRESS_MS) return;
lastSent.set(key, t);
osa(`display notification "${sanitizeForOsascript(n.message)}" with title "maestro"`);
} catch (err) {
log.error(`通知处理失败:${(err as Error).message}`);
}
});
}
/**
* 接线入口(照 startSyncLoop 模式):MAESTRO_NOTIFY=0 关闭;非 macOS 平台不启用。
* 返回取消订阅函数(shutdown 时调用),未启用时返回 null。
*/
export function startNotifier(store: Store, app: { log: NotifyLogger }): (() => void) | null {
if (process.env.MAESTRO_NOTIFY === '0') {
app.log.info('macOS 通知已关闭(MAESTRO_NOTIFY=0');
return null;
}
if (process.platform !== 'darwin') {
app.log.info('非 macOS 平台,原生通知不启用');
return null;
}
const stop = createNotifier(store, app.log);
app.log.info('macOS 通知已启用:审核闸/需人工/合并完成(同任务同类型 60s 抑制,MAESTRO_NOTIFY=0 关闭)');
return stop;
}