diff --git a/agent.md b/agent.md index 4f2e0d6..f7533a4 100644 --- a/agent.md +++ b/agent.md @@ -79,8 +79,7 @@ - 视觉方向是“温和的本地工作台 + 编辑器质感”,不要做成工业监控大屏。 - 不要使用大面积黑底荧光绿、密集硬边框、无解释的红绿灯状态或全页面等宽字体。 - 页面必须清楚区分: - - 项目视图:项目里的智能体执行情况。 - - 工作流视图:阶段进度、交接、审查循环、主智能体监管。 + - 项目视图:项目里的智能体执行情况、工作流批次、阶段进度、交接、审查循环、主智能体监管。 - 智能体视图:名称、描述、角色设定编辑。 - 草稿:TOML 校验、diff、备份、写回。 - 设置:Codex 路径和数据源配置。 @@ -133,6 +132,7 @@ ## 工作流显示规则 - 工作流必须建模为动态事件流或有向图,不允许写死固定流程。 +- 项目内工作流必须先按“工作流批次”分组,再按阶段分组;同一项目的 `v1.0 初始搭建`、`v1.1 优化修复`、`v2.0 重构升级` 必须清楚区分。 - `thread_spawn_edges` 是派发关系的主要结构化来源。 - `threads` 表用于补充智能体昵称、角色、项目路径和更新时间。 - `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 产品规划] +[角色: 产品经理] +``` + +如果继续派发下一级子智能体,必须把同一组元数据继续传递给下一级任务。`项目` 必须写完整项目路径,不要只写项目名;不要只写“继续优化”或“修一下页面”,否则页面只能低置信度推断批次和阶段。 + ## 状态规则 运行状态至少包含: diff --git a/web/src/api/normalizers.js b/web/src/api/normalizers.js index ac0fcb2..179cf13 100644 --- a/web/src/api/normalizers.js +++ b/web/src/api/normalizers.js @@ -244,7 +244,9 @@ function describeRuntimeProcess(thread) { export function filterRuntimeByProject(runtime = normalizeRuntime(), 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 agentByID = new Map(agents.map((agent) => [agent.id, agent])) 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 phaseGroups = buildPhaseGroups(agents) const phaseHandoffs = buildPhaseHandoffs(handoffs, phaseGroups) + const workflowBatches = buildWorkflowBatches(phaseHandoffs) const supervision = buildSupervision({ agents, handoffs, projectThreadIds, runtime, threadByID, goals }) return { @@ -273,6 +276,7 @@ export function filterRuntimeByProject(runtime = normalizeRuntime(), projectPath handoffs, phaseGroups, phaseHandoffs, + workflowBatches, supervision, isEmpty: agents.length === 0, emptyTitle: '这个项目没有运行线程', @@ -301,6 +305,7 @@ function normalizeProjectHandoff(edge, context) { source: source.label, confidence: source.confidenceLabel, phaseName: handoffPhaseName({ fromAgent, toAgent, toIsMain }), + workflowBatch: handoffWorkflowBatch({ fromAgent, toAgent, toIsMain }), } } @@ -334,6 +339,16 @@ function handoffPhaseName({ fromAgent, toAgent, toIsMain }) { 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 }) { if (fromIsMain && toAgent) { return '主线程派发' @@ -354,32 +369,38 @@ function buildPhaseGroups(agents) { const groups = new Map() for (const agent of agents) { const phaseName = agent.phaseName || extractPhaseName([agent.goal, ...(agent.projectHints || [])]) || '未标注阶段' - if (!groups.has(phaseName)) { - groups.set(phaseName, []) + const workflowBatch = agent.workflowBatch || '默认工作流' + 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()] - .map(([name, phaseAgents]) => { + return [...groups.values()] + .map(({ name, workflowBatch, agents: phaseAgents }) => { const roles = [...new Set(phaseAgents.map((agent) => agent.role).filter(Boolean))].sort((a, b) => a.localeCompare(b, 'zh-CN')) return { name, + workflowBatch, status: phaseStatus(phaseAgents), statusZh: formatStatus(phaseStatus(phaseAgents)), roles, agents: phaseAgents, } }) - .sort((a, b) => phaseSortKey(a.name) - phaseSortKey(b.name) || a.name.localeCompare(b.name, 'zh-CN')) + .sort(compareWorkflowPhase) } 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) { const phaseName = handoff.phaseName || '未标注阶段' - if (!groups.has(phaseName)) { - groups.set(phaseName, { + const workflowBatch = handoff.workflowBatch || '默认工作流' + const groupKey = `${workflowBatch}::${phaseName}` + if (!groups.has(groupKey)) { + groups.set(groupKey, { name: phaseName, + workflowBatch, status: handoff.status, statusZh: formatStatus(handoff.status), roles: [], @@ -387,7 +408,7 @@ function buildPhaseHandoffs(handoffs, phaseGroups) { handoffs: [], }) } - groups.get(phaseName).handoffs.push(handoff) + groups.get(groupKey).handoffs.push(handoff) } return [...groups.values()] .filter((phase) => phase.agents.length > 0 || phase.handoffs.length > 0) @@ -396,7 +417,36 @@ function buildPhaseHandoffs(handoffs, phaseGroups) { status: 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 }) { @@ -434,6 +484,10 @@ function phaseStatus(agents) { function extractPhaseName(values) { for (const value of values) { const text = String(value || '') + const optimization = text.match(/优化阶段\s*([0-9一二三四五六七八九十]+)/) + if (optimization) { + return `优化阶段 ${optimization[1]}` + } const chinese = text.match(/阶段\s*([0-9一二三四五六七八九十]+)/) if (chinese) { return `阶段 ${chinese[1]}` @@ -446,6 +500,54 @@ function extractPhaseName(values) { 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 = '未记录角色' } = {}) { const cleanName = String(name || '').trim() const cleanRole = String(role || '').trim() @@ -462,13 +564,28 @@ function actorDisplayName({ name = '', role = '', fallbackName = '', fallbackRol } function phaseSortKey(name) { - const match = String(name).match(/阶段\s*([0-9]+)/) + const match = String(name).match(/(?:阶段|优化阶段)\s*([0-9]+)/) if (!match) { return Number.MAX_SAFE_INTEGER } 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 = {}) { const source = normalizeSource(payload.source) const phases = Array.isArray(payload.phases) @@ -624,7 +741,7 @@ function belongsToProject(agent, targetPath) { if (!agentPath || !targetPath.startsWith(`${agentPath}/`)) { 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) { @@ -638,7 +755,7 @@ function threadBelongsToProject(thread, targetPath) { if (!cwd || !targetPath.startsWith(`${cwd}/`)) { 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) { @@ -675,6 +792,19 @@ function containsPathToken(value, targetPath) { 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) { if (!value) { return '没有时间记录' diff --git a/web/src/api/normalizers.test.mjs b/web/src/api/normalizers.test.mjs index 1af6204..6293228 100644 --- a/web/src/api/normalizers.test.mjs +++ b/web/src/api/normalizers.test.mjs @@ -320,6 +320,123 @@ test('bare subagent threads are not treated as main supervision threads', () => 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: '阶段 8:Docker 发布 /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', () => { const runtime = normalizeRuntime({ items: [ @@ -482,11 +599,22 @@ test('project and app views keep workflow inside the selected project view', asy assert.doesNotMatch(appSource, /工作流视图/) assert.match(viewSource, /selectedAgentId/) assert.match(viewSource, /selectAgent/) - assert.match(viewSource, /phaseHandoffs/) + assert.match(viewSource, /workflowBatches/) + assert.match(viewSource, /工作流批次/) assert.match(viewSource, /supervision/) 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 () => { const thisFile = fileURLToPath(import.meta.url) const componentPath = new URL('../components/HandoffTimeline.vue', `file://${thisFile}`) diff --git a/web/src/styles.css b/web/src/styles.css index ec0495f..1c52402 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -569,6 +569,37 @@ button { 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 { display: grid; gap: 10px; @@ -588,6 +619,11 @@ button { gap: 10px; } +.phase-handoff-nested { + display: grid; + gap: 8px; +} + .muted-line { margin: 0; color: var(--muted); diff --git a/web/src/views/ProjectView.vue b/web/src/views/ProjectView.vue index 3beea26..835fd45 100644 --- a/web/src/views/ProjectView.vue +++ b/web/src/views/ProjectView.vue @@ -144,7 +144,7 @@ async function loadReadonlyData() { > {{ agent.displayName }} - {{ agent.phaseName }} + {{ agent.workflowBatch }} / {{ agent.phaseName }} @@ -163,21 +163,32 @@ async function loadReadonlyData() {

