agent-ecosystem/docs/iterations/iteration-07-task-logs-explicit-board-task-links.md

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 contract
  • claude_team - read-side task activity feed and UI integration

Decision Record

Chosen direction

  • New Task Activity feed
  • Keep old Execution Sessions block, 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 Logs view 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 Logs entirely 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 taskContext object per message
    • Breaks on multi-target tools such as task_link
    • Becomes ambiguous too quickly

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 History timeline
  • Deleting the current Execution Sessions logic
  • Rebuilding all historical logs retroactively
  • Stamping ambiguous lead free-text execution in v1
  • Reworking built-in TaskCreate / TaskUpdate into 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:

  • boardTaskLinks
  • boardTaskToolActions

Do not use:

  • taskContext
  • boardTaskContext

Why:

  • one message can legitimately link to multiple board tasks
  • task_link and task_unlink are the clearest example
  • plural naming makes the model honest

Persisted types

Use:

  • BoardTaskLinkV1
  • BoardTaskLocator
  • BoardTaskToolActionV1

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:

  • ActivityTimeline for team inbox/message activity
  • Workflow History / StatusHistoryTimeline for board-state history

Using TaskActivityTimeline as the main internal component name would make the codebase harder to scan.

So:

  • Task Logs is the better outer section label
  • Task Activity stays the user-facing subsection label
  • TaskActivitySection is the better internal renderer name

Domain Boundaries

Included

Board task domain only:

  • task_* MCP tools that operate on board tasks
  • review_* 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.ref instead of unconditional taskId
    • 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
  • 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_result block that produced them
    • protects the contract when one transcript message contains multiple tool_result blocks
  • 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_result blocks
    • 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 / attachmentId when the tool returns them
    • canonicalToolName should store the canonical bare board tool name after agent-teams MCP normalization
    • input / resultRefs should 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, and notifyOwner

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 BoardTaskToolActionV1 per toolUseId in one message
  • keep boardTaskLinks to 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 chars
    • filename - 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:
    • taskArgumentSlot
    • toolUseId on ambient execution links
    • canonicalId when unresolved
    • actorContext.activeTask
    • actorContext.activePhase
    • actorContext.activeExecutionSeq
    • optional input / resultRefs fields that are not whitelisted for the current tool
  • use explicit null only when the domain itself uses null as meaningful data:
    • input.owner = null
    • input.clarification = null

Why:

  • omission means "not available / not applicable"
  • null means "explicitly cleared"
  • mixing them loosely would make parser behavior and UI labels inconsistent

Invariants

  • every boardTaskToolActions[*].toolUseId should match at least one boardTaskLinks[*].toolUseId
  • boardTaskToolActions must not appear without at least one boardTaskLink
  • within one message, boardTaskToolActions should be unique by toolUseId
  • linkKind = 'execution' is reserved for ambient execution rows in v1
  • execution links may carry toolUseId when they intentionally anchor a worker tool_result row for exact task-log reconstruction
  • therefore execution links should omit taskArgumentSlot
  • boardTaskToolActions should only pair with sibling links whose linkKind is lifecycle or board_action
  • actorContext.activeTask should only be set when relation = 'other_active_task'
  • actorContext.activePhase / actorContext.activeExecutionSeq describe the actor's active scope, not the target task's own identity
  • for linkKind = 'lifecycle', actorContext should 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
  • toolUseId should still be omitted for ordinary conversational execution messages

Additive-safety note

This is safe as additive transcript metadata because:

  • agent_teams_orchestrator transcript messages already tolerate optional extra fields
  • claude_team JSONL parsing is loose and ignores unknown fields until explicitly consumed

