86 KiB
Iteration 07 - Task Logs + Explicit Board Task Links
Historical note This document captures the planned scope and architecture at iteration time. It is not the source of truth for the final runtime contract.
This iteration introduces a new explicit task activity model for team board tasks and keeps the current session-based execution logs as a separate legacy block.
The goal is to stop reconstructing task -> logs mostly from heuristics and instead persist a small, explicit, board-task-specific linkage in runtime transcripts, then build a clean read model for the task popup UI.
This iteration spans two repos:
agent_teams_orchestrator- write-side runtime and transcript contractclaude_team- read-side task activity feed and UI integration
Decision Record
Chosen direction
- New
Task Activityfeed - Keep old
Execution Sessionsblock, but explicitly treat it as legacy/session-centric - Persist explicit board-task links in transcript JSONL
- Build a read model on top of those links
Why this was chosen
- The current
Execution Logsview is fundamentally session-centric - The new requirement is event-centric:
- "show all logs/actions related to task A"
- including actions performed by another actor while they were actively working on task B
- Mixing both into one model makes both of them worse
Rejected alternatives
- Replace
Execution Logsentirely with one new event timeline- Too risky for first rollout
- Would throw away useful current session features
- Keep only the old session logic and improve heuristics
- Not reliable enough
- Does not solve cross-task board actions correctly
- Use one single
taskContextobject per message- Breaks on multi-target tools such as
task_link - Becomes ambiguous too quickly
- Breaks on multi-target tools such as
Goals
- Add a new explicit activity feed for board tasks
- Keep the current execution session logs available as a separate legacy block
- Make task-log linkage structural, not mainly heuristic
- Make the new feed explicit-link only in v1
- Support:
- task lifecycle events
- ordinary execution logs during active task work
- board actions performed on a task by another actor
- review flow actions
- multi-target task tools where relevant
Non-Goals
- Replacing the existing
Workflow Historytimeline - Deleting the current
Execution Sessionslogic - Rebuilding all historical logs retroactively
- Stamping ambiguous lead free-text execution in v1
- Reworking built-in
TaskCreate/TaskUpdateinto this domain
This iteration is for board-task activity only, not generic task tooling.
What We Fixed Before This Iteration
Before implementing this iteration, we fixed a real false-negative in the current modern MCP task boundary detection:
- fully-qualified tool names such as
mcp__agent-teams__task_start - alternate normalized names such as
mcp__agent_teams__task_complete
The fix was intentionally narrow:
- one canonicalization helper for agent-teams MCP tool names
- structural boundary detection now sees modern MCP task markers
This is a prerequisite hardening step, not the main solution for the new feed.
Core Architectural Decision
Use two levels of model, not one:
1. Persisted wire contract
The runtime writes small, explicit, additive transcript fields:
boardTaskLinks?: BoardTaskLinkV1[]boardTaskToolActions?: BoardTaskToolActionV1[]
Together these fields capture the minimum durable truth:
- which board task(s) this message is linked to
- what kind of link each task has to the message
- how the actor's active task state relates to each task at that moment
- what board-task tool action(s) the message represents, when the message contains successful tool results
They are not UI objects.
2. Read model
claude_team reads transcript entries and builds:
BoardTaskActivityEntry
This is the UI-facing model for the new task activity feed.
This separation keeps the runtime contract stable while allowing the UI to evolve.
Layering and Isolation Rules
These rules are part of the design, not optional cleanup.
1. Persisted contract is not a UI DTO
boardTaskLinks[] must remain a small runtime fact model.
It should not grow UI-only fields such as:
- display labels
- actor names
- timestamps duplicated from transcript entries
- section-level rendering hints
2. The new feed must not depend on legacy heuristics in v1
The new Task Activity feed should read explicit links only.
That means:
- no mention-based guessing
- no owner/session overlap inference
- no work-interval heuristics inside the new feed
Legacy heuristics remain available only inside the legacy execution-sessions block.
3. Keep the old session code, but isolate it
Do not delete the current execution-session code.
Do not comment it out either.
Instead:
- keep it behind a separate service boundary
- keep it rendered in a separate UI section
- treat it as compatibility/session-exploration logic, not as the new source of truth
4. The popup composes two read models, not one mixed model
The task popup should compose:
- explicit event-level task activity
- legacy session-level execution browsing
It should not merge both into one array or one card list.
Naming Decisions
Persisted fields
Use:
boardTaskLinksboardTaskToolActions
Do not use:
taskContextboardTaskContext
Why:
- one message can legitimately link to multiple board tasks
task_linkandtask_unlinkare the clearest example- plural naming makes the model honest
Persisted types
Use:
BoardTaskLinkV1BoardTaskLocatorBoardTaskToolActionV1
Read model
Use:
- shared DTO:
BoardTaskActivityEntry - main service:
BoardTaskActivityService - transcript discovery service:
TeamTranscriptSourceLocator
Renderer names
Use:
- outer section label:
Task Logs - user-facing subsection label:
Task Activity - renderer component:
TaskActivitySection - composed container:
TaskLogsPanel
Legacy/session block
Use:
Execution Sessions
This keeps the old block clearly separate from the new activity feed.
Why not TaskActivityTimeline as the main internal name
The repo already has:
ActivityTimelinefor team inbox/message activityWorkflow History/StatusHistoryTimelinefor board-state history
Using TaskActivityTimeline as the main internal component name would make the codebase harder to scan.
So:
Task Logsis the better outer section labelTask Activitystays the user-facing subsection labelTaskActivitySectionis the better internal renderer name
Domain Boundaries
Included
Board task domain only:
task_*MCP tools that operate on board tasksreview_*MCP tools tied to a board task
Excluded
Do not include in the new core:
- built-in
TaskCreate - built-in
TaskUpdate - generic inbox/message/process tools without task target
Those can remain as legacy/fallback logic where needed, but they are not part of the new activity core.
Persisted Wire Contract
Transcript field
Add an optional field to transcript messages in agent_teams_orchestrator:
type BoardTaskLocator = {
ref: string
refKind: 'canonical' | 'display' | 'unknown'
canonicalId?: string
}
type BoardTaskLinkV1 = {
schemaVersion: 1
task: BoardTaskLocator
taskArgumentSlot?: 'taskId' | 'targetId'
toolUseId?: string
linkKind: 'execution' | 'lifecycle' | 'board_action'
actorContext: {
relation: 'same_task' | 'other_active_task' | 'idle' | 'ambiguous'
activeTask?: BoardTaskLocator
activePhase?: 'work' | 'review'
activeExecutionSeq?: number
}
}
type BoardTaskToolActionV1 = {
schemaVersion: 1
toolUseId: string
canonicalToolName: string
input?: {
status?: 'pending' | 'in_progress' | 'completed' | 'deleted'
owner?: string | null
relationship?: 'blocked-by' | 'blocks' | 'related'
clarification?: 'lead' | 'user' | null
reviewer?: string
commentId?: string
}
resultRefs?: {
commentId?: string
attachmentId?: string
filename?: string
}
}
type TranscriptMessage = ExistingTranscriptMessage & {
boardTaskLinks?: BoardTaskLinkV1[]
boardTaskToolActions?: BoardTaskToolActionV1[]
}
Why this shape
task.refinstead of unconditionaltaskId- runtime input may contain display IDs
- do not lie about canonical identity
- store the normalized task reference without a leading
#
schemaVersion- clearer than a generic nested
version - safer when transcript messages already contain their own top-level version fields
- clearer than a generic nested
taskArgumentSlot- needed for multi-target tools
- aligns the persisted contract with the actual MCP input slots (
taskId/targetId) - clearer than
inputRole, which is too easy to confuse with user/assistant message roles - clearer than
toolArgumentRole, because this is specifically the task-related argument slot - should be omitted for ambient execution links that do not originate from a tool argument
toolUseId- needed to join task links to the exact
tool_resultblock that produced them - protects the contract when one transcript message contains multiple
tool_resultblocks
- needed to join task links to the exact
linkKind- distinguishes execution, lifecycle, and board actions
actorContext- captures the subtle "actor is currently active on another task" case
boardTaskToolActions- keeps message-level tool semantics out of the per-target link object
- avoids repeating the same tool metadata across multiple target links
- must be plural because a single user message can legitimately contain multiple
tool_resultblocks - gives the read-side enough stable structure for rows such as owner/status/relationship/clarification changes without parsing free text
- can carry stable result references such as
commentId/attachmentIdwhen the tool returns them canonicalToolNameshould store the canonical bare board tool name afteragent-teamsMCP normalizationinput/resultRefsshould stay minimal and semantic, not a dump of raw MCP input or raw tool result- do not copy long free-text payloads such as comment text, review notes, or request-change prose into transcript metadata
- omit orchestration-only inputs already represented elsewhere, such as
from,actor,leadSessionId, andnotifyOwner
Important rule
Do not duplicate in boardTaskLinks or boardTaskToolActions:
- timestamp
- sessionId
- agentId
- memberName
- teamName
Those already exist on the transcript entry itself and should remain single-source.
For read-side task popup queries, the team scope comes from the surrounding team-scoped query/file
discovery path, so boardTaskLinks[] does not need to repeat it.
This is especially important because not every transcript path is guaranteed to stamp teamName
uniformly on every entry, particularly sidechain-oriented paths.
Metadata size budget
The explicit contract must stay small enough to remain transcript-friendly.
Recommended budget rules:
- at most one
BoardTaskToolActionV1pertoolUseIdin one message - keep
boardTaskLinksto the minimal task-target set for that message - never persist arbitrary free-text comment bodies, review prose, or task descriptions
- trim all persisted string identifiers
- suggested soft caps:
task.ref/canonicalId/toolUseId/canonicalToolName- at most 128 charsfilename- at most 256 chars- enum-like fields only from explicit allow-lists
If a value exceeds the budget:
- prefer omitting that optional field over truncating it into a misleading value
- for required identifiers, skip that object and emit debug diagnostics instead of persisting junk
Omit vs null policy
Use omission by default for unknown or unavailable optional fields.
Rules:
- use
undefined/ omitted for:taskArgumentSlottoolUseIdon ambient execution linkscanonicalIdwhen unresolvedactorContext.activeTaskactorContext.activePhaseactorContext.activeExecutionSeq- optional
input/resultRefsfields that are not whitelisted for the current tool
- use explicit
nullonly when the domain itself uses null as meaningful data:input.owner = nullinput.clarification = null
Why:
- omission means "not available / not applicable"
nullmeans "explicitly cleared"- mixing them loosely would make parser behavior and UI labels inconsistent
Invariants
- every
boardTaskToolActions[*].toolUseIdshould match at least oneboardTaskLinks[*].toolUseId boardTaskToolActionsmust not appear without at least oneboardTaskLink- within one message,
boardTaskToolActionsshould be unique bytoolUseId linkKind = 'execution'is reserved for ambient execution rows in v1executionlinks may carrytoolUseIdwhen they intentionally anchor a workertool_resultrow for exact task-log reconstruction- therefore
executionlinks should omittaskArgumentSlot boardTaskToolActionsshould only pair with sibling links whoselinkKindislifecycleorboard_actionactorContext.activeTaskshould only be set whenrelation = 'other_active_task'actorContext.activePhase/actorContext.activeExecutionSeqdescribe the actor's active scope, not the target task's own identity- for
linkKind = 'lifecycle',actorContextshould reflect the actor state before the lifecycle transition is applied - within one message, emitted links should be unique by
(toolUseId ?? 'ambient', task.ref, taskArgumentSlot ?? 'none', linkKind) - ambient execution links should omit
taskArgumentSlot - tool-derived links should set
taskArgumentSlot = 'taskId'for the primary task-argument slot toolUseIdshould still be omitted for ordinary conversational execution messages
Additive-safety note
This is safe as additive transcript metadata because:
agent_teams_orchestratortranscript messages already tolerate optional extra fieldsclaude_teamJSONL parsing is loose and ignores unknown fields until explicitly consumed
Version evolution policy
- bump
schemaVersiononly for breaking meaning changes, not for additive optional fields - additive optional fields within
BoardTaskLinkV1/BoardTaskToolActionV1should remain on version1 - a single message should not mix multiple schema versions for the same object family
- readers should accept the current version and ignore newer unknown versions object-by-object
- writers should emit exactly one stable version family at a time
This keeps rollout and future migrations simple:
- old readers keep working by ignoring what they do not understand
- new readers can still salvage older transcript rows without rewriting history
Write-Side Emission Policy
The runtime should emit explicit links only when it has reliable information.
Carrier-field rule
On the write side, the cleanest implementation is to carry:
boardTaskLinks?: BoardTaskLinkV1[]boardTaskToolActions?: BoardTaskToolActionV1[]
as internal transcript-only fields on runtime Message objects before persistence.
Those carriers must be threaded through the message creation/normalization path for any message types that can legitimately receive task metadata.
That implies adding optional transcript-only fields to the orchestrator's internal message types,
not just to TranscriptMessage.
This keeps the contract close to the message that will actually be persisted and avoids having a separate side registry that can drift from message ordering.
Carrier propagation checkpoints
The implementation should explicitly audit the runtime paths that rebuild messages rather than
assuming a new field on TranscriptMessage will survive automatically.
At minimum, verify the carrier survives:
- message factory helpers such as
createUserMessage(...) - any assistant-message creation path that rebuilds plain objects
- message normalization paths that split multi-block messages into new message objects
- transcript logging cleanup paths before
insertMessageChain(...)
And the implementation should explicitly not leak transcript-only task metadata into:
- model payload normalization
- SDK/web message mappers
- any API-facing serialization path not intended for transcript persistence
V1 rules
- stamp explicit task links on successful board-task
tool_resultmessages - stamp
boardTaskToolActionsonly on successful board-tasktool_resultmessages - stamp ambient
executionlinks only on ordinary conversational messages when the actor has exactly one active task - do not rely on raw
tool_usealone to claim lifecycle success - do not attach ambient execution links to progress, attachment, system, or transcript-only meta scaffolding
- do not attach ambient execution links to assistant
tool_useblocks or thinking-only assistant children after normalization - do not ambient-stamp lead free-text execution in v1
- dedupe lifecycle/action application by
(sessionId, agentId ?? 'main', toolUseId)before mutating actor execution state or stamping transcript fields
Carrier placement matrix
Allowed carrier placement by runtime message shape:
- user
tool_resultmessage- may carry
boardTaskLinks - may carry
boardTaskToolActions
- may carry
- ordinary user conversational message
- may carry ambient
boardTaskLinks - must not carry
boardTaskToolActions
- may carry ambient
- ordinary assistant conversational message
- may carry ambient
boardTaskLinks - must not carry
boardTaskToolActions
- may carry ambient
- assistant
tool_usemessage- must not carry either carrier family in v1
- thinking-only assistant child
- must not carry either carrier family
progress,attachment,system,tombstone, compact-boundary, and other non-conversational items- must not carry either carrier family
Read-side simplifying assumption enabled by this rule:
boardTaskToolActionsalways means "this message contains a concrete successful board-tool result"- ambient execution links only appear on human-readable conversational rows
Tool-result success matrix
For v1 explicit stamping, treat a board-tool result as successful only when all of the following hold:
- the message is a real user
tool_resultmessage, not a synthetic placeholder - the
tool_resultblock pairs to a real assistanttool_use - the result is not an interrupt/reject/denial synthetic recovery block
- the execution outcome is semantically successful for that tool family
Conservative success rules:
- paired MCP board-task tool result with no synthetic/error recovery markers
- emit
board_actionorlifecyclemetadata
- emit
- paired board-task tool result that is denied, rejected, interrupted, synthetic, or otherwise unsuccessful
- emit no explicit board-task metadata in v1
- unpaired
tool_result- emit no explicit board-task metadata in v1
- ambient conversational message while one active task exists
- emit
executionlinks only
- emit
Important design choice:
- v1 does not model failed board actions as task-activity rows
- this is intentional to keep the first explicit feed highly reliable
- if failed-action visibility becomes important later, add a separate
failed_board_actionconcept instead of overloading the success-only v1 contract
Why this matters
Tool success semantics differ across tool families, so the observer must decide after execution outcome is known, not just from the attempted tool call.
Also, some runtime paths - especially subagent-oriented ones - do not preserve rich structured
toolUseResult / mcpMeta all the way to transcript persistence. The explicit transcript fields
must therefore carry enough stable board-task semantics for the read-side to avoid reparsing
natural-language tool output.
Just as importantly, repeated tool-result handling by the same toolUseId would create duplicated
lifecycle transitions and duplicated task-activity rows, so the observer has to dedupe before
state mutation.
The toolUseId join key is also what keeps the contract correct when a single transcript message
contains more than one successful tool_result block.
Read Model
claude_team should build a richer UI model:
type BoardTaskActivityEntry = {
id: string
timestamp: string
actor: {
memberName?: string
role: 'member' | 'lead' | 'unknown'
sessionId: string
agentId?: string
}
task: {
locator: BoardTaskLocator
taskRef?: TaskRef
resolution: 'resolved' | 'deleted' | 'unresolved' | 'ambiguous'
}
linkKind: 'execution' | 'lifecycle' | 'board_action'
actorContext: {
relation: 'same_task' | 'other_active_task' | 'idle' | 'ambiguous'
activeTask?: {
locator: BoardTaskLocator
taskRef?: TaskRef
resolution: 'resolved' | 'deleted' | 'unresolved' | 'ambiguous'
}
activePhase?: 'work' | 'review'
activeExecutionSeq?: number
}
action: {
canonicalToolName?: string
toolUseId?: string
category:
| 'status'
| 'review'
| 'comment'
| 'assignment'
| 'read'
| 'attachment'
| 'relationship'
| 'clarification'
| 'other'
peerTask?: {
locator: BoardTaskLocator
taskRef?: TaskRef
resolution: 'resolved' | 'deleted' | 'unresolved' | 'ambiguous'
}
relationshipPerspective?: 'outgoing' | 'incoming' | 'symmetric'
details?: {
status?: 'pending' | 'in_progress' | 'completed' | 'deleted'
owner?: string | null
relationship?: 'blocked-by' | 'blocks' | 'related'
clarification?: 'lead' | 'user' | null
reviewer?: string
commentId?: string
attachmentId?: string
filename?: string
}
}
source: {
messageUuid: string
filePath: string
}
}
The read model should be derived, not persisted.
id should be stable and deterministic, for example:
${messageUuid}:${action.toolUseId ?? 'ambient'}:${task.locator.ref}:${link.taskArgumentSlot ?? 'none'}:${linkKind}
This avoids duplicate-row key problems when one transcript message yields multiple task activity rows.
This read model should stay semantic, not presentation-coupled.
It is the right place to add:
- resolved actor identity
- resolved task references where possible
- action category
- actor/task relationship state
- relationship peer-task context derived from sibling links within the same message
It is not the right place to hardcode:
- final display labels
- UI tone names
- renderer-specific row text
The read model should not leak raw transport details such as taskArgumentSlot into renderer code.
For relationship tools, the builder should consume taskArgumentSlot from the persisted link and expose
semantic information instead:
peerTaskrelationshipPerspective
For non-relationship rows, taskArgumentSlot is internal transport detail only:
- ambient execution rows will usually have it omitted
- ordinary single-target tool rows may have
'task' - renderer code should not branch on it directly
Mapping rules for relationship rows:
related->relationshipPerspective = 'symmetric'on both task popupsblocked-byon thetaskside ->incomingblocked-byon thetargetside ->outgoingblockson thetaskside ->outgoingblockson thetargetside ->incoming
Whenever possible, the read-side builder should resolve persisted locators into the app's existing
shared TaskRef semantics for rendering and navigation.
If resolution fails, it should keep the raw locator for fallback display instead of dropping the row.
Task resolution policy
This is one of the highest-risk read-side areas.
The builder must never silently guess a task from a weak locator.
Rules:
- canonical identity always wins:
locator.canonicalId- then
refKind = 'canonical'
- display-form resolution is allowed only when it resolves to exactly one candidate in team scope
- if multiple candidates share the same display-like ref, mark the row
resolution = 'ambiguous'and keep only the raw locator - if no candidate matches, mark the row
resolution = 'unresolved' - if the best unique candidate exists only in deleted tasks, keep
taskRefbut markresolution = 'deleted' - never drop a row only because the task cannot be resolved to a live
TaskRef - renderer navigation should rely on both
taskRefandresolution - in v1, rows with
resolution = 'deleted' | 'unresolved' | 'ambiguous'should render as non-primary navigation targets even if a fallbacktaskRefexists for label purposes
Lookup scope:
- build the lookup from both active tasks and deleted tasks
- deleted tasks are needed mainly for:
- historical relationship rows
- lifecycle/action rows targeting tasks that were later deleted
- peer-task rendering for old
task_link/task_unlinkhistory
Anti-guessing rule:
- do not use
Map<string, TaskRef>for display-id resolution - display-like refs must resolve through a candidate set, not
last wins - if an
unknownref could be both a canonical-looking id and a display-like id, prefer exact canonical-id lookup first, then unique display resolution, otherwise stay unresolved
This policy should explicitly reuse existing shared task-identity rules where possible:
looksLikeCanonicalTaskId(...)getTaskDisplayId(...)
UI Structure
In the task popup, the current Execution Logs section should become a composed panel:
Task ActivityExecution Sessions
Target end state:
- outer collapsible title =
Task Logs - inner subsections =
Task ActivityandExecution Sessions
For rollout stability, the outer collapsible title may temporarily remain Execution Logs,
but the plan target should still be Task Logs.
Inside that block, the composed content should clearly separate:
Task ActivityExecution Sessions
This preserves user familiarity while still introducing the new model cleanly.
Task Activity
New feed based only on explicit boardTaskLinks plus message-level boardTaskToolActions
Shows:
- lifecycle events
- execution-linked activity
- related board actions on this task
This section complements Workflow History, not replaces it:
Workflow Historyremains the authoritative board-state timelineTask Activitybecomes the runtime provenance feed
Empty-state guidance:
- if no explicit activity exists for a task, render an explicit empty state instead of silently collapsing the section
- the copy should explain that older sessions may still be available below in
Execution Sessions
Resolution display guidance:
resolution = 'active'- render normal task label/navigation behavior
resolution = 'deleted'- render deleted-state badge or muted label
- do not present it as a normal clickable live-task target in v1
resolution = 'unresolved' | 'ambiguous'- render raw locator fallback
- avoid deep-link navigation because the target identity is not reliable
Execution Sessions
Keep the current session-based block, powered by the existing MemberLogsTab
Purpose:
- full transcript viewing
- current previews
- chunk filtering
- session-level exploration
This block should be clearly treated as legacy/session-centric, not the new source of truth for task activity.
Important UI rule:
- execution-specific polling affordances such as
Updating.../Onlinebelong to theExecution Sessionssubsection only - they should not be used as the loading or freshness indicator for the whole
Task Logspanel
Why We Are Not Replacing the Old Block
The current execution-log UI is useful, but it is solving a different problem:
- it groups by session
- it sorts by work-interval overlap
- it filters chunks by persisted work intervals
That is good for execution sessions, but not enough for task activity provenance.
Trying to make one model serve both purposes creates:
- misleading activity feeds
- hidden related actions from other actors
- more heuristics
- harder maintenance
So the correct design is parallel, not replacement.
Tool Classification
All tool names in this section refer to the canonical bare board-tool name after agent-teams MCP name normalization.
Lifecycle
These create linkKind = 'lifecycle':
task_starttask_completetask_set_statusreview_startreview_approvereview_request_changes
Board actions
These create linkKind = 'board_action':
task_add_commenttask_get_commenttask_set_ownertask_attach_filetask_attach_comment_filetask_linktask_unlinktask_set_clarificationreview_request
Low-signal reads
These are still explicit links, but may be visually muted or collapsible:
task_get
Ignored in v1
task_createtask_create_from_messagetask_listtask_briefingmember_briefing- broad process/message tools without explicit
taskId
Execution State Rules
The runtime must not keep a naive single currentTask.
Instead it should keep an execution scope per actor:
- key =
(sessionId, agentId ?? 'main')
State should track:
- open active task set
- active phase (
workorreview) - execution sequence number
Safe stamping rules
0active tasks- no ambient execution link
1active task- ambient execution link allowed
2+active tasks- relation becomes
ambiguous - do not guess
- relation becomes
Important rule
For lifecycle messages:
- stamp the link from the explicit tool target first
- then update the actor execution state
This ensures the lifecycle message itself is always linked to the correct task.
Review Flow Rules
Review is part of the board-task activity domain and must be modeled explicitly.
Rules
review_requestboard_action- does not open review execution
review_startlifecycle- may open review execution for the reviewer
review_approvelifecycle- closes review execution
review_request_changeslifecycle- closes review execution
This keeps reviewer activity structurally visible instead of forcing it through status heuristics.
Multi-Target Tools
task_link / task_unlink
These should emit two links when both task references are resolved:
- one with
taskArgumentSlot = 'taskId' - one with
taskArgumentSlot = 'targetId'
This is the strongest reason to use boardTaskLinks[] instead of a single object.
On the read side, the builder should combine sibling links from the same transcript message so each rendered row can expose:
- the current task
- the peer task
- the relationship perspective for the current task
That avoids forcing renderer code to understand raw MCP input roles.
The BoardTaskToolActionV1.input.relationship value plus the persisted taskArgumentSlot should be
enough for the builder to derive relationship direction without re-reading task files.
Edge Cases
Another actor updates a task
Example:
- Bob is actively working on task B
- Bob calls
task_add_commenton task A
Expected result:
- task A activity feed shows the event
- task B can continue to show Bob's own execution session separately in the legacy block
- event is marked as a related board action from another active task
- it is not shown as execution of task A
Lead mixed stream
In v1:
- do not ambient-stamp lead free-text execution
- do allow explicit lifecycle and board-action links from lead tool calls
Ambiguous execution state
If the actor has multiple active tasks:
- do not guess
- stamp explicit target links only
- use
relation = 'ambiguous'
Idle actor
If the actor is not actively executing any task but performs a task tool call:
- use
relation = 'idle'
Historical logs
Old logs without boardTaskLinks remain supported through:
- legacy execution sessions
- existing fallback logic where still needed
The new activity feed in v1 should use explicit links only.
Multi-target relationship actions
For task_link / task_unlink:
- the task popup for the
taskIdside should render the relationship from that task's perspective - the related task popup for the
targetIdside should render the mirrored relationship from the peer-task perspective - the UI label should make the relationship direction clear instead of rendering both rows identically
Implementation Structure
agent_teams_orchestrator
Create a dedicated feature area:
src/services/boardTaskActivity/contract.tssrc/services/boardTaskActivity/BoardTaskToolInterpreter.tssrc/services/boardTaskActivity/BoardTaskExecutionReducer.tssrc/services/boardTaskActivity/BoardTaskTranscriptProjector.tssrc/services/boardTaskActivity/RuntimeBoardTaskExecutionStore.tssrc/services/boardTaskActivity/QueryBoardTaskObserver.ts
Responsibilities:
- inspect board MCP tool semantics
- maintain actor execution state
- produce
boardTaskLinks[] - produce
boardTaskToolActions[]where applicable - attach transcript-only task metadata before persistence
Implementation note:
- thread the internal carrier field through the runtime message helpers before
insertMessageChain(...) - avoid computing task links late inside persistence from mutable global state
claude_team
Create a separate task-log feature area:
src/main/services/team/taskLogs/contract/BoardTaskTranscriptContract.tssrc/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.tssrc/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader.tssrc/main/services/team/taskLogs/activity/BoardTaskActivityEntryBuilder.tssrc/main/services/team/taskLogs/activity/BoardTaskActivityService.tssrc/main/services/team/taskLogs/legacy/LegacyExecutionSessionsService.tssrc/main/ipc/teams.ts- add a dedicatedgetTaskActivityhandlersrc/main/ipc/handlers.ts- register / remove the new handler with existing team IPC initialization
Shared types:
src/shared/types/team.ts- addBoardTaskActivityEntryand related IPC-visible typessrc/shared/types/api.ts- addteams.getTaskActivity(...)src/preload/constants/ipcChannels.ts- addTEAM_GET_TASK_ACTIVITYsrc/preload/index.ts- expose the new preload methodsrc/renderer/api/httpClient.ts- add browser-mode fallback forgetTaskActivity
Renderer:
src/renderer/components/team/taskLogs/TaskLogsPanel.tsxsrc/renderer/components/team/taskLogs/TaskActivitySection.tsxsrc/renderer/components/team/taskLogs/ExecutionSessionsSection.tsxsrc/renderer/components/team/taskLogs/taskActivityPresentation.ts
API separation
Do not overload the existing legacy API method.
Keep:
teams.getLogsForTask(...)for legacy execution sessions
Add:
teams.getTaskActivity(teamName, taskId)for the new explicit activity model
This separation keeps the new model isolated from the old heuristic/session path.
For the first rollout, this API can follow the same availability profile as the current task-log endpoints:
- supported in Electron
- browser-mode HTTP client can return
[]with a warning, matching the current task-log API pattern
Contract discipline
To keep both repos aligned without over-coupling them:
- define JSON schemas for
BoardTaskLinkV1andBoardTaskToolActionV1 - mirror the TypeScript type locally in each repo
- add golden fixtures for representative cases in both repos
- keep transcript-contract mirror types main-process-only in
claude_team - keep
BoardTaskActivityEntryand other IPC-visible DTOs in shared preload/renderer types
Parsing tolerance rules:
- parse
boardTaskLinksandboardTaskToolActionsdefensively and independently - if one link object is malformed, drop only that link, not the whole transcript message
- if one action object is malformed, drop only that action, not the whole transcript message
- if
schemaVersionis unknown, skip that object family and keep the rest of the message readable - if a link references a
toolUseIdwith no surviving action, the row may still be rendered from the link alone - if an action survives but no links survive for its
toolUseId, ignore the action for feed-building and optionally emit a debug log
This keeps the explicit feed resilient against partial writes, old transcripts, or future schema extensions that the current reader does not understand yet.
Minimum fixture set:
- same-task execution
- one message with multiple board-task tool results joined by distinct
toolUseId - lifecycle by another actor while active on a different task
- board action by another actor while active on a different task
- review start / review completion
- task link dual-target emission
- relationship row with derived peer task and relationship perspective
- task relationship subtype payload
- status / owner / clarification action payload
- unresolved display-only task locator
- display-id collision produces
resolution = 'ambiguous' - deleted task locator produces
resolution = 'deleted'without dropping the row - unknown refKind that looks canonical resolves by exact id before any display fallback
- ambiguous actor context
- legacy entry without explicit links
Concrete Code Blueprint
This section is intentionally implementation-oriented. The goal is to remove as much ambiguity as possible before coding starts.
agent_teams_orchestrator - exact touchpoints
1. Transcript contract types
File:
src/services/boardTaskActivity/contract.tssrc/types/logs.ts
Add:
export type BoardTaskLocator = {
ref: string
refKind: 'canonical' | 'display' | 'unknown'
canonicalId?: string
}
export type BoardTaskLinkV1 = {
schemaVersion: 1
task: BoardTaskLocator
taskArgumentSlot?: 'taskId' | 'targetId'
toolUseId?: string
linkKind: 'execution' | 'lifecycle' | 'board_action'
actorContext: {
relation: 'same_task' | 'other_active_task' | 'idle' | 'ambiguous'
activeTask?: BoardTaskLocator
activePhase?: 'work' | 'review'
activeExecutionSeq?: number
}
}
export type BoardTaskToolActionV1 = {
schemaVersion: 1
toolUseId: string
canonicalToolName: string
input?: {
status?: 'pending' | 'in_progress' | 'completed' | 'deleted'
owner?: string | null
relationship?: 'blocked-by' | 'blocks' | 'related'
clarification?: 'lead' | 'user' | null
reviewer?: string
commentId?: string
}
resultRefs?: {
commentId?: string
attachmentId?: string
filename?: string
}
}
Extend TranscriptMessage in src/types/logs.ts with:
boardTaskLinks?: BoardTaskLinkV1[]
boardTaskToolActions?: BoardTaskToolActionV1[]
Preferred reusable carrier type:
export type BoardTaskCarrierFields = {
boardTaskLinks?: BoardTaskLinkV1[]
boardTaskToolActions?: BoardTaskToolActionV1[]
}
Implementation preference:
- prefer one shared
BoardTaskCarrierFieldsmixin over repeating the same optional fields across every helper and every runtime message type by hand - if the actual runtime message owner file can be updated cleanly, extend the owner types with this mixin once
- if the owner path is awkward or generated, use local intersection types at helper boundaries
instead of falling back to
any - keep these carrier fields runtime-internal and transcript-oriented, not part of API/model payloads
Preferred blast-radius-minimizing strategy:
type TaskAwareMessage = Message & BoardTaskCarrierFields
type TaskAwareUserMessage = UserMessage & BoardTaskCarrierFields
type TaskAwareAssistantMessage = AssistantMessage &
Pick<BoardTaskCarrierFields, 'boardTaskLinks'>
Use these local aliases first in:
createUserMessage(...)baseCreateAssistantMessage(...)emitTaskAware(...)insertMessageChain(...)
Why this is safer for v1:
- it localizes type churn to the board-task feature path
- it avoids blocking the whole rollout on the unresolved physical owner path for
types/message - it reduces the chance of breaking unrelated call sites that only know about plain
Message - it still keeps transcript persistence explicit and typed
Only after the feature works end-to-end should we consider merging the mixin into the canonical runtime message owner types everywhere, and only if that cleanup actually reduces complexity.
2. Internal message carriers
File:
src/utils/messages.ts
Concrete changes:
- introduce or import
BoardTaskCarrierFields - extend
createUserMessage(...)params with that mixin - extend the runtime
Message/UserMessage/AssistantMessagetype definitions with the same mixin only if the actual owner path makes that straightforward - follow the actual import target used by
src/utils/messages.tsfor those runtime message types instead of assuming the owner file path from memory - add those fields onto the returned runtime message object
- extend the assistant message creation path with the same carrier mixin for ambient execution stamping on assistant conversational messages
- the likely concrete touchpoint is
baseCreateAssistantMessage(...), because assistant helpers already funnel through it - ensure
normalizeMessages(...)assistant split path preserves ambientboardTaskLinkson conversational assistant text children instead of silently dropping them - in the user normalization path that rebuilds per-block messages, pass those fields through when
calling
createUserMessage(...)
Pseudo-shape:
export function createUserMessage({
...,
boardTaskLinks,
boardTaskToolActions,
}: {
...
} & BoardTaskCarrierFields): TaskAwareUserMessage {
return {
...,
boardTaskLinks,
boardTaskToolActions,
}
}
For assistant helpers, the concrete shape should be parallel:
function baseCreateAssistantMessage({
...,
boardTaskLinks,
}: {
...
boardTaskLinks?: BoardTaskLinkV1[]
}): TaskAwareAssistantMessage {
return {
...,
boardTaskLinks,
}
}
And in the normalization split path:
return {
...createUserMessage({
content: [_],
...,
boardTaskLinks: filteredBoardTaskLinksForBlock(message.boardTaskLinks, _),
boardTaskToolActions: filteredBoardTaskToolActionsForBlock(message.boardTaskToolActions, _),
}),
uuid: ...,
}
Suggested helpers:
function filteredBoardTaskLinksForBlock(
links: BoardTaskLinkV1[] | undefined,
block: ContentBlockParam,
): BoardTaskLinkV1[] | undefined {
if (!links?.length) return undefined
if (block.type === 'tool_result') {
const matching = links.filter(link => link.toolUseId === block.tool_use_id)
return matching.length > 0 ? matching : undefined
}
const ambient = links.filter(link => link.toolUseId === undefined)
return ambient.length > 0 ? ambient : undefined
}
function filteredBoardTaskToolActionsForBlock(
actions: BoardTaskToolActionV1[] | undefined,
block: ContentBlockParam,
): BoardTaskToolActionV1[] | undefined {
if (!actions?.length) return undefined
if (block.type !== 'tool_result') return undefined
const matching = actions.filter(action => action.toolUseId === block.tool_use_id)
return matching.length > 0 ? matching : undefined
}
Filtering rule for split messages:
- if
_is atool_result, carry only links/actions whosetoolUseIdmatches that block - if
_is ordinary conversational content, carry only ambient execution links wheretoolUseIdis absent - do not blindly copy the full arrays to every split child message
Without this rule, one split tool_result child can silently inherit metadata that belongs to a
different tool_result block from the same original message.
Why here:
normalizeMessagesForAPI(...)rebuilds user messages- if the carrier is not passed through here, transcript metadata will silently disappear on multi-block user messages
- ordinary conversational task activity can also land on assistant messages, so the assistant
creation path must be able to carry
boardTaskLinks - but the assistant split path should keep ambient execution links only on human-readable
conversational children, not on
tool_useor thinking-only children
3. Central tool-name normalization
Files:
src/services/mcp/mcpStringUtils.tssrc/Tool.ts
Concrete rule:
- do not add handwritten regexes for
mcp__agent-teams__... - use
mcpInfoFromString(...)and/or the same canonicalization semantics astoolMatchesName(...)
Recommended helper in BoardTaskToolInterpreter.ts:
function canonicalizeBoardToolName(rawName: string): string | null {
const info = mcpInfoFromString(rawName)
if (!info?.toolName) {
return rawName.startsWith('task_') || rawName.startsWith('review_')
? rawName
: null
}
const normalizedServer = info.serverName.replace(/[-_]+/g, '_')
if (normalizedServer !== 'agent_teams') return null
return info.toolName
}
4. Execution state store
Files:
src/services/boardTaskActivity/RuntimeBoardTaskExecutionStore.tssrc/services/boardTaskActivity/BoardTaskExecutionReducer.ts
Suggested state:
type ActorExecutionState = {
openTasks: Map<string, { locator: BoardTaskLocator; phase: 'work' | 'review'; activeExecutionSeq: number }>
appliedToolUseIds: Set<string>
}
Key the store by:
`${sessionId}:${agentId ?? 'main'}`
Reducer API:
applyLifecycle(
state: ActorExecutionState,
event: {
toolUseId: string
task: BoardTaskLocator
event:
| 'task_start'
| 'task_complete'
| 'task_set_status'
| 'review_start'
| 'review_approve'
| 'review_request_changes'
status?: 'pending' | 'in_progress' | 'completed' | 'deleted'
}
): ActorExecutionState
Important reducer rules:
- no-op if
toolUseIdalready applied task_startandtask_set_status(in_progress)open work executiontask_completeandtask_set_status(completed|pending|deleted)close work executionreview_startopens review executionreview_approveandreview_request_changesclose review execution- never guess when
openTasks.size > 1
5. Tool interpreter
File:
src/services/boardTaskActivity/BoardTaskToolInterpreter.ts
Recommended public API:
class BoardTaskToolInterpreter {
interpretToolResult(params: {
rawToolName: string
toolUseId: string
input: Record<string, unknown>
result: unknown
}): {
canonicalToolName: string | null
links: BoardTaskLinkV1[]
actions: BoardTaskToolActionV1[]
lifecycleEvent?: LifecycleEvent
}
}
Why Interpreter is the safer name:
- this module does more than assign a category
- it interprets raw tool name + input + result into domain semantics:
- canonical tool identity
- target task locator(s)
- emitted task links
- emitted tool actions
- optional lifecycle transitions
- calling it a
Classifierwould understate responsibility and make semantic leakage into neighboring modules more likely
V1 source-of-truth table should follow the currently registered teammate-operational board tools
from agent-teams-controller/src/mcpToolCatalog.js.
Recommended v1 classification table:
lifecycletask_starttask_completetask_set_statusreview_startreview_approvereview_request_changes
board_actiontask_add_commenttask_attach_comment_filetask_attach_filetask_gettask_get_commenttask_linktask_set_clarificationtask_set_ownertask_unlinkreview_request
ignore in v1 explicit feedmember_briefingtask_briefingtask_createtask_create_from_messagetask_list
out of domain for this featuremessage_send- all
cross_team_* - all
process_* - all
kanban_* team_launchteam_stop
Guardrail:
- add a unit test that loads the current task/review tool names from the controller source of truth and fails if a new teammate-operational board tool appears without explicit interpreter mapping
- this prevents the runtime semantics layer from silently drifting behind controller changes
Concrete extraction rules:
- task locator from
taskId - second locator from
targetIdfor relationship tools task_link/task_unlinkproduce two links- ordinary single-target board tools should emit one link with
taskArgumentSlot = 'taskId' - tool-derived links in v1 should have
linkKind = 'lifecycle'orlinkKind = 'board_action', neverexecution review_requestisboard_action, not lifecycle- do not copy long text fields from input/result into transcript metadata
- capture stable ids only:
commentIdattachmentIdfilename
Per-tool payload whitelist for BoardTaskToolActionV1:
task_set_status- allow
input.status
- allow
task_set_owner- allow
input.owner
- allow
task_set_clarification- allow
input.clarification
- allow
review_request- allow
input.reviewerwhen present
- allow
task_link/task_unlink- allow
input.relationship
- allow
task_add_comment- allow
resultRefs.commentId
- allow
task_get_comment- allow
input.commentId
- allow
task_attach_file/task_attach_comment_file- allow
resultRefs.attachmentId - allow
resultRefs.filename
- allow
Everything else:
- omit
input - omit
resultRefs
This whitelist must live next to the interpreter logic, not in the UI builder. The renderer should never decide which raw tool payload fields were safe to persist.
6. Query integration point
File:
src/query.ts
This is the safest integration point because the loop already has:
toolUseBlocks- yielded
update.message - normalized
tool_resultmessages
Implementation shape:
const boardTaskObserver = new QueryBoardTaskObserver(...)
function emitTaskAware(message: Message): Message {
return boardTaskObserver.annotateMessage(message, {
sessionId: getSessionId(),
agentId: toolUseContext.agentId,
assistantToolUses: toolUseBlocks,
})
}
for await (const update of toolUpdates) {
if (update.message) {
const annotatedMessage = emitTaskAware(update.message)
yield annotatedMessage
toolResults.push(
...normalizeMessagesForAPI([annotatedMessage], toolUseContext.options.tools).filter(
_ => _.type === 'user',
),
)
}
...
}
Important integration rule:
- do not annotate only the
getRemainingResults()loop - route all transcript-visible assistant/user yields in
query.tsthrough a small shared helper likeemitTaskAware(...) - that includes:
- streaming completed tool results
- remaining tool results
- synthetic missing tool-result messages on abort
- ordinary assistant conversational messages where ambient execution stamping is allowed
- specifically verify these concrete yield sites in the current file:
yield result.messagefromstreamingToolExecutor.getCompletedResults()yield update.messagefrom the maintoolUpdatesloop- emitted messages from
yieldMissingToolResultBlocks(...)
- explicitly exclude these non-target paths from task annotation:
yield messageforpostCompactMessagesyield { type: 'tombstone', ... }- tool-use summary and other non-conversational synthetic items
Otherwise the implementation will correctly stamp board-task tool results but still miss ordinary assistant-side execution activity.
annotateMessage(...) should:
- for user
tool_resultmessages:- iterate all
tool_resultblocks inside the message - pair each block by
tool_use_idwith the matching assistanttool_use - interpret each result
- stamp
boardTaskLinksandboardTaskToolActions - apply lifecycle transitions after stamping pre-event actor context
- iterate all
- for ordinary conversational messages:
- if exactly one active task exists for
(sessionId, agentId), stamp ambient execution link - otherwise leave unstamped
- if exactly one active task exists for
Pairing safety rules:
- never create
boardTaskToolActionsor lifecycle transitions from atool_resultblock unless itstool_use_idresolves to a matching assistanttool_use - prefer pairing in this order:
- direct current-turn
assistantToolUses sourceToolAssistantUUID+ assistant-message lookup when available- otherwise treat as unpaired and skip explicit board-task annotation for that block
- direct current-turn
- if a
tool_resultblock is synthetic interrupt/error recovery output, do not emit lifecycle transitions even if the original tool name was a board-task tool - if the paired tool result is clearly unsuccessful, emit no lifecycle transition
- missing pairing should be visible through debug diagnostics, not silently turned into guessed links
Recommended observer helper:
function resolveToolUseForResultBlock(params: {
toolUseId: string
assistantToolUses: ToolUseBlock[]
sourceToolAssistantUUID?: string
assistantMessages: AssistantMessage[]
}): ToolUseBlock | null {
return (
params.assistantToolUses.find(block => block.id === params.toolUseId) ??
findToolUseInAssistantMessage(params.assistantMessages, params.sourceToolAssistantUUID, params.toolUseId) ??
null
)
}
7. Persistence
Files:
src/utils/sessionStorage.ts
Concrete rule:
- do not recompute task metadata in
insertMessageChain(...) - only make sure the new optional fields are allowed by the type and survive the spread:
const transcriptMessage: TranscriptMessage = {
...message,
...
}
That keeps persistence dumb and avoids late-state bugs.
claude_team - exact touchpoints
1. Keep transcript-contract parsing local to the task-activity feature
Recommended new file:
src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader.ts
Rationale:
- do not bloat the generic JSONL parser with feature-specific activity semantics
- keep explicit activity reading isolated from the existing session-centric parsing pipeline
- the current generic parsed-message path does not expose all raw transcript metadata needed here,
especially
teamName/agentName
Suggested API:
class BoardTaskActivityTranscriptReader {
async readFile(filePath: string): Promise<RawTaskActivityMessage[]>
}
RawTaskActivityMessage should be local to the feature and include only:
filePathuuidtimestampsessionIdagentIdisSidechainteamNameagentNameboardTaskLinksboardTaskToolActionssourceOrder
Implementation detail:
- stream JSONL line-by-line, like the existing parser
- skip entries without
uuid - skip entries without
boardTaskLinks - increment
sourceOrderper accepted line so same-timestamp rows remain deterministic - no need to materialize full
ParsedMessage
Recommended performance guard for v1:
- add a small per-file parse cache keyed by
(filePath, size, mtimeMs) - return cloned cached
RawTaskActivityMessage[]when the signature matches - dedupe concurrent reads with an in-flight map so repeated popup opens do not parse the same file twice
- prefer mtime+size invalidation over TTL-only invalidation
- keep the cache feature-local, similar in spirit to existing parse caches such as
LeadSessionParseCache, instead of coupling it to the legacy logs finder - when the discovered transcript file set changes for a team, clear cache entries for paths that disappeared from the source set
Suggested helper file:
src/main/services/team/taskLogs/activity/BoardTaskActivityParseCache.ts
Suggested first-slice cache API:
type BoardTaskActivityFileSignature = {
size: number
mtimeMs: number
}
class BoardTaskActivityParseCache {
getIfFresh(filePath: string, signature: BoardTaskActivityFileSignature): RawTaskActivityMessage[] | null
getInFlight(filePath: string, signature: BoardTaskActivityFileSignature): Promise<RawTaskActivityMessage[]> | null
setInFlight(filePath: string, signature: BoardTaskActivityFileSignature, promise: Promise<RawTaskActivityMessage[]>): void
clearInFlight(filePath: string, signature: BoardTaskActivityFileSignature): void
set(filePath: string, signature: BoardTaskActivityFileSignature, rows: readonly RawTaskActivityMessage[]): void
clearForPath(filePath: string): void
}
Why this matters:
- the task popup may reopen repeatedly for the same task while the underlying JSONL files have not changed
- without an mtime-aware cache, the new explicit feed would re-parse the same lead/subagent files on every open
- this is a classic way to make a correct feature feel flaky or slow even when the domain model is sound
2. Main-side contract parsing
Files:
src/main/services/team/taskLogs/contract/BoardTaskTranscriptContract.tssrc/main/types/jsonl.tsonly if lightweight type guards help
Recommended functions:
export function parseBoardTaskLinks(value: unknown): BoardTaskLinkV1[] | null
export function parseBoardTaskToolActions(value: unknown): BoardTaskToolActionV1[] | null
Keep this contract parser feature-local and tolerant:
- unknown fields ignored
- invalid entries dropped, not fatal
Suggested parser shape:
export function parseBoardTaskLocator(value: unknown): BoardTaskLocator | null {
if (!value || typeof value !== 'object') return null
const row = value as Record<string, unknown>
const ref = typeof row.ref === 'string' ? row.ref.trim() : ''
const refKind =
row.refKind === 'canonical' || row.refKind === 'display' || row.refKind === 'unknown'
? row.refKind
: null
const canonicalId =
typeof row.canonicalId === 'string' && row.canonicalId.trim().length > 0
? row.canonicalId.trim()
: undefined
if (!ref || !refKind) return null
return { ref, refKind, canonicalId }
}
export function parseBoardTaskLinks(value: unknown): BoardTaskLinkV1[] | null {
if (!Array.isArray(value)) return null
const parsed = value
.map(item => {
if (!item || typeof item !== 'object') return null
const row = item as Record<string, unknown>
if (row.schemaVersion !== 1) return null
const task = parseBoardTaskLocator(row.task)
if (!task) return null
const linkKind =
row.linkKind === 'execution' ||
row.linkKind === 'lifecycle' ||
row.linkKind === 'board_action'
? row.linkKind
: null
const relation =
row.actorContext &&
typeof row.actorContext === 'object' &&
['same_task', 'other_active_task', 'idle', 'ambiguous'].includes(
String((row.actorContext as Record<string, unknown>).relation),
)
? ((row.actorContext as Record<string, unknown>).relation as
| 'same_task'
| 'other_active_task'
| 'idle'
| 'ambiguous')
: null
if (!linkKind || !relation) return null
return {
schemaVersion: 1,
task,
taskArgumentSlot:
row.taskArgumentSlot === 'taskId' || row.taskArgumentSlot === 'targetId'
? row.taskArgumentSlot
: undefined,
toolUseId: typeof row.toolUseId === 'string' ? row.toolUseId : undefined,
linkKind,
actorContext: {
relation,
activeTask: parseBoardTaskLocator(
(row.actorContext as Record<string, unknown>).activeTask,
) ?? undefined,
activePhase:
(row.actorContext as Record<string, unknown>).activePhase === 'work' ||
(row.actorContext as Record<string, unknown>).activePhase === 'review'
? ((row.actorContext as Record<string, unknown>).activePhase as 'work' | 'review')
: undefined,
activeExecutionSeq:
typeof (row.actorContext as Record<string, unknown>).activeExecutionSeq === 'number'
? ((row.actorContext as Record<string, unknown>).activeExecutionSeq as number)
: undefined,
},
} satisfies BoardTaskLinkV1
})
.filter((entry): entry is BoardTaskLinkV1 => entry !== null)
return parsed.length > 0 ? parsed : null
}
export function parseBoardTaskToolActions(value: unknown): BoardTaskToolActionV1[] | null {
if (!Array.isArray(value)) return null
const parsed = value
.map(item => {
if (!item || typeof item !== 'object') return null
const row = item as Record<string, unknown>
if (row.schemaVersion !== 1) return null
const toolUseId = typeof row.toolUseId === 'string' ? row.toolUseId.trim() : ''
const canonicalToolName =
typeof row.canonicalToolName === 'string' ? row.canonicalToolName.trim() : ''
if (!toolUseId || !canonicalToolName) return null
return {
schemaVersion: 1,
toolUseId,
canonicalToolName,
} satisfies BoardTaskToolActionV1
})
.filter((entry): entry is BoardTaskToolActionV1 => entry !== null)
return parsed.length > 0 ? parsed : null
}
Parser behavior rule:
- do not throw for malformed per-object metadata
- salvage valid siblings and continue reading
- reserve throwing for true file-level I/O or invalid JSONL framing only
3. Task-activity builder
Files:
src/main/services/team/taskLogs/activity/BoardTaskActivityEntryBuilder.tssrc/shared/types/team.ts
Add to shared IPC-visible types:
export interface BoardTaskActivityEntry {
id: string
timestamp: string
actor: { ... }
task: {
locator: BoardTaskLocator
taskRef?: TaskRef
resolution: 'resolved' | 'deleted' | 'unresolved' | 'ambiguous'
}
linkKind: 'execution' | 'lifecycle' | 'board_action'
actorContext: { ... }
action: {
canonicalToolName?: string
toolUseId?: string
category: ...
peerTask?: {
locator: BoardTaskLocator
taskRef?: TaskRef
resolution: 'resolved' | 'deleted' | 'unresolved' | 'ambiguous'
}
relationshipPerspective?: 'outgoing' | 'incoming' | 'symmetric'
details?: { ... }
}
source: {
messageUuid: string
filePath: string
}
}
Concrete builder algorithm:
buildEntriesForTask(rawMessage, targetTaskId) {
const matchingLinks = rawMessage.boardTaskLinks.filter(link => matchesTarget(link.task, targetTaskId))
const actionsByToolUseId = buildActionMap(rawMessage.boardTaskToolActions ?? [])
return matchingLinks.map(link => {
const action = link.toolUseId ? actionsByToolUseId.get(link.toolUseId) : undefined
const siblingLinks = link.toolUseId
? rawMessage.boardTaskLinks.filter(other => other.toolUseId === link.toolUseId)
: []
const peerLink = siblingLinks.find(other => !sameLocator(other.task, link.task))
return buildTaskActivityEntry(link, action, peerLink, rawMessage)
})
}
Recommended action-map helper:
function buildActionMap(actions: BoardTaskToolActionV1[]): Map<string, BoardTaskToolActionV1> {
const map = new Map<string, BoardTaskToolActionV1>()
for (const action of actions) {
if (map.has(action.toolUseId)) {
logDebug('[BoardTaskActivityEntryBuilder] duplicate boardTaskToolAction toolUseId', {
toolUseId: action.toolUseId,
})
continue
}
map.set(action.toolUseId, action)
}
return map
}
Dedupe rule:
- do not use silent
last wins - keep the first surviving action for a
toolUseId - log duplicates in debug mode so broken writer-side invariants are visible during QA
Builder simplification rule:
- if
link.linkKind === 'execution', do not attempt to join an action object executionrows in v1 are ambient-only and should be rendered withoutBoardTaskToolActionV1- only
lifecycleandboard_actionlinks participate intoolUseId -> actionjoins
Suggested locator-resolution helpers:
type ResolvedTaskHandle =
| { resolution: 'resolved' | 'deleted'; taskRef: TaskRef }
| { resolution: 'unresolved' | 'ambiguous' }
function buildTaskLookup(
activeTasks: TeamTask[],
deletedTasks: TeamTask[],
teamName: string,
): {
byId: Map<string, { resolution: 'resolved' | 'deleted'; taskRef: TaskRef }>
byDisplayId: Map<string, Array<{ resolution: 'resolved' | 'deleted'; taskRef: TaskRef }>>
} {
const byId = new Map<string, { resolution: 'resolved' | 'deleted'; taskRef: TaskRef }>()
const byDisplayId = new Map<
string,
Array<{ resolution: 'resolved' | 'deleted'; taskRef: TaskRef }>
>()
const addTask = (task: TeamTask, resolution: 'resolved' | 'deleted') => {
const taskRef: TaskRef = {
taskId: task.id,
displayId: getTaskDisplayId(task),
teamName,
}
byId.set(task.id, { resolution, taskRef })
const key = taskRef.displayId.toLowerCase()
const bucket = byDisplayId.get(key) ?? []
bucket.push({ resolution, taskRef })
byDisplayId.set(key, bucket)
}
for (const task of activeTasks) addTask(task, 'active')
for (const task of deletedTasks) {
if (!byId.has(task.id)) addTask(task, 'deleted')
}
return { byId, byDisplayId }
}
function resolveLocator(
locator: BoardTaskLocator,
lookup: {
byId: Map<string, { resolution: 'resolved' | 'deleted'; taskRef: TaskRef }>
byDisplayId: Map<string, Array<{ resolution: 'resolved' | 'deleted'; taskRef: TaskRef }>>
},
): ResolvedTaskHandle {
if (locator.canonicalId) {
return lookup.byId.get(locator.canonicalId) ?? { resolution: 'unresolved' }
}
if (locator.refKind === 'canonical') {
return lookup.byId.get(locator.ref) ?? { resolution: 'unresolved' }
}
if (locator.refKind === 'display') {
const candidates = lookup.byDisplayId.get(locator.ref.toLowerCase()) ?? []
if (candidates.length === 1) return candidates[0]
if (candidates.length > 1) return { resolution: 'ambiguous' }
return { resolution: 'unresolved' }
}
if (looksLikeCanonicalTaskId(locator.ref)) {
return lookup.byId.get(locator.ref) ?? { resolution: 'unresolved' }
}
const candidates = lookup.byDisplayId.get(locator.ref.toLowerCase()) ?? []
if (candidates.length === 1) return candidates[0]
if (candidates.length > 1) return { resolution: 'ambiguous' }
return { resolution: 'unresolved' }
}
Matching rule for getTaskActivity(teamName, taskId):
- target matching should primarily compare against canonical
taskId - if a link only has display-form identity, resolve it through the task lookup first
- do not compare raw strings only
- do not guess by display id when the lookup returns more than one candidate
- do not drop a row solely because the target resolves to
deletedorunresolved
Suggested actor-resolution helper:
function resolveActivityActor(rawMessage: RawTaskActivityMessage): {
memberName?: string
role: 'member' | 'lead' | 'unknown'
sessionId: string
agentId?: string
} {
if (rawMessage.agentName && rawMessage.agentName.trim().length > 0) {
return {
memberName: rawMessage.agentName.trim(),
role: rawMessage.isSidechain ? 'member' : 'lead',
sessionId: rawMessage.sessionId,
agentId: rawMessage.agentId,
}
}
return {
memberName: undefined,
role: rawMessage.isSidechain ? 'member' : 'unknown',
sessionId: rawMessage.sessionId,
agentId: rawMessage.agentId,
}
}
Actor-resolution rule:
- prefer explicit
agentNamefrom the raw transcript entry - use
isSidechainonly as a fallback hint forrole - do not infer actor identity from task ownership or task history
Stable ordering rule:
- sort final
BoardTaskActivityEntry[]bytimestamp ASC - tie-break by
rawMessage.filePath - then by
rawMessage.sourceOrder ASC - then by
action.toolUseId ?? '' - then by
id
This keeps the feed deterministic when multiple entries share the same timestamp or come from the same transcript message.
4. Dedicated service, not legacy finder reuse
Files:
src/main/services/team/taskLogs/activity/BoardTaskActivityService.tssrc/main/services/team/taskLogs/legacy/LegacyExecutionSessionsService.tssrc/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts
Suggested BoardTaskActivityService dependencies:
TeamTranscriptSourceLocatorTeamTaskReaderBoardTaskActivityTranscriptReader
Suggested API:
class BoardTaskActivityService {
async getTaskActivity(teamName: string, taskId: string): Promise<BoardTaskActivityEntry[]>
}
Concrete rule:
- new service reads explicit links only
- it must not call
findLogsForTask(...)for inference - legacy block keeps using
TeamMemberLogsFinder - task lookup for builder resolution should load both:
TeamTaskReader.getTasks(teamName)TeamTaskReader.getDeletedTasks(teamName)
- deleted tasks are part of history resolution, not an optional nice-to-have
Concrete discovery rule:
- do not make
BoardTaskActivityServicedepend onTeamMemberLogsFinder - extract a small shared locator for:
- resolving
projectDir - current
leadSessionId sessionIds- enumerating lead and subagent transcript files
- resolving
- let the new explicit path depend on that lower-level discovery boundary directly
Why:
TeamMemberLogsFinderis session-centric and attribution-heavy- the new explicit activity path does not need member-attribution heuristics
- depending on the old finder would reintroduce the mixed-responsibility boundary we are trying to remove
Suggested transcript-source locator shape:
type TeamTranscriptSourceContext = {
projectDir: string
leadSessionId?: string
sessionIds: string[]
}
class TeamTranscriptSourceLocator {
async getContext(teamName: string): Promise<TeamTranscriptSourceContext | null> { ... }
async listTranscriptFiles(teamName: string): Promise<string[]> {
const context = await this.getContext(teamName)
if (!context) return []
const files = new Set<string>()
if (context.leadSessionId) {
files.add(path.join(context.projectDir, `${context.leadSessionId}.jsonl`))
}
for (const sessionId of context.sessionIds) {
const dir = path.join(context.projectDir, sessionId, 'subagents')
for (const file of await safeListAgentJsonlFiles(dir)) {
files.add(path.join(dir, file))
}
}
return [...files].sort()
}
}
safeListAgentJsonlFiles(...) should mirror the existing subagent-file rules:
- include
agent-*.jsonl - exclude
agent-acompact*
Recommended main-process wiring:
// src/main/index.ts
const teamTranscriptSourceLocator = new TeamTranscriptSourceLocator()
const taskActivityTranscriptReader = new BoardTaskActivityTranscriptReader()
const taskActivityService = new BoardTaskActivityService(
teamTranscriptSourceLocator,
new TeamTaskReader(),
taskActivityTranscriptReader,
)
Then thread the service through IPC bootstrap:
// src/main/ipc/handlers.ts
export function initializeIpcHandlers(
registry: ServiceContextRegistry,
updater: UpdaterService,
sshManager: SshConnectionManager,
teamDataService: TeamDataService,
teamProvisioningService: TeamProvisioningService,
teamMemberLogsFinder: TeamMemberLogsFinder,
memberStatsComputer: MemberStatsComputer,
teammateToolTracker: TeammateToolTracker | undefined,
branchStatusService: BranchStatusService | undefined,
taskActivityService: BoardTaskActivityService | undefined,
...
): void {
initializeTeamHandlers(
teamDataService,
teamProvisioningService,
teamMemberLogsFinder,
memberStatsComputer,
teamBackupService,
teammateToolTracker,
branchStatusService,
taskActivityService,
)
}
// src/main/index.ts
initializeIpcHandlers(
registry,
updater,
sshManager,
teamDataService,
teamProvisioningService,
teamMemberLogsFinder,
memberStatsComputer,
teammateToolTracker,
branchStatusService,
taskActivityService,
...
)
Service export note:
- if
initializeIpcHandlers(...)insrc/main/ipc/handlers.tscontinues importing service types from../services, add the new service export to:src/main/services/team/index.tssrc/main/services/index.ts
- if you decide to import the new service type directly in
handlers.ts, keep that decision local and do not mix both import styles in the same patch
// src/main/ipc/teams.ts
let taskActivityService: BoardTaskActivityService | null = null
export function initializeTeamHandlers(
service: TeamDataService,
provisioningService: TeamProvisioningService,
logsFinder?: TeamMemberLogsFinder,
statsComputer?: MemberStatsComputer,
backupService?: TeamBackupService,
toolTracker?: TeammateToolTracker,
branchTracker?: BranchStatusService,
activityService?: BoardTaskActivityService,
): void {
...
taskActivityService = activityService ?? null
}
function getTaskActivityService(): BoardTaskActivityService {
if (!taskActivityService) {
throw new Error('Task activity service is not initialized')
}
return taskActivityService
}
This keeps the new explicit path as a first-class service instead of constructing it ad hoc inside the IPC handler.
5. Implementation checkpoints before CP1
These checks should happen before writing feature code.
-
Resolve the real runtime owner for
Message/UserMessage/AssistantMessagesrc/utils/messages.tsimports from../types/message.js- the physical source file is not obvious from the current tree walk
- do not start patching helper signatures until the actual symbol owner is confirmed
- if necessary, use editor "Go to Definition" or TypeScript resolution tooling instead of guessing
-
Enumerate every transcript-visible yield path in
src/query.ts- tool result updates
- assistant conversational updates
- synthetic missing tool-result recovery
- any other user/assistant message path that lands in transcript storage
- confirm all of them route through the planned annotation helper before enabling the feature
-
Verify split/normalize paths in
src/utils/messages.ts- assistant split path must not duplicate ambient execution links onto every child
- thinking-only children must not inherit task metadata
- user tool-result children must retain only the links/actions that match the child block's
tool_use_id
-
Verify transcript discovery assumptions in
claude_teamTeamTranscriptSourceLocatorshould reuse the same lead/subagent file discovery rules as the legacy path- subagent transcript enumeration must exclude
agent-acompact* - the first slice should not depend on worker-thread plumbing
If any of these checks fail, stop and correct the plan before code changes continue.
6. IPC / preload / browser fallback
Files:
src/preload/constants/ipcChannels.tssrc/shared/types/api.tssrc/preload/index.tssrc/main/ipc/teams.tssrc/main/ipc/handlers.tssrc/renderer/api/httpClient.ts
Add:
export const TEAM_GET_TASK_ACTIVITY = 'team:getTaskActivity'
Shared API:
getTaskActivity: (teamName: string, taskId: string) => Promise<BoardTaskActivityEntry[]>
Main handler shape in teams.ts:
async function handleGetTaskActivity(
_event: IpcMainInvokeEvent,
teamName: unknown,
taskId: unknown,
): Promise<IpcResult<BoardTaskActivityEntry[]>> { ... }
Recommended first-slice handler:
async function handleGetTaskActivity(
_event: IpcMainInvokeEvent,
teamName: unknown,
taskId: unknown,
): Promise<IpcResult<BoardTaskActivityEntry[]>> {
const vTeam = validateTeamName(teamName)
if (!vTeam.valid) {
return { success: false, error: vTeam.error ?? 'Invalid teamName' }
}
const vTask = validateTaskId(taskId)
if (!vTask.valid) {
return { success: false, error: vTask.error ?? 'Invalid taskId' }
}
return wrapTeamHandler('getTaskActivity', () =>
getTaskActivityService().getTaskActivity(vTeam.value!, vTask.value!),
)
}
Recommended preload addition:
getTaskActivity: async (teamName: string, taskId: string) => {
return invokeIpcWithResult<BoardTaskActivityEntry[]>(
TEAM_GET_TASK_ACTIVITY,
teamName,
taskId,
)
}
Important integration detail:
initializeTeamHandlers(...)should receive the new service or create/store it next to existingteamMemberLogsFinderregisterTeamHandlers(...)should registerTEAM_GET_TASK_ACTIVITYremoveTeamHandlers(...)should unregister it
Concrete handler registration:
// registerTeamHandlers(...)
ipcMain.handle(TEAM_GET_TASK_ACTIVITY, handleGetTaskActivity)
// removeTeamHandlers(...)
ipcMain.removeHandler(TEAM_GET_TASK_ACTIVITY)
Browser fallback in HttpAPIClient can mirror current task-log behavior:
getTaskActivity: async () => {
console.warn('[HttpAPIClient] getTaskActivity is not available in browser mode')
return []
}
7. UI composition
Files:
src/renderer/components/team/dialogs/TaskDetailDialog.tsxsrc/renderer/components/team/taskLogs/TaskLogsPanel.tsxsrc/renderer/components/team/taskLogs/TaskActivitySection.tsxsrc/renderer/components/team/taskLogs/ExecutionSessionsSection.tsx
Concrete change in TaskDetailDialog.tsx:
- replace direct inline
MemberLogsTabblock withTaskLogsPanel
Pseudo-shape:
<TaskLogsPanel
teamName={teamName}
task={currentTask}
taskSince={taskSince}
allowLeadExecutionPreview={allowLeadExecutionPreview}
isLeadOwnedTask={isLeadOwnedTask}
/>
TaskLogsPanel should internally render:
TaskActivitySectionExecutionSessionsSection
ExecutionSessionsSection should be a thin wrapper around the current MemberLogsTab props so the
legacy block keeps its existing behavior and polling indicators.
UI state rule:
TaskActivitySectionshould own its own loading and empty statesExecutionSessionsSectionshould keep the current refreshing and online indicators- do not reuse
ExecutionSessionsSectionpolling state as the header status for the wholeTask Logspanel - fetch
Task ActivityandExecution Sessionsindependently so one slow path does not block the other
Suggested panel skeleton:
export function TaskLogsPanel(props: {
teamName: string
task: TeamTask
taskSince?: string
allowLeadExecutionPreview?: boolean
isLeadOwnedTask?: boolean
}): React.JSX.Element {
const { teamName, task, taskSince, allowLeadExecutionPreview, isLeadOwnedTask } = props
return (
<div className="min-w-0 space-y-3">
<TaskActivitySection teamName={teamName} taskId={task.id} />
<ExecutionSessionsSection
teamName={teamName}
taskId={task.id}
taskOwner={task.owner}
taskStatus={task.status}
taskWorkIntervals={task.workIntervals}
taskSince={taskSince}
allowLeadExecutionPreview={allowLeadExecutionPreview}
isLeadOwnedTask={isLeadOwnedTask}
/>
</div>
)
}
Suggested TaskActivitySection fetch shape:
const [entries, setEntries] = useState<BoardTaskActivityEntry[] | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
setError(null)
setEntries(null)
void api.teams
.getTaskActivity(teamName, taskId)
.then(result => {
if (!cancelled) setEntries(result)
})
.catch(err => {
if (!cancelled) setError(err instanceof Error ? err.message : String(err))
})
return () => {
cancelled = true
}
}, [teamName, taskId])
8. Recommended tests
agent_teams_orchestrator
- interpreter unit tests for each board tool family
- reducer tests for open/close/ambiguous transitions
- observer tests for:
- single
tool_result - multiple
tool_resultblocks in one message - ambient execution stamp
- duplicate
toolUseIdno-op
- single
claude_team
- transcript reader tests for additive contract parsing
- builder tests for:
- same-task execution
- external board action
- lifecycle with pre-event actor context
task_link/task_unlinkwith derivedpeerTask- display-id collision resolves to
ambiguous, not first-match - deleted peer task still renders a row with
resolution = 'deleted' - unresolved locator still renders fallback row without navigation
- UI tests for:
- empty explicit activity + legacy sessions still visible
Task ActivityandExecution Sessionsseparated- deleted or unresolved peer-task rows are visibly non-primary / non-navigable
9. Runtime diagnostics
Add lightweight counters or debug logs around the new explicit path.
Minimum writer-side diagnostics:
board_task_activity.tool_result_pairedboard_task_activity.tool_result_unpairedboard_task_activity.synthetic_tool_result_skippedboard_task_activity.lifecycle_emittedboard_task_activity.lifecycle_skipped_unsuccessfulboard_task_activity.ambient_execution_emittedboard_task_activity.ambient_execution_skipped_ambiguous
Minimum read-side diagnostics:
board_task_activity.link_parse_droppedboard_task_activity.action_parse_droppedboard_task_activity.duplicate_action_tool_use_idboard_task_activity.unresolved_locatorboard_task_activity.ambiguous_locator
Rules:
- keep diagnostics low-cardinality
- never log full comment text, review prose, or arbitrary tool payloads
- prefer counts and short identifiers over verbose blobs
- debug logging is enough for v1 if metrics plumbing would slow the rollout, but the hook points should still be explicit in code
Rollout Plan
CP0 - contract and names are fixed
- finalize
BoardTaskLinkV1 - finalize
BoardTaskToolActionV1 - finalize
toolUseIdjoin rules for links and actions - finalize the tool semantics table derived from
agent-teams-controller/src/mcpToolCatalog.js - finalize naming across runtime contract, read model, and renderer
- add JSON schema and fixture examples
Pre-flight verification gate before leaving CP0:
- confirm the runtime message type owner path used by
src/utils/messages.ts - confirm the final transcript-discovery class name is
TeamTranscriptSourceLocator - confirm
query.tsannotate coverage list is complete
Rollout safety switches
Keep the feature decomposed behind separate flags or equivalent runtime gates:
boardTaskLinksWriteEnabled- enables writer-side transcript stamping only
boardTaskActivityReadEnabled- enables the new
getTaskActivity(...)read path only
- enables the new
boardTaskActivityUiEnabled- enables the
Task Activitysubsection in the popup only
- enables the
Recommended staged activation:
- writer flag on in local/dev only
- read flag on after explicit transcripts are verified
- UI flag on after read-side QA passes
Kill-switch rule:
- any serious mismatch in transcript stamping should be recoverable by disabling only the write flag
without removing legacy
Execution Sessions - any read-side performance or parsing issue should be recoverable by disabling only the read/UI flag while keeping persisted transcripts intact
- do not make rollout depend on a single all-or-nothing switch
Shadow validation phase:
- before exposing the new UI section broadly, run the writer + reader path in shadow mode
- in shadow mode:
- write explicit transcript metadata
- build activity entries in the background or in targeted debug sessions
- compare obvious invariants:
- task activity rows exist for fresh lifecycle events
- no duplicate action rows per
toolUseId - no lifecycle rows emitted from synthetic interrupt tool results
- keep the user-facing UI hidden until these checks are stable
CP1 - writer-side explicit links
- add
boardTaskLinks?: BoardTaskLinkV1[]to transcript messages - add
boardTaskToolActions?: BoardTaskToolActionV1[]to transcript messages where applicable - implement runtime tool inspection
- implement actor execution state
- stamp only explicit/safe links
CP2 - read-side activity feed
- parse explicit transcript task metadata in
claude_team - build
BoardTaskActivityEntry - expose
getTaskActivity(teamName, taskId) - keep
getLogsForTask(...)unchanged for the legacy block
Do not block the first slice on worker-thread support for the new feed.
Do not route the new explicit activity query through the existing getLogsForTask(...) worker and
fallback path. Keep it as a separate read path in v1 so the explicit model stays isolated from the
legacy heuristic/session pipeline.
If profiling later shows that explicit-link scanning is still expensive, add worker support as a follow-up slice instead of mixing that concern into the first correctness rollout.
CP3 - UI integration
- replace direct
MemberLogsTabusage in task popup with a composed panel - outer title:
Task Logs Task ActivityExecution Sessions
CP4 - display policy tuning
- map semantic activity entries to renderer labels/badges
- mute noisy read actions like
task_get, especially same-task reads - improve labels for lifecycle and cross-task actions
- add manual QA on real team sessions
Definition of Done
- Task popup shows two clearly separated sections:
Task ActivityExecution Sessions
- A task can show actions from a different actor working on another task, without mislabeling them as execution of the target task
- Review actions appear correctly in task activity
- Multi-target tools can link to multiple tasks
- Ambiguous actor state never triggers guessing
- Existing execution-session viewing still works
- Old logs remain readable
- New logs gain explicit structural task linkage
- Locator collisions never silently pick an arbitrary task
- Deleted or unresolved peer tasks do not disappear from task activity history
pnpm typecheckpasses in affected repos- targeted tests pass for:
- lifecycle events
- direct board actions
- other-active-task actor actions
- review flow
- multi-target tools
- ambiguous actor state
- explicit-link-only feed behavior in v1
- unmatched
tool_resultblocks do not create guessed links - synthetic interrupt tool results do not create lifecycle rows
Top 3 Remaining Implementation Risks
-
1. Carrier propagation drift in
agent_teams_orchestrator-🎯 9 🛡️ 8 🧠 8- roughly180-320lines of careful edits. Risk: one message path insrc/utils/messages.tsorsrc/query.tsforgets to keep or filterboardTaskLinks/boardTaskToolActions, which creates silent gaps or duplication. -
2. Partial annotate coverage in
src/query.ts-🎯 8 🛡️ 8 🧠 7- roughly120-220lines. Risk: only tool-result updates go throughemitTaskAware(...), while other transcript-visible assistant or user yields bypass the helper and lose ambient execution links. -
3. Read-side overcoupling to legacy discovery -
🎯 9 🛡️ 9 🧠 5- roughly80-160lines. Risk: the new explicit feed accidentally reusesTeamMemberLogsFinderlogic and reintroduces heuristic/session coupling. KeepingTeamTranscriptSourceLocatorseparate avoids this.
Manual QA Checklist
- Start task A, produce normal execution logs - activity shows execution entries for A
- While on task A, comment on task B - task B shows related board action, task A does not lose execution state
- Request review on task A - task A shows board action
- Start review on task A - task A shows lifecycle review event
- Approve or request changes on task A - task A shows lifecycle completion event
- Link task A to task B - both task activity feeds reflect the relationship action appropriately
- Change owner / status / clarification on task A - task activity row renders without parsing free-text result output
- Open a historical task without explicit links - legacy execution sessions still load
Final Architectural Summary
We are explicitly separating:
- runtime truth -
boardTaskLinks[]+boardTaskToolActions[] - UI activity model -
BoardTaskActivityEntry - legacy session browsing -
Execution Sessions
This avoids:
- overloading one contract with UI concerns
- overloading one UI block with two different meanings
- growing the old heuristic session finder into an even larger mixed-responsibility module
This is the cleanest path that is:
- reliable
- understandable
- scalable
- compatible with the current codebase