阶段进度

-

项目内智能体阶段

+

工作流批次与阶段

- {{ projectRuntime.phaseGroups.length }} 个阶段 + {{ projectRuntime.workflowBatches.length }} 个批次
-
    -
  1. - -
    - {{ phase.name }} -

    {{ phase.roles.join('、') || '未记录角色分类' }}

    - {{ phase.agents.map((agent) => agent.displayName).join('、') }} +
    +
    +
    +
    + {{ batch.name }} + {{ batch.phaseCount }} 个阶段 · {{ batch.agentCount }} 个智能体 · {{ batch.handoffCount }} 条交接 +
    +
    - -
  2. -
+
    +
  1. + +
    + {{ phase.name }} +

    {{ phase.roles.join('、') || '未记录角色分类' }}

    + {{ phase.agents.map((agent) => agent.displayName).join('、') }} +
    + +
  2. +
+ +
没有阶段归属

当前项目智能体线程没有可解析的阶段标记。

@@ -192,14 +203,17 @@ async function loadReadonlyData() {
{{ projectRuntime.handoffs.length }} 条交接 -
-
+
+
- {{ phase.name }} - + {{ batch.name }} +
- -

这个阶段暂时没有交接边。

+
+ {{ phase.name }} + +

这个阶段暂时没有交接边。

+