740d2c2637
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>
115 lines
4.5 KiB
TypeScript
115 lines
4.5 KiB
TypeScript
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;
|
||
}
|