feat: group project workflow by batch

This commit is contained in:
Yoilun
2026-05-26 11:38:00 +08:00
parent 573d36bb60
commit 0fd7b17aba
5 changed files with 367 additions and 37 deletions

View File

@@ -79,8 +79,7 @@
- 视觉方向是“温和的本地工作台 + 编辑器质感”,不要做成工业监控大屏。 - 视觉方向是“温和的本地工作台 + 编辑器质感”,不要做成工业监控大屏。
- 不要使用大面积黑底荧光绿、密集硬边框、无解释的红绿灯状态或全页面等宽字体。 - 不要使用大面积黑底荧光绿、密集硬边框、无解释的红绿灯状态或全页面等宽字体。
- 页面必须清楚区分: - 页面必须清楚区分:
- 项目视图:项目里的智能体执行情况。 - 项目视图:项目里的智能体执行情况、工作流批次、阶段进度、交接、审查循环、主智能体监管
- 工作流视图:阶段进度、交接、审查循环、主智能体监管。
- 智能体视图:名称、描述、角色设定编辑。 - 智能体视图:名称、描述、角色设定编辑。
- 草稿TOML 校验、diff、备份、写回。 - 草稿TOML 校验、diff、备份、写回。
- 设置Codex 路径和数据源配置。 - 设置Codex 路径和数据源配置。
@@ -133,6 +132,7 @@
## 工作流显示规则 ## 工作流显示规则
- 工作流必须建模为动态事件流或有向图,不允许写死固定流程。 - 工作流必须建模为动态事件流或有向图,不允许写死固定流程。
- 项目内工作流必须先按“工作流批次”分组,再按阶段分组;同一项目的 `v1.0 初始搭建``v1.1 优化修复``v2.0 重构升级` 必须清楚区分。
- `thread_spawn_edges` 是派发关系的主要结构化来源。 - `thread_spawn_edges` 是派发关系的主要结构化来源。
- `threads` 表用于补充智能体昵称、角色、项目路径和更新时间。 - `threads` 表用于补充智能体昵称、角色、项目路径和更新时间。
- `thread_goals` 用于补充目标状态。 - `thread_goals` 用于补充目标状态。
@@ -140,6 +140,28 @@
- 没有计划文件时,只显示通用事件和推断,不强行命名阶段。 - 没有计划文件时,只显示通用事件和推断,不强行命名阶段。
- 所有推断必须标注来源和置信度。 - 所有推断必须标注来源和置信度。
### 子智能体派发元数据
主智能体派发任何子智能体任务时,必须在任务标题、目标或首段说明中保留以下元数据,方便本项目页面从 Codex 运行记录中准确归类:
```text
[项目: /Users/yoilun/Code/codex-agent-manager]
[工作流批次: v1.1 优化修复]
[阶段: 优化阶段 1]
[角色: 代码审查员]
```
本项目当前已完成初始搭建后进入 `v1.1 优化修复`,当前优化改造归入 `优化阶段 1`。后续如果开启 `codex-agent-manager v2.0`,必须显式写成类似:
```text
[项目: /Users/yoilun/Code/codex-agent-manager]
[工作流批次: v2.0 重构升级]
[阶段: 阶段 1 产品规划]
[角色: 产品经理]
```
如果继续派发下一级子智能体,必须把同一组元数据继续传递给下一级任务。`项目` 必须写完整项目路径,不要只写项目名;不要只写“继续优化”或“修一下页面”,否则页面只能低置信度推断批次和阶段。
## 状态规则 ## 状态规则
运行状态至少包含: 运行状态至少包含:

View File

