mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-06 18:39:47 +00:00
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:
@@ -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) => {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) ?? ''
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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} 条消息',
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user