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
+74 -20
View File
@@ -95,6 +95,47 @@ function shortModel(m) {
return String(m || '—').replace(/^claude-/, '');
}
/** fmtRel 的未来版:到 iso 还有多久("3h 33m" / "42m" / "<1m";已过/无效 → '' */
function fmtUntil(iso) {
if (!iso) return '';
const ms = new Date(iso).getTime() - Date.now();
if (!Number.isFinite(ms) || ms <= 0) return '';
const m = Math.ceil(ms / 60000);
if (m < 1) return '<1m';
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 24) return m % 60 ? `${h}h ${m % 60}m` : `${h}h`;
const d = Math.floor(h / 24);
return h % 24 ? `${d}d ${h % 24}h` : `${d}d`;
}
/** 字符进度条:8 格 █/░ */
function usageBar(pct) {
const filled = Math.round(Math.min(100, Math.max(0, pct)) / 100 * 8);
return '█'.repeat(filled) + '░'.repeat(8 - filled);
}
/** 单个额度窗口(5h / 周)的展示段;>80% 琥珀、>95% 红 */
function usageSeg(label, w) {
if (!w || !Number.isFinite(Number(w.percent))) {
return `<span class="usage-seg"><span class="usage-label">${label}</span> <span class="usage-na">—</span></span>`;
}
const pct = Number(w.percent);
const lvl = pct > 95 ? ' crit' : pct > 80 ? ' warn' : '';
const until = fmtUntil(w.resetsAt);
return `<span class="usage-seg${lvl}"><span class="usage-label">${label}</span> ` +
`<span class="usage-bar">${usageBar(pct)}</span> ${pct}%` +
`${until ? `<span class="usage-reset">${until} 后重置)</span>` : ''}</span>`;
}
/** Agent 面板顶部的订阅额度行(执行 agent 烧的就是这个池子);usage=null → 灰字降级 */
function usageLine(usage) {
if (!usage || (!usage.session && !usage.weekly)) {
return `<div class="agent-usage unavail">额度信息不可用</div>`;
}
return `<div class="agent-usage">额度 ${usageSeg('5h', usage.session)} · ${usageSeg('周', usage.weekly)}</div>`;
}
// ── 轻量 Markdown 渲染(零依赖;先整体转义再做结构转换,杜绝注入) ──
function mdToHtml(src) {
const inline = (s) => s
@@ -332,12 +373,14 @@ function renderAgents() {
const a = S.agents;
const total = a && Number(a.totalActive) > 0 ? Number(a.totalActive) : 0;
$('#agentTotal').textContent = total ? `· ${total}` : '';
const usage = usageLine(a ? a.usage : null);
if (!total) {
body.innerHTML = `<div class="agent-empty">无 agent 在执行(自动执行将在 Phase 2 启用)</div>`;
body.innerHTML = `${usage}<div class="agent-empty">无 agent 在执行(自动执行将在 Phase 2 启用)</div>`;
return;
}
const groups = (a.agents || []).filter((g) => g.active && g.active.length);
body.innerHTML = `
${usage}
<div class="agent-flex">
<div class="agent-big">${total}<span class="agent-big-sub">ACTIVE</span></div>
<div class="agent-groups">
@@ -372,7 +415,7 @@ function gateDocs(t) {
if (gate === 'exec') {
if (t.result) {
const r = t.result;
blocks.push(reviewSummaryBlock(r)); // 复审 SUMMARY + verdict 徽章,置于 DIFF 摘要上方
blocks.push(reviewReportBlocks(r)); // CODE REVIEW + 安全审计 两节(各带 verdict 徽章,置于 DIFF 摘要上方
blocks.push(`<div class="gate-doc-label">RESULT · 执行结果</div>
<dl class="result-kv">
${r.branch ? `<dt>分支</dt><dd>${esc(r.branch)}</dd>` : ''}
@@ -393,6 +436,8 @@ function renderGates() {
const sec = $('#gateSection');
if (!S.approvals.length) { sec.hidden = true; return; }
sec.hidden = false;
const proj = S.projects.find((x) => x.id === S.currentProjectId);
const defBranch = proj ? proj.defaultBranch : 'main';
$('#gateList').innerHTML = S.approvals.map((t) => {
const gate = GATE_OF[t.status];
const rejOpen = S.rejectOpen.has(t.id);
@@ -407,9 +452,9 @@ function renderGates() {
</div>
<div class="gate-body">${gateDocs(t)}</div>
<div class="gate-actions">
<button class="btn btn-accept" data-action="gate-accept" data-id="${esc(t.id)}">✓ 接受</button>
<button class="btn btn-accept" data-action="gate-accept" data-id="${esc(t.id)}">${gate === 'exec' ? '✓ 通过并合并' : '✓ 接受'}</button>${gate === 'exec' ? `<button class="btn btn-ghost btn-xs" data-action="gate-accept" data-merge="false" data-id="${esc(t.id)}" title="仅标记通过,不执行本地合并(适用于目标分支被工作区检出等场景)">仅通过</button>` : ''}
<button class="btn btn-reject" data-action="gate-reject-toggle" data-id="${esc(t.id)}">✗ 拒绝</button>
${gate === 'exec' ? `<span class="proj-meta">接受 = 认可改动并标记完成(合并不自动执行</span>` : ''}
${gate === 'exec' ? `<span class="proj-meta">通过 = 自动把任务分支合并到 ${esc(defBranch)}(仅本地合并,不 push</span>` : ''}
</div>
${rejOpen ? `
<div class="reject-form">
@@ -506,7 +551,7 @@ function renderArchiveModal(t, runs, events) {
const byId = new Map(S.tasks.map((x) => [x.id, x]));
const parent = t.parentId ? byId.get(t.parentId) : null;
const r = t.result;
const RUN_KIND = { executor: '执行', reviewer: '复审', planner: '规划' };
const RUN_KIND = { executor: '执行', reviewer: 'code review', security: '安全审计', planner: '规划' };
const RUN_ST = { succeeded: '成功', failed: '失败', started: '进行中', cancelled: '取消' };
const kv = (dt, dd) => `<dt>${dt}</dt><dd>${dd}</dd>`;
@@ -535,12 +580,12 @@ function renderArchiveModal(t, runs, events) {
(x.transcriptRef ? `<br><span class="t-id">转录 ${esc(x.transcriptRef)}</span>` : ''),
)).join('')}</dl>` : '';
const resultBlock = r ? `<div class="gate-doc-label">执行结果</div>
${r.summary ? `<div class="doc md">${mdToHtml(r.summary)}</div>` : ''}
const resultBlock = r ? `${reviewReportBlocks(r)}
<div class="gate-doc-label">执行结果</div>
<dl class="result-kv">
${r.verdict ? kv('复审建议', r.verdict === 'approve' ? '建议通过' : '建议拒绝') : ''}
${r.branch ? kv('分支', esc(r.branch)) : ''}
${r.worktree ? kv('worktree', esc(r.worktree)) : ''}
${r.prUrl ? kv('合并', esc(r.prUrl)) : ''}
${r.commits && r.commits.length ? kv('commits', r.commits.map(esc).join('<br>')) : ''}
</dl>
${r.diffSummary ? `<pre class="doc">${esc(r.diffSummary)}</pre>` : ''}` : '';
@@ -608,8 +653,12 @@ function renderPreview() {
</div>
<div class="preview-body">${gateDocs(t)}</div>
<div class="preview-foot">
<button class="btn btn-accept" data-action="gate-accept" data-id="${esc(t.id)}">✓ 接受</button>
<button class="btn btn-accept" data-action="gate-accept" data-id="${esc(t.id)}">${gate === 'exec' ? '✓ 通过并合并' : '✓ 接受'}</button>${gate === 'exec' ? `<button class="btn btn-ghost btn-xs" data-action="gate-accept" data-merge="false" data-id="${esc(t.id)}" title="仅标记通过,不执行本地合并(适用于目标分支被工作区检出等场景)">仅通过</button>` : ''}
<button class="btn btn-reject" data-action="preview-reject-toggle" data-id="${esc(t.id)}">✗ 拒绝</button>
${gate === 'exec' ? (() => {
const pp = S.projects.find((x) => x.id === S.currentProjectId);
return `<span class="proj-meta">通过 = 合并到 ${esc(pp ? pp.defaultBranch : 'main')}(仅本地,不 push</span>`;
})() : ''}
<span class="proj-meta">id ${esc(t.id)} · 更新 ${fmtTime(t.updatedAt)}</span>
${S.previewReject ? `
<div class="reject-form preview-reject">
@@ -650,12 +699,15 @@ function verdictBadge(v) {
return `<span class="verdict verdict-none">未复审</span>`;
}
// 复审 SUMMARY 区块(label + verdict 徽章 + markdown 渲染),无 summary 且未复审时返回空
function reviewSummaryBlock(r) {
// 复审报告区块:「CODE REVIEW」「安全审计」两节,各自 verdict 徽章(通过绿/拒绝红/未复审灰)
function reviewReportBlocks(r) {
if (!r) return '';
const label = `<div class="gate-doc-label">复审 SUMMARY ${verdictBadge(r.verdict)}</div>`;
if (r.summary) return `${label}<div class="doc md review-summary">${mdToHtml(r.summary)}</div>`;
return `${label}<pre class="doc empty">(无复审记录)</pre>`;
const sec = (label, summary, verdict) => {
const head = `<div class="gate-doc-label">${label} ${verdictBadge(verdict)}</div>`;
if (summary) return `${head}<div class="doc md review-summary">${mdToHtml(summary)}</div>`;
return `${head}<pre class="doc empty">(无复审记录)</pre>`;
};
return sec('CODE REVIEW', r.summary, r.verdict) + sec('安全审计', r.securitySummary, r.securityVerdict);
}
function childrenMap(list = S.tasks) {
@@ -842,12 +894,12 @@ function renderDetail(t) {
if (t.result) {
const r = t.result;
parts.push(`
<div><div class="detail-label">RESULT · 执行结果 ${verdictBadge(r.verdict)}</div>
${r.summary ? `<div class="doc md review-summary">${mdToHtml(r.summary)}</div>` : ''}
<div>${reviewReportBlocks(r)}
<div class="detail-label">RESULT · 执行结果</div>
<dl class="result-kv">
<dt>分支</dt><dd>${esc(r.branch || '—')}</dd>
<dt>worktree</dt><dd>${esc(r.worktree || '—')}</dd>
${r.prUrl ? `<dt>PR</dt><dd>${esc(r.prUrl)}</dd>` : ''}
${r.prUrl ? `<dt>合并</dt><dd>${esc(r.prUrl)}</dd>` : ''}
${r.commits && r.commits.length ? `<dt>commits</dt><dd>${r.commits.map(esc).join('<br>')}</dd>` : ''}
</dl>
${r.diffSummary ? `<pre class="doc" style="margin-top:6px">${esc(r.diffSummary)}</pre>` : ''}
@@ -1003,11 +1055,13 @@ document.addEventListener('click', (ev) => {
break;
}
case 'gate-accept':
case 'gate-accept': {
const noMerge = el.dataset.merge === 'false';
act(() => api(`/api/tasks/${id}/decide`, {
method: 'POST', body: JSON.stringify({ action: 'accept' }),
}), '已接受');
method: 'POST', body: JSON.stringify(noMerge ? { action: 'accept', merge: false } : { action: 'accept' }),
}), noMerge ? '已通过(未合并,分支保留)' : '已接受');
break;
}
case 'gate-reject-toggle':
S.rejectOpen.has(id) ? S.rejectOpen.delete(id) : S.rejectOpen.add(id);
+16
View File
@@ -173,6 +173,22 @@ body::before {
/* ── Agent 执行面板 ──────────────────────────────────────── */
.agent-total { color: var(--cyan); letter-spacing: .08em; }
/* 订阅额度行(5h/周窗口):cyan 常态,>80% 琥珀,>95% 红 */
.agent-usage {
display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap;
font-size: 11px; color: var(--muted); letter-spacing: .04em;
padding: 6px 2px 8px;
}
.agent-usage.unavail { color: var(--faint); }
.usage-seg { color: var(--cyan); white-space: nowrap; }
.usage-seg .usage-label { color: var(--muted); }
.usage-seg .usage-bar { letter-spacing: -.04em; text-shadow: 0 0 8px rgba(89,200,216,.35); }
.usage-seg .usage-reset { color: var(--faint); }
.usage-seg .usage-na { color: var(--faint); }
.usage-seg.warn { color: var(--amber); }
.usage-seg.warn .usage-bar { text-shadow: 0 0 8px rgba(240,180,41,.35); }
.usage-seg.crit { color: var(--red); }
.usage-seg.crit .usage-bar { text-shadow: 0 0 8px rgba(255,93,93,.4); }
.agent-empty {
padding: 12px 14px; color: var(--faint); font-size: 12px;
border: 1px dashed var(--line);