@@ -244,7 +244,9 @@ function describeRuntimeProcess(thread) {
export function filterRuntimeByProject(runtime = normalizeRuntime(), projectPath = '') { export function filterRuntimeByProject(runtime = normalizeRuntime(), projectPath = '') {
const targetPath = normalizePath(projectPath) const targetPath = normalizePath(projectPath)
const agents = runtime.agents.filter((agent) => belongsToProject(agent, targetPath)) const agents = runtime.agents
.filter((agent) => belongsToProject(agent, targetPath))
.map((agent) => enrichProjectWorkflow(agent, targetPath))
const threadIds = new Set(agents.map((agent) => agent.id)) const threadIds = new Set(agents.map((agent) => agent.id))
const agentByID = new Map(agents.map((agent) => [agent.id, agent])) const agentByID = new Map(agents.map((agent) => [agent.id, agent]))
const threadByID = new Map(runtime.threads.map((thread) => [thread.id, thread])) const threadByID = new Map(runtime.threads.map((thread) => [thread.id, thread]))
@@ -262,6 +264,7 @@ export function filterRuntimeByProject(runtime = normalizeRuntime(), projectPath
const handoffs = edges.map((edge) => normalizeProjectHandoff(edge, { agentByID, threadByID, source: runtime.source })) const handoffs = edges.map((edge) => normalizeProjectHandoff(edge, { agentByID, threadByID, source: runtime.source }))
const phaseGroups = buildPhaseGroups(agents) const phaseGroups = buildPhaseGroups(agents)
const phaseHandoffs = buildPhaseHandoffs(handoffs, phaseGroups) const phaseHandoffs = buildPhaseHandoffs(handoffs, phaseGroups)
const workflowBatches = buildWorkflowBatches(phaseHandoffs)
const supervision = buildSupervision({ agents, handoffs, projectThreadIds, runtime, threadByID, goals }) const supervision = buildSupervision({ agents, handoffs, projectThreadIds, runtime, threadByID, goals })
return { return {
@@ -273,6 +276,7 @@ export function filterRuntimeByProject(runtime = normalizeRuntime(), projectPath
handoffs, handoffs,
phaseGroups, phaseGroups,
phaseHandoffs, phaseHandoffs,
workflowBatches,
supervision, supervision,
isEmpty: agents.length === 0, isEmpty: agents.length === 0,
emptyTitle: '这个项目没有运行线程', emptyTitle: '这个项目没有运行线程',
@@ -301,6 +305,7 @@ function normalizeProjectHandoff(edge, context) {
source: source.label, source: source.label,
confidence: source.confidenceLabel, confidence: source.confidenceLabel,
phaseName: handoffPhaseName({ fromAgent, toAgent, toIsMain }), phaseName: handoffPhaseName({ fromAgent, toAgent, toIsMain }),
workflowBatch: handoffWorkflowBatch({ fromAgent, toAgent, toIsMain }),
} }
} }
@@ -334,6 +339,16 @@ function handoffPhaseName({ fromAgent, toAgent, toIsMain }) {
return fromAgent?.phaseName || '未标注阶段' return fromAgent?.phaseName || '未标注阶段'
} }
function handoffWorkflowBatch({ fromAgent, toAgent, toIsMain }) {
if (toIsMain) {
return fromAgent?.workflowBatch || '默认工作流'
}
if (toAgent?.workflowBatch) {
return toAgent.workflowBatch
}
return fromAgent?.workflowBatch || '默认工作流'
}
function handoffDirectionLabel({ fromAgent, toAgent, fromIsMain, toIsMain }) { function handoffDirectionLabel({ fromAgent, toAgent, fromIsMain, toIsMain }) {
if (fromIsMain && toAgent) { if (fromIsMain && toAgent) {
return '主线程派发' return '主线程派发'
@@ -354,32 +369,38 @@ function buildPhaseGroups(agents) {
const groups = new Map() const groups = new Map()
for (const agent of agents) { for (const agent of agents) {
const phaseName = agent.phaseName || extractPhaseName([agent.goal, ...(agent.projectHints || [])]) || '未标注阶段' const phaseName = agent.phaseName || extractPhaseName([agent.goal, ...(agent.projectHints || [])]) || '未标注阶段'
if (!groups.has(phaseName)) { const workflowBatch = agent.workflowBatch || '默认工作流'
groups.set(phaseName, []) const groupKey = `${workflowBatch}::${phaseName}`
if (!groups.has(groupKey)) {
groups.set(groupKey, { name: phaseName, workflowBatch, agents: [] })
} }
groups.get(phaseName).push(agent) groups.get(groupKey).agents.push(agent)
} }
return [...groups.entries()] return [...groups.values()]
.map(([name, phaseAgents]) => { .map(({ name, workflowBatch, agents: phaseAgents }) => {
const roles = [...new Set(phaseAgents.map((agent) => agent.role).filter(Boolean))].sort((a, b) => a.localeCompare(b, 'zh-CN')) const roles = [...new Set(phaseAgents.map((agent) => agent.role).filter(Boolean))].sort((a, b) => a.localeCompare(b, 'zh-CN'))
return { return {
name, name,
workflowBatch,
status: phaseStatus(phaseAgents), status: phaseStatus(phaseAgents),
statusZh: formatStatus(phaseStatus(phaseAgents)), statusZh: formatStatus(phaseStatus(phaseAgents)),
roles, roles,
agents: phaseAgents, agents: phaseAgents,
} }
}) })
.sort((a, b) => phaseSortKey(a.name) - phaseSortKey(b.name) || a.name.localeCompare(b.name, 'zh-CN')) .sort(compareWorkflowPhase)
} }
function buildPhaseHandoffs(handoffs, phaseGroups) { function buildPhaseHandoffs(handoffs, phaseGroups) {
const groups = new Map(phaseGroups.map((phase) => [phase.name, { ...phase, handoffs: [] }])) const groups = new Map(phaseGroups.map((phase) => [`${phase.workflowBatch}::${phase.name}`, { ...phase, handoffs: [] }]))
for (const handoff of handoffs) { for (const handoff of handoffs) {
const phaseName = handoff.phaseName || '未标注阶段' const phaseName = handoff.phaseName || '未标注阶段'
if (!groups.has(phaseName)) { const workflowBatch = handoff.workflowBatch || '默认工作流'
groups.set(phaseName, { const groupKey = `${workflowBatch}::${phaseName}`
if (!groups.has(groupKey)) {
groups.set(groupKey, {
name: phaseName, name: phaseName,
workflowBatch,
status: handoff.status, status: handoff.status,
statusZh: formatStatus(handoff.status), statusZh: formatStatus(handoff.status),
roles: [], roles: [],
@@ -387,7 +408,7 @@ function buildPhaseHandoffs(handoffs, phaseGroups) {
handoffs: [], handoffs: [],
}) })
} }
groups.get(phaseName).handoffs.push(handoff) groups.get(groupKey).handoffs.push(handoff)
} }
return [...groups.values()] return [...groups.values()]
.filter((phase) => phase.agents.length > 0 || phase.handoffs.length > 0) .filter((phase) => phase.agents.length > 0 || phase.handoffs.length > 0)
@@ -396,7 +417,36 @@ function buildPhaseHandoffs(handoffs, phaseGroups) {
status: phaseStatus([...phase.agents, ...phase.handoffs]), status: phaseStatus([...phase.agents, ...phase.handoffs]),
statusZh: formatStatus(phaseStatus([...phase.agents, ...phase.handoffs])), statusZh: formatStatus(phaseStatus([...phase.agents, ...phase.handoffs])),
})) }))
.sort((a, b) => phaseSortKey(a.name) - phaseSortKey(b.name) || a.name.localeCompare(b.name, 'zh-CN')) .sort(compareWorkflowPhase)
}
function buildWorkflowBatches(phases) {
const groups = new Map()
for (const phase of phases) {
const batchName = phase.workflowBatch || '默认工作流'
if (!groups.has(batchName)) {
groups.set(batchName, {
name: batchName,
status: 'unknown',
statusZh: '未知',
phaseCount: 0,
handoffCount: 0,
agentCount: 0,
phases: [],
})
}
const batch = groups.get(batchName)
batch.phases.push(phase)
batch.phaseCount += 1
batch.handoffCount += phase.handoffs.length
batch.agentCount += phase.agents.length
}
return [...groups.values()]
.map((batch) => {
const status = phaseStatus(batch.phases)
return { ...batch, status, statusZh: formatStatus(status) }
})
.sort((a, b) => workflowBatchSortKey(a.name) - workflowBatchSortKey(b.name) || a.name.localeCompare(b.name, 'zh-CN'))
} }
function buildSupervision({ agents, handoffs, projectThreadIds, runtime, threadByID, goals }) { function buildSupervision({ agents, handoffs, projectThreadIds, runtime, threadByID, goals }) {
@@ -434,6 +484,10 @@ function phaseStatus(agents) {
function extractPhaseName(values) { function extractPhaseName(values) {
for (const value of values) { for (const value of values) {
const text = String(value || '') const text = String(value || '')
const optimization = text.match(/优化阶段\s*([0-9一二三四五六七八九十]+)/)
if (optimization) {
return `优化阶段 ${optimization[1]}`
}
const chinese = text.match(/阶段\s*([0-9一二三四五六七八九十]+)/) const chinese = text.match(/阶段\s*([0-9一二三四五六七八九十]+)/)
if (chinese) { if (chinese) {
return `阶段 ${chinese[1]}` return `阶段 ${chinese[1]}`
@@ -446,6 +500,54 @@ function extractPhaseName(values) {
return '' return ''
} }
function enrichProjectWorkflow(agent, targetPath) {
const values = [agent.goal, agent.name, agent.role, ...(agent.projectHints || [])]
const explicitBatch = extractWorkflowBatchName(values)
const explicitPhase = extractPhaseName(values)
const isCurrentProject = basename(targetPath) === 'codex-agent-manager'
let phaseName = explicitPhase || agent.phaseName || '未标注阶段'
let workflowBatch = explicitBatch
if (!workflowBatch && isCurrentProject && isOptimizationWork(values)) {
if (!/^阶段\s*[0-9一二三四五六七八九十]+$/.test(phaseName) || /^优化阶段/.test(phaseName)) {
workflowBatch = 'v1.1 优化修复'
if (!explicitPhase || phaseName === '未标注阶段') {
phaseName = '优化阶段 1'
}
}
}
if (!workflowBatch && isCurrentProject && /^阶段\s*[0-9一二三四五六七八九十]+$/.test(phaseName)) {
workflowBatch = 'v1.0 初始搭建'
}
return {
...agent,
phaseName,
workflowBatch: normalizeWorkflowBatchName(workflowBatch || '默认工作流'),
}
}
function extractWorkflowBatchName(values) {
for (const value of values) {
const text = String(value || '')
const explicit = text.match(/工作流批次\s*[:]\s*([^\]\n]+)/)
if (explicit) {
return explicit[1].trim()
}
const version = text.match(/\b(v[0-9]+(?:\.[0-9]+)+)\s*([^\]\n,]*)/i)
if (version && /重构|升级|优化|修复|初始|搭建|发布/.test(version[2] || '')) {
return `${version[1]} ${version[2].trim()}`.trim()
}
}
return ''
}
function normalizeWorkflowBatchName(value) {
return String(value || '默认工作流').replace(/^(v[0-9]+(?:\.[0-9]+)+)([^\s].*)$/, '$1 $2').trim()
}
function isOptimizationWork(values) {
return values.some((value) => /优化|修复|改造|调整|合并|布局|详情切换|流程记录|工作流视图|工作流批次|新增|项目视图|交接/.test(String(value || '')))
}
function actorDisplayName({ name = '', role = '', fallbackName = '', fallbackRole = '未记录角色' } = {}) { function actorDisplayName({ name = '', role = '', fallbackName = '', fallbackRole = '未记录角色' } = {}) {
const cleanName = String(name || '').trim() const cleanName = String(name || '').trim()
const cleanRole = String(role || '').trim() const cleanRole = String(role || '').trim()
@@ -462,13 +564,28 @@ function actorDisplayName({ name = '', role = '', fallbackName = '', fallbackRol
} }
function phaseSortKey(name) { function phaseSortKey(name) {
const match = String(name).match(/阶段\s*([0-9]+)/) const match = String(name).match(/(?:阶段|优化阶段)\s*([0-9]+)/)
if (!match) { if (!match) {
return Number.MAX_SAFE_INTEGER return Number.MAX_SAFE_INTEGER
} }
return Number(match[1]) return Number(match[1])
} }
function workflowBatchSortKey(name) {
const match = String(name).match(/v([0-9]+)(?:\.([0-9]+))?/i)
if (!match) {
return Number.MAX_SAFE_INTEGER
}
return Number(match[1]) * 100 + Number(match[2] || 0)
}
function compareWorkflowPhase(a, b) {
return workflowBatchSortKey(a.workflowBatch) - workflowBatchSortKey(b.workflowBatch) ||
a.workflowBatch.localeCompare(b.workflowBatch, 'zh-CN') ||
phaseSortKey(a.name) - phaseSortKey(b.name) ||
a.name.localeCompare(b.name, 'zh-CN')
}
export function normalizeWorkflow(payload = {}) { export function normalizeWorkflow(payload = {}) {
const source = normalizeSource(payload.source) const source = normalizeSource(payload.source)
const phases = Array.isArray(payload.phases) const phases = Array.isArray(payload.phases)
@@ -624,7 +741,7 @@ function belongsToProject(agent, targetPath) {
if (!agentPath || !targetPath.startsWith(`${agentPath}/`)) { if (!agentPath || !targetPath.startsWith(`${agentPath}/`)) {
return false return false
} }
return [agent.process, ...(agent.projectHints || [])].some((value) => containsPathToken(value, targetPath)) return [agent.process, ...(agent.projectHints || [])].some((value) => containsPathToken(value, targetPath) || containsProjectToken(value, targetPath))
} }
function threadBelongsToProject(thread, targetPath) { function threadBelongsToProject(thread, targetPath) {
@@ -638,7 +755,7 @@ function threadBelongsToProject(thread, targetPath) {
if (!cwd || !targetPath.startsWith(`${cwd}/`)) { if (!cwd || !targetPath.startsWith(`${cwd}/`)) {
return false return false
} }
return [thread?.title, thread?.preview, thread?.agentPath].some((value) => containsPathToken(value, targetPath)) return [thread?.title, thread?.preview, thread?.agentPath].some((value) => containsPathToken(value, targetPath) || containsProjectToken(value, targetPath))
} }
function threadCanJoinProjectFlow(thread, targetPath) { function threadCanJoinProjectFlow(thread, targetPath) {
@@ -675,6 +792,19 @@ function containsPathToken(value, targetPath) {
return false return false
} }
function containsProjectToken(value, targetPath) {
if (!targetPath) {
return false
}
const text = String(value || '')
const metadata = text.match(/\[?项目\s*[:]\s*([^\]\n]+)\]?/)
if (!metadata) {
return false
}
const candidate = normalizePath(metadata[1].trim())
return candidate === targetPath
}
function formatDateTime(value) { function formatDateTime(value) {
if (!value) { if (!value) {
return '没有时间记录' return '没有时间记录'

View File

@@ -320,6 +320,123 @@ test('bare subagent threads are not treated as main supervision threads', () =>
assert.equal(projectRuntime.handoffs[0].directionLabel, '子智能体交接') assert.equal(projectRuntime.handoffs[0].directionLabel, '子智能体交接')
}) })
test('groups project flow by workflow batch and classifies current optimizations', () => {
const runtime = normalizeRuntime({
items: [
{
id: 'main',
cwd: '/Users/yoilun',
title: '监管 /Users/yoilun/Code/codex-agent-manager 项目',
threadSource: 'user',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'initial',
cwd: '/Users/yoilun',
title: '阶段 8Docker 发布 /Users/yoilun/Code/codex-agent-manager',
agentNickname: 'Ada',
agentRole: '前端开发者',
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'optimization',
cwd: '/Users/yoilun',
title: '优化改造:合并工作流视图到项目视图 /Users/yoilun/Code/codex-agent-manager',
agentNickname: 'Rawls',
agentRole: '代码审查员',
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'v2',
cwd: '/Users/yoilun',
title: '[项目: /Users/yoilun/Code/codex-agent-manager] [工作流批次: v2.0 重构升级] [阶段: 阶段 3 后端架构]',
agentNickname: 'Noether',
agentRole: '后端架构师',
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
],
edges: [
{ fromThreadId: 'main', toThreadId: 'initial', reason: 'closed' },
{ fromThreadId: 'main', toThreadId: 'optimization', reason: 'open' },
{ fromThreadId: 'optimization', toThreadId: 'v2', reason: 'open' },
],
source: { kind: 'sqlite_readonly', confidence: 'high' },
})
const projectRuntime = filterRuntimeByProject(runtime, '/Users/yoilun/Code/codex-agent-manager')
assert.equal(projectRuntime.agents.find((agent) => agent.id === 'initial').workflowBatch, 'v1.0 初始搭建')
assert.equal(projectRuntime.agents.find((agent) => agent.id === 'optimization').workflowBatch, 'v1.1 优化修复')
assert.equal(projectRuntime.agents.find((agent) => agent.id === 'optimization').phaseName, '优化阶段 1')
assert.equal(projectRuntime.agents.find((agent) => agent.id === 'v2').workflowBatch, 'v2.0 重构升级')
assert.equal(projectRuntime.agents.find((agent) => agent.id === 'v2').phaseName, '阶段 3')
assert.deepEqual(projectRuntime.workflowBatches.map((batch) => batch.name), [
'v1.0 初始搭建',
'v1.1 优化修复',
'v2.0 重构升级',
])
assert.deepEqual(projectRuntime.workflowBatches[1].phases.map((phase) => phase.name), ['优化阶段 1'])
assert.deepEqual(projectRuntime.workflowBatches[1].phases[0].handoffs.map((handoff) => handoff.to), [
'Rawls / 代码审查员',
])
})
test('workflow metadata requires full project path and keeps optimization phase name', () => {
const runtime = normalizeRuntime({
items: [
{
id: 'short-project',
cwd: '/Users/yoilun',
title: '[项目: codex-agent-manager] [工作流批次: v1.1 优化修复] [阶段: 优化阶段 1]',
agentNickname: '短名项目',
agentRole: '产品经理',
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'full-project',
cwd: '/Users/yoilun',
title: '[项目: /Users/yoilun/Code/codex-agent-manager] [工作流批次: v1.1 优化修复] [阶段: 优化阶段 1]',
agentNickname: '完整路径项目',
agentRole: '产品经理',
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'numeric-repair',
cwd: '/Users/yoilun',
title: '阶段 3修复初始搭建问题 /Users/yoilun/Code/codex-agent-manager',
agentNickname: '数字阶段',
agentRole: '代码审查员',
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'new-batch',
cwd: '/Users/yoilun',
title: '新增工作流批次与项目视图交接 /Users/yoilun/Code/codex-agent-manager',
agentNickname: '新增批次',
agentRole: '前端开发者',
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
],
source: { kind: 'sqlite_readonly', confidence: 'high' },
})
const projectRuntime = filterRuntimeByProject(runtime, '/Users/yoilun/Code/codex-agent-manager')
assert.deepEqual(projectRuntime.agents.map((agent) => agent.id), ['full-project', 'numeric-repair', 'new-batch'])
assert.equal(projectRuntime.agents.find((agent) => agent.id === 'full-project').phaseName, '优化阶段 1')
assert.equal(projectRuntime.agents.find((agent) => agent.id === 'full-project').workflowBatch, 'v1.1 优化修复')
assert.equal(projectRuntime.agents.find((agent) => agent.id === 'numeric-repair').workflowBatch, 'v1.0 初始搭建')
assert.equal(projectRuntime.agents.find((agent) => agent.id === 'new-batch').workflowBatch, 'v1.1 优化修复')
assert.equal(projectRuntime.agents.find((agent) => agent.id === 'new-batch').phaseName, '优化阶段 1')
})
test('does not turn ordinary conversation threads into agents', () => { test('does not turn ordinary conversation threads into agents', () => {
const runtime = normalizeRuntime({ const runtime = normalizeRuntime({
items: [ items: [
@@ -482,11 +599,22 @@ test('project and app views keep workflow inside the selected project view', asy
assert.doesNotMatch(appSource, /工作流视图/) assert.doesNotMatch(appSource, /工作流视图/)
assert.match(viewSource, /selectedAgentId/) assert.match(viewSource, /selectedAgentId/)
assert.match(viewSource, /selectAgent/) assert.match(viewSource, /selectAgent/)
assert.match(viewSource, /phaseHandoffs/) assert.match(viewSource, /workflowBatches/)
assert.match(viewSource, /工作流批次/)
assert.match(viewSource, /supervision/) assert.match(viewSource, /supervision/)
assert.match(viewSource, /goalSource/) assert.match(viewSource, /goalSource/)
}) })
test('agent collaboration guide requires workflow batch metadata', async () => {
const thisFile = fileURLToPath(import.meta.url)
const agentGuidePath = new URL('../../../agent.md', `file://${thisFile}`)
const source = await readFile(agentGuidePath, 'utf8')
assert.match(source, /工作流批次/)
assert.match(source, /v1\.1 优化修复/)
assert.match(source, /阶段: 优化阶段 1/)
})
test('handoff timeline uses normalized id as stable key', async () => { test('handoff timeline uses normalized id as stable key', async () => {
const thisFile = fileURLToPath(import.meta.url) const thisFile = fileURLToPath(import.meta.url)
const componentPath = new URL('../components/HandoffTimeline.vue', `file://${thisFile}`) const componentPath = new URL('../components/HandoffTimeline.vue', `file://${thisFile}`)

View File

@@ -569,6 +569,37 @@ button {
gap: 14px; gap: 14px;
} }
.workflow-batches {
display: grid;
gap: 14px;
}
.workflow-batch {
display: grid;
gap: 10px;
padding-top: 12px;
border-top: 1px solid var(--line);
}
.workflow-batch:first-child {
padding-top: 0;
border-top: 0;
}
.workflow-batch-heading {
display: flex;
align-items: start;
justify-content: space-between;
gap: 10px;
}
.workflow-batch-heading span {
display: block;
margin-top: 4px;
color: var(--muted);
font-size: 0.82rem;
}
.phase-handoff-group { .phase-handoff-group {
display: grid; display: grid;
gap: 10px; gap: 10px;
@@ -588,6 +619,11 @@ button {
gap: 10px; gap: 10px;
} }
.phase-handoff-nested {
display: grid;
gap: 8px;
}
.muted-line { .muted-line {
margin: 0; margin: 0;
color: var(--muted); color: var(--muted);

View File

@@ -144,7 +144,7 @@ async function loadReadonlyData() {
> >
<span role="cell"> <span role="cell">
<strong>{{ agent.displayName }}</strong> <strong>{{ agent.displayName }}</strong>
<small>{{ agent.phaseName }}</small> <small>{{ agent.workflowBatch }} / {{ agent.phaseName }}</small>
</span> </span>
<span role="cell"> <span role="cell">
<StatusBadge :label="agent.statusZh" :status="agent.status" :source="agent.source" :confidence="agent.confidence" /> <StatusBadge :label="agent.statusZh" :status="agent.status" :source="agent.source" :confidence="agent.confidence" />
@@ -163,21 +163,32 @@ async function loadReadonlyData() {
<div class="panel-heading horizontal compact-heading"> <div class="panel-heading horizontal compact-heading">
<div> <div>
<p class="eyebrow">阶段进度</p> <p class="eyebrow">阶段进度</p>
<h3>项目内智能体阶段</h3> <h3>工作流批次与阶段</h3>
</div> </div>
<span class="read-only-chip">{{ projectRuntime.phaseGroups.length }} 阶段</span> <span class="read-only-chip">{{ projectRuntime.workflowBatches.length }} 批次</span>
</div> </div>
<ol v-if="projectRuntime.phaseGroups.length > 0" class="phase-list compact"> <div v-if="projectRuntime.workflowBatches.length > 0" class="workflow-batches">
<li v-for="phase in projectRuntime.phaseGroups" :key="phase.name" :data-status="phase.status"> <section v-for="batch in projectRuntime.workflowBatches" :key="batch.name" class="workflow-batch">
<div class="phase-dot" aria-hidden="true"></div> <div class="workflow-batch-heading">
<div> <div>
<strong>{{ phase.name }}</strong> <strong>{{ batch.name }}</strong>
<p>{{ phase.roles.join('、') || '未记录角色分类' }}</p> <span>{{ batch.phaseCount }} 个阶段 · {{ batch.agentCount }} 个智能体 · {{ batch.handoffCount }} 条交接</span>
<span>{{ phase.agents.map((agent) => agent.displayName).join('、') }}</span> </div>
<StatusBadge :label="batch.statusZh" :status="batch.status" source="SQLite 表" confidence="高" />
</div> </div>
<StatusBadge :label="phase.statusZh" :status="phase.status" source="SQLite 表" confidence="高" /> <ol class="phase-list compact">
</li> <li v-for="phase in batch.phases" :key="`${batch.name}-${phase.name}`" :data-status="phase.status">
</ol> <div class="phase-dot" aria-hidden="true"></div>
<div>
<strong>{{ phase.name }}</strong>
<p>{{ phase.roles.join('、') || '未记录角色分类' }}</p>
<span>{{ phase.agents.map((agent) => agent.displayName).join('、') }}</span>
</div>
<StatusBadge :label="phase.statusZh" :status="phase.status" source="SQLite 表" confidence="高" />
</li>
</ol>
</section>
</div>
<div v-else class="empty-state compact"> <div v-else class="empty-state compact">
<strong>没有阶段归属</strong> <strong>没有阶段归属</strong>
<p>当前项目智能体线程没有可解析的阶段标记</p> <p>当前项目智能体线程没有可解析的阶段标记</p>
@@ -192,14 +203,17 @@ async function loadReadonlyData() {
</div> </div>
<span class="read-only-chip">{{ projectRuntime.handoffs.length }} 条交接</span> <span class="read-only-chip">{{ projectRuntime.handoffs.length }} 条交接</span>
</div> </div>
<div v-if="projectRuntime.phaseHandoffs.length > 0" class="phase-handoff-groups"> <div v-if="projectRuntime.workflowBatches.length > 0" class="phase-handoff-groups">
<section v-for="phase in projectRuntime.phaseHandoffs" :key="phase.name" class="phase-handoff-group"> <section v-for="batch in projectRuntime.workflowBatches" :key="batch.name" class="phase-handoff-group">
<div class="phase-handoff-heading"> <div class="phase-handoff-heading">
<strong>{{ phase.name }}</strong> <strong>{{ batch.name }}</strong>
<StatusBadge :label="phase.statusZh" :status="phase.status" source="SQLite 表" confidence="高" /> <StatusBadge :label="batch.statusZh" :status="batch.status" source="SQLite 表" confidence="高" />
</div> </div>
<HandoffTimeline v-if="phase.handoffs.length > 0" :items="phase.handoffs" /> <section v-for="phase in batch.phases" :key="`${batch.name}-${phase.name}`" class="phase-handoff-nested">
<p v-else class="muted-line">这个阶段暂时没有交接边</p> <strong>{{ phase.name }}</strong>
<HandoffTimeline v-if="phase.handoffs.length > 0" :items="phase.handoffs" />
<p v-else class="muted-line">这个阶段暂时没有交接边</p>
</section>
</section> </section>
</div> </div>
<div v-else class="empty-state compact"> <div v-else class="empty-state compact">