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:
+74
-20
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user