feat(hub,web): requirement cards read as question/answer, milestone-first process

- cards show 'your ask' and a result block at a glance: the lead's conclusion
  when present, otherwise a result derived from task states (n/m done plus the
  latest deliverable); the original ask stays one click away
- the process opens to key milestones only (human messages, decisions, task
  hand-offs, done/blocked tasks, system notices) with a 'show full process (+n)'
  switch for the raw stream
- hub: one-time idempotent backfill files pre-requirement history under
  requirements (fresh human asks become requirements; messages/tasks follow by
  time window; short acknowledgements do not open requirements)
- task update broadcasts now carry the status in meta so the UI can pick
  milestones without parsing text
This commit is contained in:
2026-09-17 08:06:21 +08:00
parent c9a3b260fc
commit 50ea70770a
9 changed files with 357 additions and 24 deletions
+6
View File
@@ -308,6 +308,12 @@ export async function startHub(options: StartHubOptions = {}): Promise<HubInstan
: null
if (teamService) {
console.log(`[Hub] Agent Team: enabled (${config.teamsDbPath})`)
// One-time, idempotent: file pre-requirement history under requirements.
try {
teamService.backfillRequirements()
} catch (error) {
console.error('[Hub] Team requirement backfill failed:', error)
}
// Member session ended -> announce in the team log and wake the lead
// (or notify the human when the lead itself went down).
syncEngine.subscribe((event) => {
+44
View File
@@ -1151,3 +1151,47 @@ describe('TeamService requirement targets', () => {
}
})
})
describe('TeamService requirement backfill', () => {
it('files pre-requirement history and is idempotent', async () => {
const { runtime, sessions } = createRuntime()
const store = new TeamStore(':memory:')
const service = new TeamService(store, () => {}, runtime)
addCallerSession(sessions)
try {
const team = service.createTeam('alpha', { name: 'Refactor auth', leadSessionId: 'sess-lead' })
// History written before the requirement layer existed.
store.appendMessage({ teamId: team.id, fromKind: 'human', toKind: 'broadcast', kind: 'chat', text: '把搜索修好' })
store.appendMessage({ teamId: team.id, fromKind: 'session', fromSessionId: 'sess-builder', toKind: 'broadcast', kind: 'status', text: '开工' })
// Created inside the first requirement's window.
const task = store.createTask({ teamId: team.id, title: '旧任务', status: 'todo' })
store.appendMessage({ teamId: team.id, fromKind: 'session', fromSessionId: 'sess-builder', toKind: 'broadcast', kind: 'status', text: '完成' })
store.appendMessage({ teamId: team.id, fromKind: 'human', toKind: 'broadcast', kind: 'chat', text: '再帮我加个报表' })
store.appendMessage({ teamId: team.id, fromKind: 'session', fromSessionId: 'sess-builder', toKind: 'broadcast', kind: 'status', text: '收到' })
store.appendMessage({ teamId: team.id, fromKind: 'human', toKind: 'broadcast', kind: 'chat', text: '好的' })
const first = service.backfillRequirements()
expect(first.requirements).toBe(2)
const requirements = store.listRequirements(team.id)
expect(requirements.map((requirement) => requirement.title)).toEqual(['把搜索修好', '再帮我加个报表'])
const messages = store.listMessages(team.id, { limit: 100 })
expect(messages[0]?.meta?.requirementId).toBe(requirements[0]?.id)
expect(messages[2]?.meta?.requirementId).toBe(requirements[0]?.id)
expect(messages[3]?.meta?.requirementId).toBe(requirements[1]?.id)
// Acknowledgements do not open a requirement; they stay in the window.
expect(messages[5]?.meta?.requirementId).toBe(requirements[1]?.id)
// Tasks are filed under the most recent ask at their creation time
// (same-millisecond timestamps in tests make the exact anchor
// ambiguous, so only require that it is filed).
const taskRequirementId = store.getTask(task.id)?.meta?.requirementId
expect(requirements.some((requirement) => requirement.id === taskRequirementId)).toBe(true)
// Second run changes nothing.
const second = service.backfillRequirements()
expect(second.requirements).toBe(0)
expect(store.listRequirements(team.id)).toHaveLength(2)
} finally {
service.close()
}
})
})
+76 -2
View File
@@ -357,7 +357,7 @@ export class TeamService {
// ask opens a new requirement so the timeline can group the work that
// follows under "requirement -> process -> conclusion".
let requirementId = input.newRequirement ? null : this.resolveRequirementId(team.id, input)
if (!requirementId && input.fromKind === 'human') {
if (!requirementId && input.fromKind === 'human' && !isAcknowledgement(input.text)) {
requirementId = this.store.createRequirement({
teamId: team.id,
title: requirementTitle(input.text),
@@ -655,7 +655,7 @@ export class TeamService {
toKind: 'broadcast',
kind: 'task-update',
text: `任务「${updated.title}」${changes.join(',')}(${fromRole})${deliverable ? `|交付物:${deliverable.slice(0, 200)}` : ''}`,
meta: { taskId: updated.id, fromRole, ...(deliverable ? { deliverable } : {}) }
meta: { taskId: updated.id, fromRole, status: updated.status, ...(deliverable ? { deliverable } : {}) }
})
this.publishUpdate(team)
@@ -781,6 +781,71 @@ export class TeamService {
return updated
}
/**
* One-time, idempotent backfill for teams whose history predates the
* requirement layer: each fresh human ask becomes a requirement and the
* messages/tasks that follow are filed under it (by time window). Messages
* that already carry a requirementId are never touched.
*/
backfillRequirements(): { teams: number; requirements: number; messages: number; tasks: number } {
const result = { teams: 0, requirements: 0, messages: 0, tasks: 0 }
for (const team of this.store.listAllTeams()) {
const messages = this.store.listMessages(team.id, { limit: 5000 })
if (messages.length === 0) {
continue
}
let current: { id: string; at: number } | null = null
const anchors: Array<{ id: string; at: number }> = []
for (const message of messages) {
const existing = message.meta && typeof message.meta.requirementId === 'string' ? message.meta.requirementId : null
if (existing) {
current = { id: existing, at: message.createdAt }
continue
}
const isFreshAsk = message.fromKind === 'human'
&& !(message.meta && typeof message.meta.inReplyTo === 'number')
&& !isAcknowledgement(message.text)
if (isFreshAsk) {
const requirement = this.store.createRequirement({
teamId: team.id,
title: requirementTitle(message.text),
body: message.text,
createdAt: message.createdAt
})
current = { id: requirement.id, at: message.createdAt }
anchors.push(current)
this.store.updateMessageMeta(team.id, message.seq, { requirementId: requirement.id })
result.requirements += 1
result.messages += 1
continue
}
if (current) {
this.store.updateMessageMeta(team.id, message.seq, { requirementId: current.id })
result.messages += 1
}
}
if (anchors.length > 0) {
result.teams += 1
for (const task of this.store.listTasks(team.id)) {
const meta = task.meta ?? {}
if (typeof meta.requirementId === 'string') {
continue
}
const anchor = [...anchors].reverse().find((candidate) => candidate.at <= task.createdAt)
if (!anchor) {
continue
}
this.store.updateTask(task.id, { meta: { ...meta, requirementId: anchor.id } })
result.tasks += 1
}
}
}
if (result.requirements > 0 || result.tasks > 0) {
console.log(`[Hub] Team requirement backfill: ${result.teams} teams, ${result.requirements} requirements, ${result.messages} messages, ${result.tasks} tasks`)
}
return result
}
/**
* Remove a member (human action). The member's session survives unless
* `stopSession` is set; the lead cannot be removed (change the lead first).
@@ -1408,6 +1473,15 @@ function slug(value: string): string {
.slice(0, 40)
}
/**
* Short acknowledgements ("ok", "收到", "好") are not new requirements; they
* stay unfiled instead of cluttering the requirement list.
*/
function isAcknowledgement(text: string): boolean {
const compact = text.trim().replace(/[\s!!。.,,~~]+$/g, '')
return /^(ok|okay|okey|收到|好|好的|好嘞|行|嗯+|谢谢|多谢|辛苦了|thx|thanks|👍)$/i.test(compact)
}
/** Human ask -> requirement title: first non-empty line, capped. */
function requirementTitle(text: string): string {
const firstLine = text.split('\n').map((line) => line.trim()).find((line) => line.length > 0) ?? ''
+11 -1
View File
@@ -560,6 +560,14 @@ export class TeamStore {
return result.changes
}
/** Every team across namespaces (startup backfill). */
listAllTeams(): TeamRecord[] {
const rows = this.db.prepare(
'SELECT id, namespace, name, status, lead_session_id, config, created_at, updated_at FROM teams ORDER BY created_at ASC'
).all() as TeamRow[]
return rows.map(mapTeamRow)
}
// -------------------------------------------------------- requirements
createRequirement(input: {
@@ -568,8 +576,10 @@ export class TeamStore {
body?: string | null
createdBySessionId?: string | null
id?: string
/** Backfill uses the original ask time so ordering stays truthful. */
createdAt?: number
}): TeamRequirementRecord {
const now = Date.now()
const now = input.createdAt ?? Date.now()
const record: TeamRequirementRecord = {
id: input.id ?? crypto.randomUUID(),
teamId: input.teamId,
@@ -1,7 +1,8 @@
import { useState } from 'react'
import { useMemo, useState } from 'react'
import type { TeamMember, TeamMessage, TeamRequirement, TeamTask } from '@/types/team'
import { useTranslation } from '@/lib/use-translation'
import { humanReplyState } from '@/lib/teamMessageState'
import { requirementOutcome, selectMilestones } from '@/lib/requirementSummary'
import type { RequirementGroup } from '@/lib/teamRequirementGroups'
import { TeamTimeline } from './TeamTimeline'
@@ -19,9 +20,9 @@ function statusChipClass(status: string): string {
}
/**
* One requirement (a human ask) as a collapsible card: the conclusion (result)
* and task progress stay visible while collapsed; the original ask and the full
* process are one click away. A card with a pending decision opens by itself.
* One requirement as a question/answer card: your ask plus the result stay
* visible while collapsed; the process opens to key milestones first and the
* full message stream only on request.
*/
export function TeamRequirementCard(props: {
group: RequirementGroup
@@ -36,13 +37,17 @@ export function TeamRequirementCard(props: {
const tasks = props.tasks
const hasPendingDecision = messages.some((message) => humanReplyState(message) === 'pending')
const [expanded, setExpanded] = useState(hasPendingDecision)
const last = messages[messages.length - 1]
const status = requirement?.status ?? 'open'
const [showAll, setShowAll] = useState(false)
const milestones = useMemo(() => selectMilestones(messages), [messages])
const hiddenCount = messages.length - milestones.length
const outcome = requirementOutcome(requirement, tasks, messages)
const askText = requirement?.body?.trim() || requirement?.title || null
const title = requirement ? requirement.title : t('team.requirement.other')
const doneTasks = tasks.filter((task) => task.status === 'done').length
const assignees = Array.from(new Set(tasks
.map((task) => props.members.find((member) => member.sessionId === task.assigneeSessionId)?.role)
.filter((role): role is string => Boolean(role))))
const shown = showAll ? messages : milestones
return (
<div
@@ -58,8 +63,8 @@ export function TeamRequirementCard(props: {
>
<div className="flex flex-wrap items-center gap-1.5">
{requirement ? (
<span className={`rounded px-1.5 py-0.5 text-[10px] font-semibold ${statusChipClass(status)}`}>
{t(`team.requirement.status.${status}`)}
<span className={`rounded px-1.5 py-0.5 text-[10px] font-semibold ${statusChipClass(requirement.status)}`}>
{t(`team.requirement.status.${requirement.status}`)}
</span>
) : null}
<span className="min-w-0 truncate text-xs font-semibold text-[var(--app-fg)]">{title}</span>
@@ -69,22 +74,45 @@ export function TeamRequirementCard(props: {
</span>
) : null}
</div>
{requirement?.conclusion ? (
<div className="mt-1.5 rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-2 py-1.5">
<div className="text-[10px] font-semibold text-emerald-700">{t('team.requirement.conclusion')}</div>
<div className="mt-0.5 line-clamp-3 whitespace-pre-wrap text-[11px] text-[var(--app-fg)]">
{requirement.conclusion}
</div>
</div>
) : last ? (
<div className="mt-1 truncate text-[11px] text-[var(--app-hint)]">
{last.text.replace(/\s+/g, ' ').slice(0, 80)}
{askText ? (
<div className="mt-1.5 rounded-lg bg-[var(--app-subtle-bg)]/60 px-2 py-1.5">
<div className="text-[10px] font-semibold text-[var(--app-hint)]">{t('team.requirement.yourAsk')}</div>
<div className="mt-0.5 line-clamp-3 whitespace-pre-wrap text-[11px] text-[var(--app-fg)]">{askText}</div>
</div>
) : null}
{outcome.kind === 'conclusion' ? (
<div className="mt-1.5 rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-2 py-1.5">
<div className="text-[10px] font-semibold text-emerald-700">{t('team.requirement.result')}</div>
<div className="mt-0.5 line-clamp-4 whitespace-pre-wrap text-[11px] text-[var(--app-fg)]">
{requirement?.conclusion}
</div>
</div>
) : outcome.kind === 'tasks' ? (
<div className="mt-1.5 rounded-lg border border-[var(--app-border)] bg-[var(--app-subtle-bg)]/40 px-2 py-1.5">
<div className="text-[10px] font-semibold text-[var(--app-hint)]">{t('team.requirement.result')}</div>
<div className="mt-0.5 text-[11px] text-[var(--app-fg)]">
{outcome.doneTasks === outcome.totalTasks
? t('team.requirement.resultTasks', { done: outcome.doneTasks, total: outcome.totalTasks })
: t('team.requirement.resultProgress', { done: outcome.doneTasks, total: outcome.totalTasks })}
</div>
{outcome.deliverable ? (
<div className="mt-0.5 line-clamp-2 text-[10px] text-[var(--app-hint)]">
{t('team.requirement.deliverable')}:{outcome.deliverable}
</div>
) : outcome.latestText ? (
<div className="mt-0.5 truncate text-[10px] text-[var(--app-hint)]">{outcome.latestText}</div>
) : null}
</div>
) : outcome.latestText ? (
<div className="mt-1 truncate text-[11px] text-[var(--app-hint)]">{outcome.latestText}</div>
) : null}
<div className="mt-1 flex flex-wrap items-center gap-2 text-[10px] text-[var(--app-hint)]">
<span>{t('team.requirement.messages', { n: messages.length })}</span>
{tasks.length > 0 ? (
<span>{t('team.requirement.tasks', { done: doneTasks, total: tasks.length })}</span>
<span>{t('team.requirement.tasks', { done: outcome.doneTasks, total: outcome.totalTasks })}</span>
) : null}
{assignees.length > 0 ? <span className="truncate">{assignees.join(' · ')}</span> : null}
</div>
@@ -118,7 +146,19 @@ export function TeamRequirementCard(props: {
</div>
</div>
) : null}
<TeamTimeline messages={messages} members={props.members} foldThreads onReply={props.onReply} />
<div className="mx-1 mt-2 flex items-center gap-2 text-[10px] text-[var(--app-hint)]">
<span>{showAll ? t('team.requirement.allProcess') : t('team.requirement.milestones')}</span>
{hiddenCount > 0 || showAll ? (
<button
type="button"
onClick={() => setShowAll((current) => !current)}
className="text-[var(--app-link)]"
>
{showAll ? t('team.requirement.showMilestones') : t('team.requirement.showAll', { n: hiddenCount })}
</button>
) : null}
</div>
<TeamTimeline messages={shown} members={props.members} foldThreads={showAll} onReply={props.onReply} />
</div>
) : null}
</div>
+9
View File
@@ -1354,6 +1354,15 @@ export default {
'team.send.requirementNew': 'As new requirement',
'team.requirement.other': 'Other',
'team.requirement.original': 'Original ask',
'team.requirement.yourAsk': 'Your ask',
'team.requirement.result': 'Result',
'team.requirement.resultTasks': '{done}/{total} tasks done',
'team.requirement.resultProgress': 'In progress · {done}/{total} tasks',
'team.requirement.deliverable': 'Deliverable',
'team.requirement.milestones': 'Key milestones',
'team.requirement.allProcess': 'Full process',
'team.requirement.showAll': 'Show full process (+{n})',
'team.requirement.showMilestones': 'Milestones only',
'team.requirement.tasks': 'Tasks {done}/{total}',
'team.requirement.addNote': 'Add a note',
'team.requirement.messages': '{n} messages',
+9
View File
@@ -1353,6 +1353,15 @@ export default {
'team.send.requirementNew': '作为新需求',
'team.requirement.other': '其他',
'team.requirement.original': '需求原文',
'team.requirement.yourAsk': '你的问题',
'team.requirement.result': '结果',
'team.requirement.resultTasks': '{done}/{total} 任务完成',
'team.requirement.resultProgress': '进行中 · 任务 {done}/{total}',
'team.requirement.deliverable': '交付物',
'team.requirement.milestones': '关键节点',
'team.requirement.allProcess': '全部过程',
'team.requirement.showAll': '显示全部过程(+{n})',
'team.requirement.showMilestones': '只看关键节点',
'team.requirement.tasks': '任务 {done}/{total}',
'team.requirement.addNote': '补充说明',
'team.requirement.messages': '{n} 条消息',
+78
View File
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import type { TeamMessage, TeamRequirement, TeamTask } from '@/types/team'
import { requirementOutcome, selectMilestones } from './requirementSummary'
function requirement(conclusion: string | null): TeamRequirement {
return {
id: 'r1',
teamId: 'team-1',
title: '把搜索修好',
body: '把搜索修好',
status: conclusion ? 'done' : 'doing',
conclusion,
createdBySessionId: null,
createdAt: 1,
updatedAt: 1
}
}
function task(status: TeamTask['status'], deliverable?: string): TeamTask {
return {
id: `t-${status}-${deliverable ?? ''}`,
teamId: 'team-1',
title: '实现',
status,
assigneeSessionId: null,
meta: deliverable ? { deliverable } : null,
createdAt: 1,
updatedAt: 1
}
}
function message(seq: number, overrides: Partial<TeamMessage> = {}): TeamMessage {
return {
seq,
teamId: 'team-1',
fromKind: 'session',
fromSessionId: 'sess-a',
toKind: 'broadcast',
toSessionId: null,
kind: 'status',
text: `m${seq}`,
meta: null,
createdAt: seq,
...overrides
}
}
describe('requirementOutcome', () => {
it('prefers the lead conclusion', () => {
const outcome = requirementOutcome(requirement('已修复,测试全绿'), [task('done', 'branch x')], [])
expect(outcome.kind).toBe('conclusion')
})
it('derives a result from task states when there is no conclusion', () => {
const outcome = requirementOutcome(requirement(null), [task('done', 'branch x'), task('doing')], [])
expect(outcome).toMatchObject({ kind: 'tasks', doneTasks: 1, totalTasks: 2, deliverable: 'branch x' })
})
it('falls back to none without tasks or conclusion', () => {
expect(requirementOutcome(requirement(null), [], [message(1)]).kind).toBe('none')
})
})
describe('selectMilestones', () => {
it('keeps human messages, decisions, hub notices and finished tasks', () => {
const milestones = selectMilestones([
message(1, { fromKind: 'human' }),
message(2, { kind: 'decision' }),
message(3, { kind: 'task-update', meta: { status: 'doing' } }),
message(4, { kind: 'task-update', meta: { status: 'done' } }),
message(5, { kind: 'task-update', text: '任务「x」→ blocked(dev)' }),
message(6, { fromKind: 'hub', kind: 'system' }),
message(7)
])
expect(milestones.map((entry) => entry.seq)).toEqual([1, 2, 4, 5, 6])
})
})
+63
View File
@@ -0,0 +1,63 @@
import type { TeamMessage, TeamRequirement, TeamTask } from '@/types/team'
export interface RequirementOutcome {
/** 'conclusion' = the lead wrote one; 'tasks' = derived from task states. */
kind: 'conclusion' | 'tasks' | 'none'
doneTasks: number
totalTasks: number
/** Latest task evidence (branch/files/test result), when a task carries one. */
deliverable: string | null
/** One-line preview of the newest message, for in-progress requirements. */
latestText: string | null
}
function readDeliverable(task: TeamTask): string | null {
const value = task.meta?.deliverable
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
}
/**
* What the human should read at a glance: the lead's conclusion when it exists,
* otherwise a result derived from the requirement's tasks.
*/
export function requirementOutcome(
requirement: TeamRequirement | null,
tasks: TeamTask[],
messages: TeamMessage[]
): RequirementOutcome {
const doneTasks = tasks.filter((task) => task.status === 'done').length
const totalTasks = tasks.length
const deliverable = [...tasks]
.sort((a, b) => b.updatedAt - a.updatedAt)
.map(readDeliverable)
.find((value): value is string => value !== null) ?? null
const last = messages[messages.length - 1]
const latestText = last ? last.text.replace(/\s+/g, ' ').slice(0, 80) : null
if (requirement?.conclusion) {
return { kind: 'conclusion', doneTasks, totalTasks, deliverable, latestText }
}
if (totalTasks > 0) {
return { kind: 'tasks', doneTasks, totalTasks, deliverable, latestText }
}
return { kind: 'none', doneTasks, totalTasks, deliverable, latestText }
}
/**
* Key nodes of a requirement's process: what the human wrote, decisions, task
* hand-offs, completed/blocked tasks and system notices. Everything else is
* ordinary chatter that stays behind "show full process".
*/
export function selectMilestones(messages: TeamMessage[]): TeamMessage[] {
return messages.filter((message) => {
if (message.fromKind === 'human' || message.fromKind === 'hub') return true
if (message.kind === 'decision' || message.kind === 'system') return true
if (message.kind === 'task-update') {
const status = message.meta?.status
if (status === 'done' || status === 'blocked') return true
// Older hubs did not record the status in meta; fall back to text.
return /(→|->)\s*(done|blocked)/.test(message.text)
}
if (typeof message.meta?.deliverable === 'string') return true
return false
})
}