Version evolution policy

  • bump schemaVersion only for breaking meaning changes, not for additive optional fields
  • additive optional fields within BoardTaskLinkV1 / BoardTaskToolActionV1 should remain on version 1
  • 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_result messages
  • stamp boardTaskToolActions only on successful board-task tool_result messages
  • stamp ambient execution links only on ordinary conversational messages when the actor has exactly one active task
  • do not rely on raw tool_use alone 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_use blocks 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_result message
    • may carry boardTaskLinks
    • may carry boardTaskToolActions
  • ordinary user conversational message
    • may carry ambient boardTaskLinks
    • must not carry boardTaskToolActions
  • ordinary assistant conversational message
    • may carry ambient boardTaskLinks
    • must not carry boardTaskToolActions
  • assistant tool_use message
    • 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:

  • boardTaskToolActions always 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_result message, not a synthetic placeholder
  • the tool_result block pairs to a real assistant tool_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_action or lifecycle metadata
  • 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 execution links only

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_action concept 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:

  • peerTask
  • relationshipPerspective

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 popups
  • blocked-by on the task side -> incoming
  • blocked-by on the target side -> outgoing
  • blocks on the task side -> outgoing
  • blocks on the target side -> 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 taskRef but mark resolution = 'deleted'
  • never drop a row only because the task cannot be resolved to a live TaskRef
  • renderer navigation should rely on both taskRef and resolution
  • in v1, rows with resolution = 'deleted' | 'unresolved' | 'ambiguous' should render as non-primary navigation targets even if a fallback taskRef exists 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_unlink history

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 unknown ref 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 Activity
  • Execution Sessions

Target end state:

  • outer collapsible title = Task Logs
  • inner subsections = Task Activity and Execution 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 Activity
  • Execution 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 History remains the authoritative board-state timeline
  • Task Activity becomes 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... / Online belong to the Execution Sessions subsection only
  • they should not be used as the loading or freshness indicator for the whole Task Logs panel

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_start
  • task_complete
  • task_set_status
  • review_start
  • review_approve
  • review_request_changes

Board actions

These create linkKind = 'board_action':

  • task_add_comment
  • task_get_comment
  • task_set_owner
  • task_attach_file
  • task_attach_comment_file
  • task_link
  • task_unlink
  • task_set_clarification
  • review_request

Low-signal reads

These are still explicit links, but may be visually muted or collapsible:

  • task_get

Ignored in v1

  • task_create
  • task_create_from_message
  • task_list
  • task_briefing
  • member_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 (work or review)
  • execution sequence number

Safe stamping rules

  • 0 active tasks
    • no ambient execution link
  • 1 active task
    • ambient execution link allowed
  • 2+ active tasks
    • relation becomes ambiguous
    • do not guess

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_request
    • board_action
    • does not open review execution
  • review_start
    • lifecycle
    • may open review execution for the reviewer
  • review_approve
    • lifecycle
    • closes review execution
  • review_request_changes
    • lifecycle
    • closes review execution

This keeps reviewer activity structurally visible instead of forcing it through status heuristics.


Multi-Target Tools

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_comment on 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 taskId side should render the relationship from that task's perspective
  • the related task popup for the targetId side 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.ts
  • src/services/boardTaskActivity/BoardTaskToolInterpreter.ts
  • src/services/boardTaskActivity/BoardTaskExecutionReducer.ts
  • src/services/boardTaskActivity/BoardTaskTranscriptProjector.ts
  • src/services/boardTaskActivity/RuntimeBoardTaskExecutionStore.ts
  • src/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.ts
  • src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts
  • src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader.ts
  • src/main/services/team/taskLogs/activity/BoardTaskActivityEntryBuilder.ts
  • src/main/services/team/taskLogs/activity/BoardTaskActivityService.ts
  • src/main/services/team/taskLogs/legacy/LegacyExecutionSessionsService.ts
  • src/main/ipc/teams.ts - add a dedicated getTaskActivity handler
  • src/main/ipc/handlers.ts - register / remove the new handler with existing team IPC initialization

Shared types:

  • src/shared/types/team.ts - add BoardTaskActivityEntry and related IPC-visible types
  • src/shared/types/api.ts - add teams.getTaskActivity(...)
  • src/preload/constants/ipcChannels.ts - add TEAM_GET_TASK_ACTIVITY
  • src/preload/index.ts - expose the new preload method
  • src/renderer/api/httpClient.ts - add browser-mode fallback for getTaskActivity

Renderer:

  • src/renderer/components/team/taskLogs/TaskLogsPanel.tsx
  • src/renderer/components/team/taskLogs/TaskActivitySection.tsx
  • src/renderer/components/team/taskLogs/ExecutionSessionsSection.tsx
  • src/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 BoardTaskLinkV1 and BoardTaskToolActionV1
  • 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 BoardTaskActivityEntry and other IPC-visible DTOs in shared preload/renderer types

