diff --git a/hub/src/startHub.ts b/hub/src/startHub.ts index 3378623e..0f69d294 100644 --- a/hub/src/startHub.ts +++ b/hub/src/startHub.ts @@ -308,6 +308,12 @@ export async function startHub(options: StartHubOptions = {}): Promise announce in the team log and wake the lead // (or notify the human when the lead itself went down). syncEngine.subscribe((event) => { diff --git a/hub/src/teams/teamService.test.ts b/hub/src/teams/teamService.test.ts index e7ae7ad5..83a34b6a 100644 --- a/hub/src/teams/teamService.test.ts +++ b/hub/src/teams/teamService.test.ts @@ -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() + } + }) +}) diff --git a/hub/src/teams/teamService.ts b/hub/src/teams/teamService.ts index 4a3dc055..4ecd5139 100644 --- a/hub/src/teams/teamService.ts +++ b/hub/src/teams/teamService.ts @@ -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) ?? '' diff --git a/hub/src/teams/teamStore.ts b/hub/src/teams/teamStore.ts index 0100b485..04539bf9 100644 --- a/hub/src/teams/teamStore.ts +++ b/hub/src/teams/teamStore.ts @@ -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, diff --git a/web/src/components/teams/TeamRequirementCard.tsx b/web/src/components/teams/TeamRequirementCard.tsx index 02c53de4..f35bca8e 100644 --- a/web/src/components/teams/TeamRequirementCard.tsx +++ b/web/src/components/teams/TeamRequirementCard.tsx @@ -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 (
{requirement ? ( - - {t(`team.requirement.status.${status}`)} + + {t(`team.requirement.status.${requirement.status}`)} ) : null} {title} @@ -69,22 +74,45 @@ export function TeamRequirementCard(props: { ) : null}
- {requirement?.conclusion ? ( -
-
{t('team.requirement.conclusion')}
-
- {requirement.conclusion} -
-
- ) : last ? ( -
- {last.text.replace(/\s+/g, ' ').slice(0, 80)} + + {askText ? ( +
+
{t('team.requirement.yourAsk')}
+
{askText}
) : null} + + {outcome.kind === 'conclusion' ? ( +
+
{t('team.requirement.result')}
+
+ {requirement?.conclusion} +
+
+ ) : outcome.kind === 'tasks' ? ( +
+
{t('team.requirement.result')}
+
+ {outcome.doneTasks === outcome.totalTasks + ? t('team.requirement.resultTasks', { done: outcome.doneTasks, total: outcome.totalTasks }) + : t('team.requirement.resultProgress', { done: outcome.doneTasks, total: outcome.totalTasks })} +
+ {outcome.deliverable ? ( +
+ {t('team.requirement.deliverable')}:{outcome.deliverable} +
+ ) : outcome.latestText ? ( +
{outcome.latestText}
+ ) : null} +
+ ) : outcome.latestText ? ( +
{outcome.latestText}
+ ) : null} +
{t('team.requirement.messages', { n: messages.length })} {tasks.length > 0 ? ( - {t('team.requirement.tasks', { done: doneTasks, total: tasks.length })} + {t('team.requirement.tasks', { done: outcome.doneTasks, total: outcome.totalTasks })} ) : null} {assignees.length > 0 ? {assignees.join(' · ')} : null}
@@ -118,7 +146,19 @@ export function TeamRequirementCard(props: {
) : null} - +
+ {showAll ? t('team.requirement.allProcess') : t('team.requirement.milestones')} + {hiddenCount > 0 || showAll ? ( + + ) : null} +
+ ) : null} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index ea9b6f15..dd3fadee 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -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', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index fbbc374c..8e1ede49 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -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} 条消息', diff --git a/web/src/lib/requirementSummary.test.ts b/web/src/lib/requirementSummary.test.ts new file mode 100644 index 00000000..5f76e359 --- /dev/null +++ b/web/src/lib/requirementSummary.test.ts @@ -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 { + 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]) + }) +}) diff --git a/web/src/lib/requirementSummary.ts b/web/src/lib/requirementSummary.ts new file mode 100644 index 00000000..a6d13237 --- /dev/null +++ b/web/src/lib/requirementSummary.ts @@ -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 + }) +}