Parsing tolerance rules:

  • parse boardTaskLinks and boardTaskToolActions defensively 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 schemaVersion is unknown, skip that object family and keep the rest of the message readable
  • if a link references a toolUseId with 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.ts
  • src/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 BoardTaskCarrierFields mixin 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 / AssistantMessage type definitions with the same mixin only if the actual owner path makes that straightforward
  • follow the actual import target used by src/utils/messages.ts for 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 ambient boardTaskLinks on 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 a tool_result, carry only links/actions whose toolUseId matches that block
  • if _ is ordinary conversational content, carry only ambient execution links where toolUseId is 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_use or thinking-only children

3. Central tool-name normalization

Files:

  • src/services/mcp/mcpStringUtils.ts
  • src/Tool.ts

Concrete rule:

  • do not add handwritten regexes for mcp__agent-teams__...
  • use mcpInfoFromString(...) and/or the same canonicalization semantics as toolMatchesName(...)

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.ts
  • src/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 toolUseId already applied
  • task_start and task_set_status(in_progress) open work execution
  • task_complete and task_set_status(completed|pending|deleted) close work execution
  • review_start opens review execution
  • review_approve and review_request_changes close 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 Classifier would 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:

  • lifecycle
    • task_start
    • task_complete
    • task_set_status
    • review_start
    • review_approve
    • review_request_changes
  • board_action
    • task_add_comment
    • task_attach_comment_file
    • task_attach_file
    • task_get
    • task_get_comment
    • task_link
    • task_set_clarification
    • task_set_owner
    • task_unlink
    • review_request
  • ignore in v1 explicit feed
    • member_briefing
    • task_briefing
    • task_create
    • task_create_from_message
    • task_list
  • out of domain for this feature
    • message_send
    • all cross_team_*
    • all process_*
    • all kanban_*
    • team_launch
    • team_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 targetId for relationship tools
  • task_link / task_unlink produce two links
  • ordinary single-target board tools should emit one link with taskArgumentSlot = 'taskId'
  • tool-derived links in v1 should have linkKind = 'lifecycle' or linkKind = 'board_action', never execution
  • review_request is board_action, not lifecycle
  • do not copy long text fields from input/result into transcript metadata
  • capture stable ids only:
    • commentId
    • attachmentId
    • filename

Per-tool payload whitelist for BoardTaskToolActionV1:

  • task_set_status
    • allow input.status
  • task_set_owner
    • allow input.owner
  • task_set_clarification
    • allow input.clarification
  • review_request
    • allow input.reviewer when present
  • task_link / task_unlink
    • allow input.relationship
  • task_add_comment
    • allow resultRefs.commentId
  • task_get_comment
    • allow input.commentId
  • task_attach_file / task_attach_comment_file
    • allow resultRefs.attachmentId
    • allow resultRefs.filename

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_result messages

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.ts through a small shared helper like emitTaskAware(...)
  • 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.message from streamingToolExecutor.getCompletedResults()
    • yield update.message from the main toolUpdates loop
    • emitted messages from yieldMissingToolResultBlocks(...)
  • explicitly exclude these non-target paths from task annotation:
    • yield message for postCompactMessages
    • yield { 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_result messages:
    • iterate all tool_result blocks inside the message
    • pair each block by tool_use_id with the matching assistant tool_use
    • interpret each result
    • stamp boardTaskLinks and boardTaskToolActions
    • apply lifecycle transitions after stamping pre-event actor context
  • for ordinary conversational messages:
    • if exactly one active task exists for (sessionId, agentId), stamp ambient execution link
    • otherwise leave unstamped

Pairing safety rules:

  • never create boardTaskToolActions or lifecycle transitions from a tool_result block unless its tool_use_id resolves to a matching assistant tool_use
  • prefer pairing in this order:
    1. direct current-turn assistantToolUses
    2. sourceToolAssistantUUID + assistant-message lookup when available
    3. otherwise treat as unpaired and skip explicit board-task annotation for that block
  • if a tool_result block 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:

  • filePath
  • uuid
  • timestamp
  • sessionId
  • agentId
  • isSidechain
  • teamName
  • agentName
  • boardTaskLinks
  • boardTaskToolActions
  • sourceOrder

Implementation detail:

  • stream JSONL line-by-line, like the existing parser
  • skip entries without uuid
  • skip entries without boardTaskLinks
  • increment sourceOrder per 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.ts
  • src/main/types/jsonl.ts only 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.ts
  • src/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
  • execution rows in v1 are ambient-only and should be rendered without BoardTaskToolActionV1
  • only lifecycle and board_action links participate in toolUseId -> action joins

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 deleted or unresolved

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 agentName from the raw transcript entry
  • use isSidechain only as a fallback hint for role
  • do not infer actor identity from task ownership or task history

Stable ordering rule:

  • sort final BoardTaskActivityEntry[] by timestamp 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.ts
  • src/main/services/team/taskLogs/legacy/LegacyExecutionSessionsService.ts
  • src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts

Suggested BoardTaskActivityService dependencies:

  • TeamTranscriptSourceLocator
  • TeamTaskReader
  • BoardTaskActivityTranscriptReader

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 BoardTaskActivityService depend on TeamMemberLogsFinder
  • extract a small shared locator for:
    • resolving projectDir
    • current leadSessionId
    • sessionIds
    • enumerating lead and subagent transcript files
  • let the new explicit path depend on that lower-level discovery boundary directly

Why:

  • TeamMemberLogsFinder is 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(...) in src/main/ipc/handlers.ts continues importing service types from ../services, add the new service export to:
    • src/main/services/team/index.ts
    • src/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.

  1. Resolve the real runtime owner for Message / UserMessage / AssistantMessage

    • src/utils/messages.ts imports 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
  2. 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
  3. 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
  4. Verify transcript discovery assumptions in claude_team

    • TeamTranscriptSourceLocator should 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.ts
  • src/shared/types/api.ts
  • src/preload/index.ts
  • src/main/ipc/teams.ts
  • src/main/ipc/handlers.ts
  • src/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 existing teamMemberLogsFinder
  • registerTeamHandlers(...) should register TEAM_GET_TASK_ACTIVITY
  • removeTeamHandlers(...) 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.tsx
  • src/renderer/components/team/taskLogs/TaskLogsPanel.tsx
  • src/renderer/components/team/taskLogs/TaskActivitySection.tsx
  • src/renderer/components/team/taskLogs/ExecutionSessionsSection.tsx

Concrete change in TaskDetailDialog.tsx:

  • replace direct inline MemberLogsTab block with TaskLogsPanel

Pseudo-shape:

<TaskLogsPanel
  teamName={teamName}
  task={currentTask}
  taskSince={taskSince}
  allowLeadExecutionPreview={allowLeadExecutionPreview}
  isLeadOwnedTask={isLeadOwnedTask}
/>

TaskLogsPanel should internally render:

  • TaskActivitySection
  • ExecutionSessionsSection

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:

  • TaskActivitySection should own its own loading and empty states
  • ExecutionSessionsSection should keep the current refreshing and online indicators
  • do not reuse ExecutionSessionsSection polling state as the header status for the whole Task Logs panel
  • fetch Task Activity and Execution Sessions independently 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])

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_result blocks in one message
    • ambient execution stamp
    • duplicate toolUseId no-op

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_unlink with derived peerTask
    • 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 Activity and Execution Sessions separated
    • 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_paired
  • board_task_activity.tool_result_unpaired
  • board_task_activity.synthetic_tool_result_skipped
  • board_task_activity.lifecycle_emitted
  • board_task_activity.lifecycle_skipped_unsuccessful
  • board_task_activity.ambient_execution_emitted
  • board_task_activity.ambient_execution_skipped_ambiguous

Minimum read-side diagnostics:

  • board_task_activity.link_parse_dropped
  • board_task_activity.action_parse_dropped
  • board_task_activity.duplicate_action_tool_use_id
  • board_task_activity.unresolved_locator
  • board_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 toolUseId join 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.ts annotate 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
  • boardTaskActivityUiEnabled
    • enables the Task Activity subsection in the popup only

Recommended staged activation:

  1. writer flag on in local/dev only
  2. read flag on after explicit transcripts are verified
  3. 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
  • 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 MemberLogsTab usage in task popup with a composed panel
  • outer title: Task Logs
  • Task Activity
  • Execution 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 Activity
    • Execution 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 typecheck passes 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_result blocks 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 - roughly 180-320 lines of careful edits. Risk: one message path in src/utils/messages.ts or src/query.ts forgets to keep or filter boardTaskLinks / boardTaskToolActions, which creates silent gaps or duplication.

  • 2. Partial annotate coverage in src/query.ts - 🎯 8 🛡️ 8 🧠 7 - roughly 120-220 lines. Risk: only tool-result updates go through emitTaskAware(...), 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 - roughly 80-160 lines. Risk: the new explicit feed accidentally reuses TeamMemberLogsFinder logic and reintroduces heuristic/session coupling. Keeping TeamTranscriptSourceLocator separate 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