123 KiB
Member Work Sync Control Plane Plan
Status: Phase 1, Phase 1.5 observability, minimal read-only member details surface, and active-by-default Phase 2 nudge outbox/dispatcher/scheduler implemented
Scope: Team management, task work synchronization, agent work coordination
Primary repo: claude_team
Secondary write-boundary repo: agent_teams_orchestrator / agent-teams-controller
Feature name: member-work-sync
1. Summary
Build a shadow-first control plane that lets the app determine whether each teammate has seen and acknowledged the current actionable work state.
This is not a simple "ping when agent is idle" feature. The app owns the truth:
- the current actionable work agenda;
- the agenda fingerprint;
- whether an agent report is valid for that fingerprint;
- whether a future nudge would be needed;
- whether watchdog remains responsible for semantic task stalls.
Recommended implementation:
Phase 1: Shadow-first member-work-sync control plane
🎯 10 🛡️ 10 🧠 5, roughly 850-1150 LOC.
Phase 1 does not send nudges. It computes agenda/fingerprint/status, validates member_work_sync_report, stores status conditions, and exposes diagnostics. This avoids agent spam and gives real metrics before behavior changes.
Phase 2 adds durable nudges only after Phase 1 metrics prove that fingerprint churn and false positives are low.
Current implementation note:
- Phase 1 is intentionally shadow-only: it computes agendas, fingerprints, report tokens, reports, persisted status, passive queue reconciliation, startup replay, diagnostics, metrics, and a neutral read-only member details surface.
- Phase 1 does not insert inbox messages, send nudges, mark tasks/messages read, or change
TeamTaskStallMonitorsemantics. - Phase 1.5 exposes a machine-readable
phase2Readinessassessment from shadow metrics. It can saycollecting_shadow_data,blocked, orshadow_ready; it still does not dispatch nudges. - Phase 2 storage foundation is implemented as a durable outbox: idempotency key, payload hash conflict checks, claim generation fencing, retry/terminal states.
- Queue reconciles can plan a Phase 2 outbox item only when
phase2Readiness=shadow_ready; read-only diagnostics never create outbox intents. This preserves the anti-spam guard and keeps UI/status reads passive. - Phase 2 nudge delivery is active by default in production composition. Safety is provided by internal guards:
shadow_ready, current fingerprint, active team, busy signal, watchdog cooldown, rate limit, and idempotent outbox. - Dispatcher use case runs after queued reconcile and is also exposed through the facade. It claims due outbox rows, revalidates active team/status/current fingerprint/readiness/busy/watchdog cooldown, then writes one idempotent inbox nudge through a narrow port.
- Production busy revalidation is wired through a tool-activity busy signal adapter. Active or recently finished tool calls defer Phase 2 nudges instead of interrupting work.
- A feature-owned dispatch scheduler wakes due retryable outbox items for lifecycle-active teams when a lifecycle-active team source is available. It is bounded, unref'ed, and still relies on dispatcher revalidation before any inbox write.
- Dispatcher applies per-member hourly rate limiting and bounded deterministic retry backoff with jitter before retrying failed nudge attempts.
- Superseded-but-undelivered outbox items can be revived by a fresh queued reconcile for the same agenda fingerprint. Delivered nudges remain one-per-fingerprint.
- Phase 2 dispatch stays blocked until real shadow metrics confirm that
needs_syncchurn and false positives are acceptably low.
Patterns used:
- Kubernetes-style level-triggered reconcile: recompute from current desired/current state instead of trusting events.
- Queue visibility / lease pattern:
still_workingsuppresses duplicate nudges for a bounded time, but never completes work. - Durable outbox pattern: Phase 2 sends rare nudges through idempotent outbox records, not direct side effects in reconcilers.
- Concurrency-key pattern: one pending nudge per
(team, member, agendaFingerprint). - Gastown-style control loop idea: prefer explicit coordination state over ad hoc idle pings.
- GoClaw-style lightweight worker signals: tool/turn events are useful triggers, but not proof.
2. Why This Exists
Current failure class:
- A teammate has assigned tasks or review work.
- The agent stops after saying "done", "standing by", "continuing", or "I will work on it".
- The UI may still show "working on" or the task remains incomplete.
- Existing watchdog eventually catches some stalls, but it is task-specific and delayed.
- A naive ping-after-idle loop would spam agents and conflict with the watchdog.
The missing layer is a fast consistency loop:
Does this member know the current actionable work agenda?
That question is different from:
Did delivery succeed?
Is the runtime alive?
Is the member making meaningful progress?
Did the task complete?
Those remain separate systems.
3. Design Principles
3.1 Clean Architecture
The feature follows docs/FEATURE_ARCHITECTURE_STANDARD.md.
src/features/member-work-sync/
contracts/
core/
domain/
application/
main/
composition/
adapters/
input/
output/
infrastructure/
preload/
renderer/
Rules:
core/domaincontains pure business rules.core/applicationorchestrates use cases through ports.main/adapters/outputadapts current team/task/runtime services.main/infrastructureowns filesystem stores, locks, versioning.rendereronly displays status and diagnostics.- controller/orchestrator owns only the MCP write boundary for
member_work_sync_report.
3.2 SOLID
- SRP: agenda building, fingerprinting, report validation, decision policy, persistence, and dispatch are separate classes.
- OCP: Phase 2 nudges add a new outbox port without rewriting Phase 1 domain logic.
- LSP: tests can replace real adapters with fakes without special cases.
- ISP: ports are narrow:
WorkAgendaSource,MemberWorkSyncStatusStore,BusySignalSource,Clock,Logger. - DIP: application layer depends on interfaces, not
TeamDataService,TeamTaskReader, Electron, or filesystem.
3.3 Naming Convention
Use MemberWorkSync for implementation types and files.
Preferred public names:
- feature folder:
src/features/member-work-sync - agent report tool:
member_work_sync_report - optional read helper:
member_work_sync_status - message kind for Phase 2 nudges:
member_work_sync_nudge
Avoid introducing new board-prefixed sync type names. "Board" is still part of the domain explanation, but the feature name should stay focused on member actionable work.
3.4 Domain Coupling Is Intentional
The feature is strongly coupled to the task board domain. That is correct.
It must depend on:
TeamTask;- task owner;
- review state;
- blockers;
- clarification state;
- workflow history events;
- configured team members.
It must not depend on:
- React state;
- kanban UI columns as presentation;
- CSS/layout;
- raw OpenCode transcript quirks;
- prompt wording;
- current animation/spinner state.
The core abstraction is not "kanban board UI". It is:
authoritative actionable work graph
4. Key Terms
4.1 Actionable Work Agenda
A canonical list of work items that currently require action from a specific member.
It can include:
- implementation work owned by the member;
- review work assigned to the member;
- clarification follow-up owned by the member;
- blocked work that requires explicit blocker reporting;
- tasks that became actionable because another task unblocked them.
It should not include:
- completed tasks;
- deleted tasks;
- tasks owned by another member unless this member is reviewer/action owner;
- informational comments;
- runtime heartbeats;
- tool logs;
- UI-only ordering.
4.2 Agenda Fingerprint
A stable hash of the canonical agenda.
Example:
agenda:v1:7d6d337b5f91c1e9a2f7f6e9d2f0b1013e145db1...
The fingerprint is the work-sync equivalent of observedGeneration.
Agent reports are valid only for the current fingerprint.
4.3 Work Sync Report
An agent-side report for a specific fingerprint.
It is not trusted blindly. The app validates it against current agenda.
Allowed states:
still_workingblockedcaught_up
4.4 Lease
A time-limited report that suppresses sync nudges.
Important:
still_workingis a lease.blockedis a lease with board evidence.caught_upis not a free-form lease. It is only accepted when agenda is empty.- A lease is not task progress.
5. Non-Goals
Phase 1 does not:
- send nudges to agents;
- auto-complete tasks;
- mark inbox messages read;
- replace
TeamTaskStallMonitor; - change OpenCode delivery ledger semantics;
- interpret model text as truth;
- read UI kanban layout as source of truth.
Phase 1 may add prompt/tool instructions, but they must be advisory. The server validates everything.
6. Architecture Overview
flowchart TD
A["TeamChangeEvent / scheduled scan / manual refresh"] --> B["MemberWorkSyncReconciler"]
B --> C["WorkAgendaSource port"]
C --> D["TeamTaskAgendaSource adapter"]
B --> E["AgendaFingerprint"]
B --> F["MemberWorkSyncStatusStore port"]
B --> G["BusySignalSource port"]
B --> H["SyncDecisionPolicy"]
H --> I["MemberWorkSyncStatusStore port"]
I --> J["JsonMemberWorkSyncStatusStore"]
K["member_work_sync_report MCP tool"] --> L["MemberWorkSyncReporter"]
L --> M["ReportValidator"]
M --> I
I --> N["Renderer status / diagnostics"]
Phase 2 extends this:
flowchart TD
A["SyncDecisionPolicy returns needs_sync"] --> B["MemberWorkSyncOutbox"]
B --> C["MemberWorkSyncDispatcher"]
C --> D["Fresh agenda revalidation"]
D --> E["IdempotentInboxNudgePort.insertIfAbsent"]
7. Feature Directory Plan
src/features/member-work-sync/
index.ts
contracts/
index.ts
types.ts
ipc.ts
core/
domain/
ActionableWorkAgenda.ts
AgendaFingerprint.ts
MemberWorkSyncReportValidator.ts
SyncDecisionPolicy.ts
MemberWorkSyncConditions.ts
memberName.ts
application/
MemberWorkSyncReconciler.ts
MemberWorkSyncReporter.ts
MemberWorkSyncDiagnosticsReader.ts
ports.ts
main/
composition/
createMemberWorkSyncFeature.ts
adapters/
input/
registerMemberWorkSyncIpc.ts
output/
TeamTaskAgendaSource.ts
TeamMemberRosterSource.ts
MemberBusySignalSource.ts
WatchdogCooldownSource.ts
infrastructure/
JsonMemberWorkSyncStore.ts
MemberWorkSyncStorePaths.ts
MemberWorkSyncQueue.ts
HmacReportTokenAdapter.ts
preload/
index.ts
renderer/
adapters/
memberWorkSyncStatusViewModel.ts
hooks/
useMemberWorkSyncStatus.ts
ui/
MemberWorkSyncBadge.tsx
MemberWorkSyncDetails.tsx
Phase 1 can omit preload/renderer UI if we expose diagnostics only through existing debug surfaces, but the feature should reserve contracts now.
8. Core Domain Types
8.0 Highest-Risk Domain Decisions
These are the places most likely to create bugs if implemented casually.
| Area | Main failure mode | Required guard | Required tests |
|---|---|---|---|
| Agenda semantics | False NeedsSync, hidden pending work, or wrong owner |
Build only from canonical task/review/blocker facts and document every include/exclude rule | pending owned task, review task, blocked task, clarification task, completed task |
| Fingerprint | Churn from comments, timestamps, retries, or runtime liveness | Stable canonical JSON, explicit include/exclude list, transition diagnostics | timestamp-only change, weak comment, owner change, blocker change |
| Reviewer resolution | Old review cycle becomes current work | Current-cycle review resolver, never "last reviewer wins" | approved old review plus new work, needs-fix reopening, unresolved reviewer |
| Report validation | Model hides work with caught_up or stale lease |
Validate against fresh app-side agenda, fail closed, return current preview | stale fingerprint, non-empty caught-up, foreign task id |
| Identity authority | Model claims user, provider id, lead alias, or another teammate |
Treat from as a claim, require runtime context or report token for accepted leases |
codex-as-author, user-as-author, session jack/from bob, removed member |
| Controller fallback | Controller claims lease while app is down | Raw intent only, never accepted lease, replay through app validator | app unavailable, duplicate intent replay, stale intent |
| Event queue | File/change burst creates reconcile storm | Per-member coalescing, quiet window, bounded concurrency, no synchronous emitter work | burst coalescing, stop drains queue, removed member drop |
| Team lifecycle | Stopped team still accepts reports or schedules nudges | Explicit lifecycle port checked before reconcile, report acceptance, and dispatch | stopped team report, queued item after stop, restart after stop |
| Store writes | Lost update or corrupted JSON | Versioned store, file lock, atomic write, bounded history, quarantine | concurrent update, invalid JSON, future schema |
| Watchdog interaction | Double nudge or false progress proof | Work sync reports are not progress, shared cooldown only in Phase 2 | watchdog cooldown respected, watchdog still fires on real stall |
Phase 1 success depends more on these guards than on UI.
Risk ranking:
- Agenda semantics -
🎯 8 🛡️ 9 🧠 7,250-400 LOC. This is the hardest part because it decides what the system believes is real work. - Identity authority and report-token validation -
🎯 8 🛡️ 9 🧠 6,180-320 LOC. This prevents the model from suppressing another member's sync state or repeating the earliercodex/userauthor bug. - App/controller validation split -
🎯 8 🛡️ 9 🧠 6,150-260 LOC. This prevents MCP tool calls from becoming untrusted writes. - Queue, locking, and outbox boundaries -
🎯 9 🛡️ 9 🧠 6,220-380 LOC. This prevents spam, deadlocks, duplicate nudges, and startup storms. - Team lifecycle gating -
🎯 9 🛡️ 9 🧠 4,80-160 LOC. This prevents "team is off but background agents still report" behavior from becoming accepted sync state.
Pre-coding hardening checklist:
- Write agenda-builder tests before adapters. If the agenda is wrong, every later layer behaves confidently wrong.
- Implement fingerprint diagnostics before Phase 2. Without transition reasons, churn bugs are hard to debug after nudges exist.
- Keep controller fallback intentionally weak. It can record intent, but cannot accept a lease.
- Add identity tests before report persistence.
fromis not authority unless runtime context or report token proves it. - Treat every app restart as a replay scenario. Pending intents, queued reconciles, and stale reports must be safe to process again.
- Make every Phase 2 side effect idempotent before adding the dispatcher.
- Keep side effects bounded by internal guards and narrow ports. Do not add permanent product switches for reconcile/status, report acceptance, or nudges.
- Do not merge watchdog and work-sync concepts. Work-sync is agenda observation; watchdog is semantic progress.
Failure-mode matrix:
| Failure | Safe behavior |
|---|---|
| Agenda source throws | record diagnostic, no accepted report, no nudge |
| Roster source cannot resolve member | reject report, drop queued reconcile |
| Team is stopped/cancelled | reject reports as inactive, drop queued reconcile, send no nudges |
| Controller cannot reach app validator | append pending intent only |
Runtime identity and payload from disagree |
reject with identity_mismatch, write no intent |
| Report token missing in full feature mode | reject or pending diagnostics, no accepted lease |
| Provider id appears as member name | reject unless roster has that exact configured member |
| Pending intent becomes stale before replay | reject/supersede intent, no lease |
| Fingerprint changes while report writes | app validator re-reads and rejects stale report |
| Queue receives 100 task events | one reconcile per member after quiet window |
| Store lock timeout | leave old status intact, record diagnostic |
| Phase 2 dispatcher crashes after inbox insert | deterministic message id prevents duplicate |
| Watchdog already nudged same member/task | Phase 2 work-sync nudge suppressed by cooldown |
Codebase-specific integration hazards:
| Existing area | Risk for this feature | Required decision |
|---|---|---|
src/shared/types/team.ts |
Plan drifts from real TeamTask fields |
Use historyEvents, reviewState, needsClarification, blockedBy, blocks, comments exactly as typed |
TeamDataService reviewer helpers |
Duplicate stale reviewer logic | Extract or reuse current-cycle resolver instead of creating a third interpretation |
stallMonitor/reviewerResolution.ts |
Existing resolver can return old review_approved actor |
Do not reuse it blindly for work-sync action ownership |
TeamTaskStallMonitor.noteTeamChange() |
Two background systems may react to same events | Work-sync uses its own queue and never calls stall monitor |
VersionedJsonStore |
New store could reimplement locking/quarantine badly | Wrap or reuse this pattern in member-work-sync infrastructure |
TeamTaskWriter locks |
Nested task lock plus sync-store lock can deadlock | Read task snapshot first, release, then write sync store |
TeammateToolTracker |
Tool finish may be mistaken for completion | Use only as busy/trigger signal |
RuntimeDeliveryService / OpenCode ledger |
Pending delivery may cause premature sync nudge | Treat pending delivery as busy until quiet window expires |
Most important code alignment:
Action ownership comes from TeamTask board state.
Current activity comes from tracker/ledger only as suppression.
Progress quality comes from TaskStallMonitor only.
If implementation starts parsing transcript text to decide agenda ownership, stop and redesign. That crosses the boundary into watchdog semantics.
Real type alignment:
TeamTask.reviewStateis derived, not the only authority.TeamTaskWithKanban.reviewercan contain kanban overlay reviewer state.TeamTask.historyEventsis append-only workflow history and must be used for current-cycle proof.TaskHistoryEvent.typeusesreview_changes_requested, not genericchanges_requested.TaskHistoryEvent.typeusesstatus_changedwithto, not separatetask_start/task_completeevent names.
Implementation must compile against src/shared/types/team.ts before writing any adapter code. If a planned event name is not in that file, the plan is wrong, not the type.
8.1 Actionable Work Item
export type ActionableWorkKind =
| 'work'
| 'review'
| 'clarification'
| 'blocked_dependency';
export type ActionableWorkPriority =
| 'normal'
| 'review_requested'
| 'blocked'
| 'needs_clarification';
export interface ActionableWorkItem {
taskId: string;
displayId?: string;
subject: string;
kind: ActionableWorkKind;
assignee: string;
priority: ActionableWorkPriority;
reason: string;
evidence: {
status: string;
owner?: string;
reviewer?: string;
reviewState?: string;
needsClarification?: 'lead' | 'user';
blockerTaskIds?: string[];
blockedByTaskIds?: string[];
historyEventIds?: string[];
};
}
8.2 Agenda
export interface ActionableWorkAgenda {
teamName: string;
memberName: string;
generatedAt: string;
items: ActionableWorkItem[];
sourceRevision?: string;
}
sourceRevision is optional in Phase 1. If a reliable board revision exists later, it should be included.
8.2.1 Agenda Semantics
The agenda is a per-member operational projection, not a full task list.
Include a task item only when the member has a concrete next action:
| Task state | Member relation | Agenda item |
|---|---|---|
pending |
owner === member |
work |
in_progress, not in review |
owner === member |
work |
reviewState === review |
member is current-cycle reviewer | review |
needsClarification set |
owner/member must respond or escalate | clarification when owner is member |
| blocked by dependency | owner still owns blocked work | blocked_dependency when owner is member |
Exclude:
- completed/deleted tasks;
- pending tasks not assigned to member;
- tasks in review where member is original owner but not reviewer;
- stale reviewer assignments from previous review cycles;
- comments that only say "starting", "ok", "will do";
- work-sync report records.
Important: blocked_dependency means "the member owns blocked work and may need to report/wait", not "the member must fix the dependency". If the dependency is owned by another member, that other member gets their own agenda item from their task state.
Important: pending + owner member is actionable. Otherwise a newly assigned task can be invisible until the agent calls task_start, which is exactly the class of "agent stopped but still has work" bug this feature exists to catch.
Clarification semantics:
- If
needsClarification === 'lead'and the task owner is the member, the member agenda should include aclarificationitem: the member must unblock by asking the lead or updating the task. - If
needsClarification === 'user'and the task owner is the member, include aclarificationitem only if the member is expected to route the question through team messaging or task comments. - If the task owner is not the member, do not assign the clarification to this member unless the current-cycle reviewer resolver says this member owns the current action.
- If uncertain, include no item and store a diagnostic reason. A false positive nudge is worse than a missing diagnostic in Phase 1.
Blocked dependency semantics:
- Include a
blocked_dependencyitem for the blocked task owner. - Do not include a dependency task for the blocked task owner unless they also own that dependency task.
blocked_dependencypermitsblockedreport only when the dependency/blocker evidence is still present in board state.- If the blocker disappears, the fingerprint changes and old
blockedleases become stale.
Concrete action owner table:
| Situation | Action owner |
|---|---|
| owned pending task | owner |
| owned in-progress task | owner |
| task in active review | current-cycle reviewer |
| changes requested / returned to work | owner |
| clarification on owned task | owner |
| task completed | none |
| task deleted | none |
| unowned task | none |
Agenda builder must be deterministic and monotonic per board snapshot:
- Deterministic: same task snapshot always produces the same agenda items in the same order.
- Monotonic: adding unrelated comments or runtime logs must not remove an agenda item.
- Conservative: if ownership is ambiguous, prefer no item plus diagnostic over assigning to the wrong member.
- Board-only: do not inspect transcript text, model messages, or UI labels to determine action ownership.
Hard edge cases:
| Case | Expected agenda behavior |
|---|---|
Task owner changed from bob to jack |
Bob agenda loses item, Jack agenda gains item, fingerprint changes for both |
Task moved to review with reviewer alice |
Owner agenda loses work item, Alice agenda gains review item |
| Review asks for changes | Reviewer agenda loses item, owner agenda gains work item |
| Task has stale reviewer from previous cycle | No review item unless current-cycle resolver proves it |
| Task is blocked by another task | Owner gets blocked_dependency; dependency owner is handled separately |
| Task has only "starting work" comment | No fingerprint change |
| Member removed from config | Agenda returns null and queued reconciles drop |
| Lead owns task | Lead agenda can include it; teammates do not inherit it |
Implementation guidance:
function buildAgendaForMember(input: {
tasks: TeamTask[];
member: ActiveTeamMember;
reviewerResolver: ReviewerResolverPort;
}): ActionableWorkAgenda {
const items = input.tasks.flatMap((task) => {
if (isTaskTerminal(task)) {
return [];
}
const reviewItem = mapCurrentReviewItem(task, input.member, input.reviewerResolver);
if (reviewItem) {
return [reviewItem];
}
return mapOwnedTaskItem(task, input.member) ?? [];
});
return sortAgenda(input.member, items);
}
Do not let mapOwnedTaskItem create an item for a task currently owned by another member. Cross-member dependencies should be modeled as evidence, not ownership transfer.
8.2.2 Current-Cycle Review Resolution
Review ownership must use the current review cycle.
Recommended algorithm:
- Find the most recent review-cycle opening event for the task.
- Ignore review events before the latest return-to-work / needs-fix / completed transition.
- Resolve reviewer from current-cycle
review_startedfirst, then current-cyclereview_requested. - If reviewer is still unresolved, do not assign review agenda to a teammate.
- Do not use old
review_approvedactor as the current reviewer.
This mirrors the conservative direction used by the stall monitor and avoids assigning work to a stale reviewer.
Pseudo-code:
function resolveCurrentCycleReviewer(task: TeamTask): string | null {
if (task.reviewState !== 'review') {
return null;
}
const historyEvents = task.historyEvents ?? [];
const cycleStartIndex = findLatestIndex(historyEvents, (event) =>
event.type === 'review_requested' ||
event.type === 'review_started'
);
const returnToWorkIndex = findLatestIndex(historyEvents, (event) =>
event.type === 'review_changes_requested' ||
(event.type === 'status_changed' &&
(event.to === 'in_progress' || event.to === 'completed' || event.to === 'deleted'))
);
if (cycleStartIndex < 0) {
return null;
}
if (returnToWorkIndex > cycleStartIndex) {
return null;
}
const currentCycleEvents = historyEvents.slice(cycleStartIndex);
return (
findLastReviewer(currentCycleEvents, 'review_started') ??
findLastReviewer(currentCycleEvents, 'review_requested') ??
null
);
}
Hard rule: if this resolver cannot prove a current reviewer, it returns null. It must not fall back to task.owner, lead, or a previously active reviewer. That keeps Phase 1 conservative and prevents accidental reassignment.
Existing-code warning:
stallMonitor/reviewerResolution.ts can resolve from review_approved history actor because stall detection needs historical evidence. Member-work-sync needs current action ownership, so it must not directly use that function unless the function is split into two explicit policies:
resolveCurrentActionReviewer()for work-sync and task briefing action ownership;resolveHistoricalReviewActor()for stall evidence and diagnostics.
Recommended extraction:
src/main/services/team/reviewerResolution/currentReviewCycle.ts
src/main/services/team/reviewerResolution/historicalReviewEvidence.ts
Do not hide the policy difference behind one generic resolveReviewerFromHistory() name.
Kanban overlay rule:
- If
TeamTaskWithKanban.revieweris present and taskreviewState === 'review', it can be used as current action reviewer. - If kanban reviewer is missing, fall back to current-cycle history resolver.
- If both exist and conflict, prefer kanban reviewer for current UI state but record a diagnostic conflict.
- Do not hash diagnostic conflicts into agenda fingerprint unless the action owner changes.
This matches the current app shape: task files carry history, while UI DTOs may add kanban reviewer overlay.
8.3 Fingerprint
export interface AgendaFingerprintResult {
version: 'agenda:v1';
fingerprint: string;
canonicalJson: string;
}
Fingerprint must be deterministic.
It should sort:
- items by
taskId, thenkind; - arrays inside evidence;
- object keys.
It must exclude:
generatedAt;- raw comments;
- tool logs;
- runtime status;
- sync report timestamps.
It must also exclude low-signal self updates that do not change actionable ownership:
- "starting", "начинаю", "беру в работу";
- acknowledgement-only comments;
- member work-sync reports;
- delivery retry markers;
- runtime liveness changes.
It must include only facts that change the member agenda:
- owner/reviewer/action owner;
- task status and review state;
- blocker/dependency/clarification state;
- current-cycle review assignment;
- new actionable user/lead request;
- task creation/deletion when it affects this member.
Implementation split:
core/domainbuilds canonical data and stable JSON only.core/applicationdepends on aHashPort.main/infrastructureprovides the Nodesha256implementation.
Domain example:
export function buildCanonicalAgendaJson(agenda: ActionableWorkAgenda): string {
const canonical = {
teamName: agenda.teamName,
memberName: agenda.memberName,
items: agenda.items.map((item) => ({
taskId: item.taskId,
displayId: item.displayId ?? null,
kind: item.kind,
assignee: item.assignee,
priority: item.priority,
reason: item.reason,
evidence: sortObjectDeep(item.evidence),
})),
};
return stableStringify(canonical);
}
Application example:
export interface HashPort {
sha256Hex(input: string): string;
}
export class AgendaFingerprintService {
constructor(private readonly hash: HashPort) {}
fingerprint(agenda: ActionableWorkAgenda): AgendaFingerprintResult {
const canonicalJson = buildCanonicalAgendaJson(agenda);
const hash = this.hash.sha256Hex(canonicalJson);
return {
version: 'agenda:v1',
fingerprint: `agenda:v1:${hash}`,
canonicalJson,
};
}
}
Infrastructure example:
import { createHash } from 'crypto';
export class NodeHashAdapter implements HashPort {
sha256Hex(input: string): string {
return createHash('sha256').update(input).digest('hex');
}
}
Do not import Node crypto in core/domain or core/application.
Avoid:
// Bad: framework/runtime dependency inside domain logic.
import { createHash } from 'crypto';
The final output still has this shape:
function exampleResult(hash: string, canonicalJson: string): AgendaFingerprintResult {
return {
version: 'agenda:v1',
fingerprint: `agenda:v1:${hash}`,
canonicalJson,
};
}
8.3.1 Fingerprint Stability Contract
Fingerprint changes should mean: "the member's actionable agenda changed".
It should not mean:
- a task file mtime changed;
- an agent wrote "I am starting";
- a runtime process restarted;
- a delivery retry marker was appended;
- a status condition timestamp changed.
Phase 1 must track fingerprintChangeCount and store the last few fingerprint transition reasons. If this count rises without visible agenda changes, keep phase2Readiness blocked until the source of churn is fixed.
Recommended transition diagnostic:
export interface AgendaFingerprintTransition {
from: string | null;
to: string;
changedTaskIds: string[];
changedReasons: Array<
| 'task_added'
| 'task_removed'
| 'owner_changed'
| 'reviewer_changed'
| 'status_changed'
| 'review_state_changed'
| 'blocker_changed'
| 'clarification_changed'
>;
changedAt: string;
}
This diagnostic is not part of the hash. It is stored only to debug churn.
8.4 Report
export type MemberWorkSyncReportState =
| 'still_working'
| 'blocked'
| 'caught_up';
export interface MemberWorkSyncTrustedContext {
origin: 'mcp' | 'app' | 'intent_replay';
expectedTeamName?: string;
expectedMemberName?: string;
runtimeSessionId?: string;
providerId?: string;
receivedAt: string;
identitySource:
| 'runtime_session'
| 'process_team'
| 'report_token'
| 'claimed_member'
| 'unknown';
}
export interface MemberWorkSyncReportInput {
teamName: string;
memberName: string;
agendaFingerprint: string;
state: MemberWorkSyncReportState;
taskIds?: string[];
blockerCommentId?: string;
note?: string;
reportToken?: string;
reportedAt?: string;
trustedContext: MemberWorkSyncTrustedContext;
}
Stored report:
export interface MemberWorkSyncReportRecord extends MemberWorkSyncReportInput {
id: string;
accepted: boolean;
rejectedReason?: string;
observedFingerprint: string;
expiresAt?: string;
acceptedAt?: string;
lastSeenAt: string;
trustedIdentity: boolean;
}
Report identity must be stable enough for idempotent replay.
Recommended accepted/rejected report id:
function buildReportId(input: {
teamName: string;
memberName: string;
agendaFingerprint: string;
state: MemberWorkSyncReportState;
taskIds: string[];
blockerCommentId?: string;
}): string {
return `member-work-sync-report:${stableHash(input)}`;
}
Do not include reportedAt or note in this id. A repeated report for the same lease key should update lastSeenAt and latest diagnostic note, not create unbounded duplicates. note is useful for diagnostics but must not affect lease identity.
8.4.1 Clock And Lease Authority
Lease time is always computed from app-owned time, never model-supplied time.
Rules:
trustedContext.receivedAtis the authority foracceptedAt,lastSeenAt, and lease expiry.reportedAtis optional diagnostics only. It must not extend or shorten a lease.- If the system clock jumps, lease expiry is recomputed from stored app timestamps on next read.
- Tests must use an injected
ClockPort; no directDate.now()in domain or application use cases. - A replayed pending intent uses the replay app time for validation, but the original intent time stays in diagnostics.
This prevents a model from keeping itself quiet forever by sending a future timestamp.
8.4.2 Identity Authority
memberName / MCP from is a claim, not authority.
Accepted reports that suppress nudges require one of these identity proofs:
- trusted runtime session context says the caller is the same configured member;
- process/team metadata says the caller is the same configured member;
- app-generated
reportTokenvalidates for(teamName, memberName, agendaFingerprint).
If none is available, the report can be stored as rejected diagnostics, but it must not create a valid lease.
Identity hard rules:
- All identity comparisons use canonical team/member names, preserving display case only after validation.
userandsystemcan never submit a member work-sync report.- Provider ids such as
codex,anthropic,opencode, orgeminiare not member names unless the team explicitly has a member with that exact configured name. lead/team-leadaliases map only to the configured lead member and only for the lead's own agenda.- A lead alias cannot report for another teammate.
- A runtime-bound report for
expectedMemberName="jack"withfrom="bob"is rejected asidentity_mismatch. - A report for a removed/inactive member is rejected before intent persistence.
- Identity mismatch is terminal for that call; do not write a pending intent that could later become accepted.
This deliberately repeats the earlier codex / user author bug prevention at the work-sync boundary.
Retention:
- keep latest accepted report per member/fingerprint;
- keep latest rejected report per member/fingerprint/reason;
- keep a bounded history, recommended
20report records per member or7days; - never let report history affect fingerprint;
- never treat a pending intent as accepted history.
8.5 Conditions
export type MemberWorkSyncConditionType =
| 'CaughtUp'
| 'ValidLease'
| 'NeedsSync'
| 'SuppressedBusy'
| 'SuppressedCooldown'
| 'StaleReport'
| 'InvalidReport';
export interface MemberWorkSyncCondition {
type: MemberWorkSyncConditionType;
status: 'true' | 'false' | 'unknown';
observedFingerprint: string;
reason: string;
message: string;
lastTransitionAt: string;
}
Condition examples:
{
"type": "NeedsSync",
"status": "true",
"observedFingerprint": "agenda:v1:abc...",
"reason": "ActionableAgendaWithoutValidLease",
"message": "Member has 2 actionable work items and no valid report lease.",
"lastTransitionAt": "2026-04-29T12:00:00.000Z"
}
9. Application Ports
core/application/ports.ts:
export interface ClockPort {
now(): Date;
}
export interface LoggerPort {
debug(message: string, context?: Record<string, unknown>): void;
warn(message: string, context?: Record<string, unknown>): void;
error(message: string, context?: Record<string, unknown>): void;
}
export interface WorkAgendaSourcePort {
getAgenda(input: {
teamName: string;
memberName: string;
}): Promise<ActionableWorkAgenda | null>;
}
export interface MemberRosterSourcePort {
getMember(input: {
teamName: string;
memberName: string;
}): Promise<{
name: string;
providerId?: string;
active: boolean;
isLead: boolean;
} | null>;
}
export interface TeamLifecycleSourcePort {
getTeamLifecycle(input: {
teamName: string;
}): Promise<{
active: boolean;
state: 'active' | 'stopped' | 'cancelled' | 'deleted' | 'unknown';
reason?: string;
}>;
}
export interface BusySignalSourcePort {
getBusyState(input: {
teamName: string;
memberName: string;
}): Promise<{
busy: boolean;
reason?: 'tool_active' | 'runtime_delivery_pending' | 'launching' | 'recent_activity';
until?: string;
}>;
}
export interface MemberWorkSyncStatusStorePort {
readMemberStatus(input: {
teamName: string;
memberName: string;
}): Promise<MemberWorkSyncStatus | null>;
updateMemberStatus(input: {
teamName: string;
memberName: string;
updater: (current: MemberWorkSyncStatus | null) => MemberWorkSyncStatus;
}): Promise<MemberWorkSyncStatus>;
}
export interface ReportTokenPort {
issue(input: {
teamName: string;
memberName: string;
agendaFingerprint: string;
issuedAt: Date;
}): Promise<string>;
verify(input: {
teamName: string;
memberName: string;
agendaFingerprint: string;
token: string;
now: Date;
}): Promise<{ ok: true } | { ok: false; reason: 'missing' | 'expired' | 'invalid' }>;
}
Phase 2 ports:
export interface MemberWorkSyncOutboxPort {
ensurePending(input: MemberWorkSyncOutboxEnsureInput): Promise<MemberWorkSyncOutboxItem>;
claimDue(input: MemberWorkSyncOutboxClaimInput): Promise<MemberWorkSyncOutboxItem[]>;
markDelivered(input: MemberWorkSyncOutboxMarkDeliveredInput): Promise<void>;
markSuperseded(input: MemberWorkSyncOutboxMarkSupersededInput): Promise<void>;
}
export interface IdempotentInboxNudgePort {
insertIfAbsent(input: {
teamName: string;
memberName: string;
messageId: string;
payloadHash: string;
payload: MemberWorkSyncNudgePayload;
}): Promise<{
inserted: boolean;
messageId: string;
conflict?: boolean;
}>;
}
10. Domain Policies
10.1 Report Validation
Rules:
- Unknown or stopped team -> reject.
- Unknown member -> reject.
- Removed/inactive member -> reject.
- Reserved member names
user/system-> reject. - Runtime identity mismatch -> reject without writing an intent.
- Invalid report token -> reject without writing an accepted lease.
- Stale fingerprint -> reject with current fingerprint and agenda summary.
caught_upwith non-empty agenda -> reject.still_workingwith empty agenda -> reject withcaught_uprecommendation.still_workingtaskIds must be subset of current agenda task ids.blockedrequires board evidence.
Validation must be fail-closed. If the validator cannot prove a report is safe, reject it and return the current agenda preview.
Additional rules:
noteis advisory only and never changes validation.blockerCommentIdmust point to a comment on one of the current agenda tasks if provided.- A report for a lead agenda cannot be used on behalf of another teammate.
taskIdsmust be unique after task-ref normalization.- Reports for deleted tasks are rejected.
- A report with empty
taskIdsand statestill_workingcovers all current agenda items. - A report with empty
taskIdsand stateblockedis rejected unless the whole agenda is blocked by board evidence.
Source-of-truth rule:
MCP controller validates identity, schema, and size.
claude_team application validates agenda, fingerprint, leases, blockers, and status writes.
The controller must not duplicate agenda policy. If the controller has no live app validation port, it records an intent and returns pending_validation. This avoids policy drift where the orchestrator accepts a report that the app would reject.
Hard failure cases:
- agenda source unavailable -> reject or pending intent, never accepted;
- stopped/cancelled team -> reject, never accepted;
- member missing from roster -> reject;
- report for
user/system-> reject; - runtime/session identity mismatch -> reject, no pending intent;
- invalid report token -> reject, no accepted lease;
- fingerprint not matching current app-side fingerprint -> reject;
caught_upwhile app-side agenda has items -> reject;blockedwithout blocker evidence in the current agenda -> reject.
Agent-facing rejected responses should be corrective, not punitive. For stale/agenda rejections, include current fingerprint and a compact agenda preview so the model can retry correctly. For identity failures, do not include agenda preview for another member.
Validation result contract:
export type MemberWorkSyncReportValidationReason =
| 'capability_unavailable'
| 'team_inactive'
| 'member_inactive'
| 'reserved_author'
| 'unsafe_provider_alias'
| 'identity_mismatch'
| 'identity_untrusted'
| 'team_mismatch'
| 'invalid_report_token'
| 'agenda_unavailable'
| 'stale_fingerprint'
| 'caught_up_rejected_actionable_items_exist'
| 'still_working_rejected_empty_agenda'
| 'task_not_in_current_agenda'
| 'blocked_rejected_without_evidence'
| 'invalid_payload';
export type MemberWorkSyncReportValidationResult =
| {
ok: true;
acceptedState: MemberWorkSyncReportState;
leaseExpiresAt?: string;
}
| {
ok: false;
reason: MemberWorkSyncReportValidationReason;
currentFingerprint?: string;
currentAgendaPreview?: AgendaSummaryItem[];
};
Side-effect rule:
validate() is pure.
report() is the only use case allowed to write accepted/rejected report state.
controller fallback writes only report intents.
This keeps SRP clear: validator decides, reporter persists, controller translates MCP input.
Stale report handling:
- Store the rejected report reason for diagnostics.
- Do not update
latestAcceptedReport. - Return current fingerprint and preview.
- If the stale report came from a pending intent replay, mark the intent
rejected. - Do not enqueue a nudge immediately because the stale response itself is corrective.
Blocked report handling:
blockedmust refer to current agenda items.- If
blockerCommentIdis provided, it must belong to one of those tasks. - If no
blockerCommentIdis provided, the agenda item must already carry blocker/dependency/clarification evidence. - Free-text
notecannot create blocker evidence.
Example:
export class MemberWorkSyncReportValidator {
validate(input: {
report: MemberWorkSyncReportInput;
agenda: ActionableWorkAgenda;
currentFingerprint: string;
memberActive: boolean;
trustedContext: MemberWorkSyncTrustedContext;
tokenValidation: { ok: true } | { ok: false; reason: 'missing' | 'expired' | 'invalid' };
}): MemberWorkSyncReportValidationResult {
if (!input.memberActive) {
return { ok: false, reason: 'member_inactive' };
}
if (
input.trustedContext.expectedTeamName &&
input.trustedContext.expectedTeamName !== input.report.teamName
) {
return { ok: false, reason: 'team_mismatch' };
}
if (
input.trustedContext.expectedMemberName &&
input.trustedContext.expectedMemberName !== input.report.memberName
) {
return { ok: false, reason: 'identity_mismatch' };
}
if (input.report.agendaFingerprint !== input.currentFingerprint) {
return {
ok: false,
reason: 'stale_fingerprint',
currentFingerprint: input.currentFingerprint,
currentAgendaPreview: previewAgenda(input.agenda),
};
}
const hasTrustedIdentity =
input.trustedContext.identitySource === 'runtime_session' ||
input.trustedContext.identitySource === 'process_team' ||
input.tokenValidation.ok;
if (!hasTrustedIdentity) {
return {
ok: false,
reason:
input.tokenValidation.reason === 'missing'
? 'identity_untrusted'
: 'invalid_report_token',
};
}
if (input.report.state === 'caught_up' && input.agenda.items.length > 0) {
return {
ok: false,
reason: 'caught_up_rejected_actionable_items_exist',
currentFingerprint: input.currentFingerprint,
currentAgendaPreview: previewAgenda(input.agenda),
};
}
if (input.report.state === 'still_working') {
return this.validateStillWorking(input.report, input.agenda);
}
if (input.report.state === 'blocked') {
return this.validateBlocked(input.report, input.agenda);
}
return { ok: true, acceptedState: input.report.state };
}
}
10.2 Decision Policy
Decision input:
export interface MemberWorkSyncDecisionInput {
agenda: ActionableWorkAgenda;
fingerprint: string;
latestValidReport: MemberWorkSyncReportRecord | null;
busyState: MemberBusyState;
now: Date;
}
Decision output:
export type MemberWorkSyncDecision =
| { kind: 'caught_up'; conditions: MemberWorkSyncCondition[] }
| { kind: 'valid_lease'; leaseExpiresAt: string; conditions: MemberWorkSyncCondition[] }
| { kind: 'suppressed_busy'; reason: string; conditions: MemberWorkSyncCondition[] }
| { kind: 'needs_sync'; conditions: MemberWorkSyncCondition[] };
Policy:
export class SyncDecisionPolicy {
decide(input: MemberWorkSyncDecisionInput): MemberWorkSyncDecision {
if (input.agenda.items.length === 0) {
return {
kind: 'caught_up',
conditions: [conditionCaughtUp(input)],
};
}
if (hasValidLease(input.latestValidReport, input.fingerprint, input.now)) {
return {
kind: 'valid_lease',
leaseExpiresAt: input.latestValidReport.expiresAt!,
conditions: [conditionValidLease(input)],
};
}
if (input.busyState.busy) {
return {
kind: 'suppressed_busy',
reason: input.busyState.reason ?? 'unknown',
conditions: [conditionSuppressedBusy(input)],
};
}
return {
kind: 'needs_sync',
conditions: [conditionNeedsSync(input)],
};
}
}
Phase 1 stores needs_sync but does not send.
10.3 Lease Defaults
Default lease durations should be conservative:
still_working: 10 minutes
blocked: 30 minutes
caught_up: no lease, condition only
Reasons:
still_workingshould suppress noisy sync checks, but not hide long-running stalls.blockedcan reasonably last longer because it must have board evidence.caught_upis recalculated from empty agenda and should not be trusted after new work appears.
Expired leases are ignored by SyncDecisionPolicy.
10.4 Shadow Would-Nudge Semantics
Read-only status and diagnostics may compute wouldNudgeCount, but must not enqueue or send. Production composition always wires the outbox and inbox nudge sink; side effects stay limited to queued reconcile planning and dispatcher delivery guards.
wouldNudge is true only when all are true:
- agenda is non-empty;
- no valid lease for current fingerprint;
- not busy;
- not inside quiet window;
- not suppressed by recent watchdog remediation;
- member is active and not stopped/removed.
This makes Phase 1 metrics close to Phase 2 behavior without changing runtime behavior.
11. Main Adapters
11.1 TeamTaskAgendaSource
Responsibility:
- read current tasks;
- read members/config metadata;
- resolve owner/reviewer/action owner from authoritative task fields and current-cycle history;
- output
ActionableWorkAgenda.
It should not:
- write files;
- send messages;
- inspect UI kanban presentation;
- parse runtime transcripts;
- classify semantic progress.
Reviewer resolution must be conservative. Prefer the same current-cycle semantics used by stall-monitor review resolution. Do not treat stale review history from a previous cycle as current action ownership.
Pseudo-code:
export class TeamTaskAgendaSource implements WorkAgendaSourcePort {
constructor(
private readonly taskReader: TeamTaskReader,
private readonly memberResolver: TeamMemberResolverPort,
private readonly reviewerResolver: ReviewerResolverPort
) {}
async getAgenda(input: {
teamName: string;
memberName: string;
}): Promise<ActionableWorkAgenda | null> {
const tasks = await this.taskReader.getTasks(input.teamName);
const activeMember = await this.memberResolver.resolve(input.teamName, input.memberName);
if (!activeMember?.active) {
return null;
}
const items = tasks.flatMap((task) =>
this.toActionableItems({
task,
memberName: activeMember.name,
})
);
return {
teamName: input.teamName,
memberName: activeMember.name,
generatedAt: new Date().toISOString(),
items: sortAgendaItems(items),
};
}
}
Actionable mapping examples:
function mapWorkItem(task: TeamTask, memberName: string): ActionableWorkItem | null {
if (task.status !== 'pending' && task.status !== 'in_progress') {
return null;
}
if (task.reviewState === 'review') {
return null;
}
if (!sameMember(task.owner, memberName)) {
return null;
}
if (task.needsClarification) {
return {
taskId: task.id,
displayId: task.displayId,
subject: task.subject,
kind: 'clarification',
assignee: memberName,
priority: 'needs_clarification',
reason: `Task needs clarification from ${task.needsClarification}.`,
evidence: {
status: task.status,
owner: task.owner,
reviewState: task.reviewState,
needsClarification: task.needsClarification,
},
};
}
return {
taskId: task.id,
displayId: task.displayId,
subject: task.subject,
kind: 'work',
assignee: memberName,
priority: task.blockedBy?.length ? 'blocked' : 'normal',
reason: task.blockedBy?.length
? 'Owned task is blocked by dependencies.'
: task.status === 'pending'
? 'Owned task is pending and needs to be started, clarified, or declined.'
: 'Owned task is in progress.',
evidence: {
status: task.status,
owner: task.owner,
reviewState: task.reviewState,
blockerTaskIds: task.blocks,
blockedByTaskIds: task.blockedBy,
},
};
}
Review mapping:
function mapReviewItem(args: {
task: TeamTask;
memberName: string;
reviewer: string | null;
}): ActionableWorkItem | null {
if (args.task.reviewState !== 'review') {
return null;
}
if (!args.reviewer || !sameMember(args.reviewer, args.memberName)) {
return null;
}
return {
taskId: args.task.id,
displayId: args.task.displayId,
subject: args.task.subject,
kind: 'review',
assignee: args.memberName,
priority: 'review_requested',
reason: 'Task is waiting for this member review.',
evidence: {
status: args.task.status,
owner: args.task.owner,
reviewer: args.reviewer,
reviewState: args.task.reviewState,
},
};
}
11.2 MemberBusySignalSource
Inputs:
- active tool calls from
TeammateToolTracker; - OpenCode delivery ledger pending states;
- member spawn status launching/restarting;
- recent team/member activity quiet window.
Output:
export interface MemberBusyState {
busy: boolean;
reason?: 'tool_active' | 'runtime_delivery_pending' | 'launching' | 'recent_activity';
until?: string;
}
Phase 1 can keep this conservative:
- if uncertain, return busy for short quiet window;
- better to suppress false
needs_syncthan create noisy status.
Busy signal precedence:
- active tool call or active runtime turn;
- runtime delivery pending or retry scheduled;
- member launch/restart in progress;
- recent inbox delivery or recent tool finish inside quiet window;
- unknown runtime state for a configured OpenCode lane.
For Phase 1, unknown should bias toward busy for a bounded quiet window, not permanent suppression.
11.2.1 Member Work Sync Queue
Do not reconcile synchronously inside teamChangeEmitter.
Use a per-team/member queue:
export interface MemberWorkSyncQueue {
enqueue(input: {
teamName: string;
memberName: string;
trigger: MemberWorkSyncTrigger;
runAfterMs?: number;
}): void;
start(): void;
stop(): Promise<void>;
}
Queue requirements:
- coalesce duplicate
(teamName, memberName)entries; - keep the strongest/most recent trigger reason for diagnostics;
- apply quiet window, default
90_000ms; - bounded concurrency, default
2; - drop work for removed/stopped teams;
- never send messages in Phase 1;
- expose debug counts for queued/running/dropped entries.
This prevents file watcher bursts from becoming expensive repeated agenda reads.
Implementation details:
- Use one in-memory work item per
(teamName, memberName). - Store
firstQueuedAt,lastQueuedAt,triggerReasons, andrunAfter. - If a new trigger arrives while the key is queued, merge reasons and push
runAfteronly when the new trigger is later than the existing quiet window. - If a new trigger arrives while the key is running, mark
rerunRequestedand enqueue one follow-up pass after the current pass finishes. - Use
setTimeout(...).unref?.()for delayed work so the queue does not keep the app alive. stop()must clear timers and wait for current in-flight reconciles to settle.- Do not persist Phase 1 queue state. Startup reconciliation can recreate safe state from authoritative tasks.
Startup reconciliation:
- on app start / team load, enqueue every active member with trigger
startup_scan; - on config/member metadata change, enqueue all active members for that team;
- on task import/migration, enqueue all active members after a longer quiet window;
- if a team is stopped/cancelled, drop queued entries for that team;
- if a member is removed, drop queued entries and keep only bounded diagnostic status.
Event strength order:
config_changed > task_state_changed > review_changed > inbox_delivered > tool_finished > runtime_heartbeat
This order is only for diagnostics and queue coalescing. The reconciler still recomputes from fresh state.
Race conditions to test:
| Race | Required behavior |
|---|---|
| task event arrives while reconcile is running | set rerunRequested, run exactly one follow-up pass |
| member removed while queued | drop queue item before reading agenda |
| team stopped while queued | drop queue item, do not write status |
| report accepted while queued | reconcile sees latest accepted report and avoids false NeedsSync |
| status write fails | retry only through later queue event or startup scan, not tight loop |
| app shutdown during delayed timer | timer cleared, no side effects after stop |
Suggested queue state:
interface QueuedMemberWorkSyncItem {
key: string;
teamName: string;
memberName: string;
firstQueuedAt: number;
lastQueuedAt: number;
runAfter: number;
triggerReasons: MemberWorkSyncTrigger[];
running: boolean;
rerunRequested: boolean;
}
The queue is an adapter/infrastructure concern. The domain must not know it exists.
11.3 JsonMemberWorkSyncStore
Use a versioned JSON envelope similar to VersionedJsonStore.
Path:
~/.claude/teams/<teamName>/.member-work-sync/status.json
Schema:
export interface MemberWorkSyncStoreData {
members: Record<string, MemberWorkSyncStatus>;
}
Envelope:
{
"schemaName": "member-work-sync.status",
"schemaVersion": 1,
"updatedAt": "2026-04-29T12:00:00.000Z",
"data": {
"members": {}
}
}
Store requirements:
- atomic write;
- file lock;
- invalid JSON quarantine;
- future schema quarantine or safe error;
- tests for missing/invalid/future schema.
Reuse requirement:
Prefer adapting the existing OpenCode VersionedJsonStore pattern instead of inventing a new JSON store. If direct reuse is awkward because it currently lives under opencode/store, extract a generic main-process infrastructure helper first.
Extraction target:
src/main/services/team/versionedJsonStore/VersionedJsonStore.ts
Do not deep-import from opencode/store into a new feature. That would make member-work-sync depend on OpenCode implementation details and violate dependency direction.
12. Application Use Cases
12.1 Reconcile Member
This use case must be side-effect-light:
- allowed: read ports, compute fingerprint, update member-work-sync status;
- forbidden: send messages, mutate tasks, mark inbox read, launch/restart runtimes;
- forbidden: call
TeamDataServicedirectly from core/application.
If a future behavior needs a message, write an outbox intent in a separate Phase 2 use case.
export class MemberWorkSyncReconciler {
constructor(
private readonly agendaSource: WorkAgendaSourcePort,
private readonly rosterSource: MemberRosterSourcePort,
private readonly lifecycleSource: TeamLifecycleSourcePort,
private readonly busySource: BusySignalSourcePort,
private readonly statusStore: MemberWorkSyncStatusStorePort,
private readonly fingerprintService: AgendaFingerprintService,
private readonly decisionPolicy: SyncDecisionPolicy,
private readonly clock: ClockPort,
private readonly logger: LoggerPort
) {}
async reconcileMember(input: {
teamName: string;
memberName: string;
trigger: MemberWorkSyncTrigger;
}): Promise<MemberWorkSyncStatus | null> {
const lifecycle = await this.lifecycleSource.getTeamLifecycle({ teamName: input.teamName });
if (!lifecycle.active) {
return null;
}
const member = await this.rosterSource.getMember(input);
if (!member?.active) {
return null;
}
const agenda = await this.agendaSource.getAgenda(input);
if (!agenda) {
return null;
}
const fingerprint = this.fingerprintService.fingerprint(agenda);
const busyState = await this.busySource.getBusyState(input);
return this.statusStore.updateMemberStatus({
teamName: input.teamName,
memberName: member.name,
updater: (current) => {
const latestValidReport = selectValidReport({
current,
fingerprint: fingerprint.fingerprint,
now: this.clock.now(),
});
const decision = this.decisionPolicy.decide({
agenda,
fingerprint: fingerprint.fingerprint,
latestValidReport,
busyState,
now: this.clock.now(),
});
return buildNextStatus({
current,
agenda,
fingerprint,
decision,
trigger: input.trigger,
now: this.clock.now(),
});
},
});
}
}
12.2 Report Sync
export class MemberWorkSyncReporter {
constructor(
private readonly agendaSource: WorkAgendaSourcePort,
private readonly rosterSource: MemberRosterSourcePort,
private readonly lifecycleSource: TeamLifecycleSourcePort,
private readonly statusStore: MemberWorkSyncStatusStorePort,
private readonly reportTokenPort: ReportTokenPort,
private readonly fingerprintService: AgendaFingerprintService,
private readonly validator: MemberWorkSyncReportValidator,
private readonly clock: ClockPort
) {}
async report(input: MemberWorkSyncReportInput): Promise<MemberWorkSyncReportResult> {
const lifecycle = await this.lifecycleSource.getTeamLifecycle({ teamName: input.teamName });
if (!lifecycle.active) {
return { ok: false, reason: 'team_inactive' };
}
const member = await this.rosterSource.getMember(input);
if (!member?.active) {
return { ok: false, reason: 'member_inactive' };
}
const agenda = await this.agendaSource.getAgenda(input);
if (!agenda) {
return { ok: false, reason: 'agenda_unavailable' };
}
const fingerprint = this.fingerprintService.fingerprint(agenda);
const tokenValidation = input.reportToken
? await this.reportTokenPort.verify({
teamName: input.teamName,
memberName: member.name,
agendaFingerprint: fingerprint.fingerprint,
token: input.reportToken,
now: this.clock.now(),
})
: ({ ok: false, reason: 'missing' } as const);
const validation = this.validator.validate({
report: input,
agenda,
currentFingerprint: fingerprint.fingerprint,
memberActive: member.active,
tokenValidation,
trustedContext: input.trustedContext,
});
await this.statusStore.updateMemberStatus({
teamName: input.teamName,
memberName: member.name,
updater: (current) =>
applyReportValidation({
current,
report: input,
validation,
agenda,
fingerprint,
now: this.clock.now(),
}),
});
return toReportResult(validation, agenda, fingerprint);
}
}
13. MCP Tool Design
Tool name:
member_work_sync_report
Purpose:
Report whether you have observed the current assigned/review work agenda.
Write-boundary location:
- The MCP tool is implemented in
agent_teams_orchestrator/agent-teams-controller. - Full agenda validation lives in
claude_team. - The controller reaches app validation through an explicit port/bridge when available.
- If that bridge is not available, the controller records only a pending intent.
This is intentionally not a "smart controller" design. The controller is a write boundary and identity/schema gate, not the business-policy owner.
Cross-repo rollout risk:
agent_teams_orchestrator and claude_team can be out of sync during development or user upgrades. The new tool must therefore be capability-gated.
Rules:
- Do not add
member_work_sync_reportas a hard required OpenCode readiness tool until both repos support it in the same release path. - In Phase 1, missing
member_work_sync_reportmust not block team launch. - If the tool is missing, omit work-sync instructions from
task_briefing/member_briefing. - If the tool exists but app validation bridge is unavailable, return
pending_validation. - Do not add a runtime/env flag to require the tool in Phase 1.
- OpenCode readiness tests should prove old required tools still gate launch, while work-sync tools remain optional compatibility capabilities.
Important distinction:
capability-gated means "use it if both sides expose it".
feature-flagged means "runtime branch changes behavior based on env/config".
Phase 1 uses capability gating only. That avoids permanent new vs legacy branches while still supporting mixed repo versions during development.
Compatibility matrix:
| claude_team | orchestrator/controller | Expected behavior |
|---|---|---|
| no feature | no tool | no work-sync surface |
| app has feature | no tool | status/reconcile only, no report instruction |
| app has feature | tool exists, no app bridge | pending intent only |
| app has feature | tool exists, app bridge live | full report validation |
13.1 Current Agenda Read Surface
The report tool needs a current agendaFingerprint. The agent must not invent this value.
Preferred Phase 1 read surface:
- extend
task_briefingwith a compactworkSyncblock; - include current
agendaFingerprint; - include a short actionable agenda preview;
- include report instructions only when the report tool is available.
Example task_briefing addition:
{
"workSync": {
"feature": "member-work-sync",
"agendaFingerprint": "agenda:v1:abc...",
"reportToken": "wrs:v1:short-lived-token",
"state": "needs_sync",
"actionableCount": 2,
"items": [
{
"taskRef": "#00d1e081",
"kind": "work",
"reason": "Owned task is in progress."
}
],
"reportTool": "member_work_sync_report"
}
}
Optional fallback read tool:
member_work_sync_status
Use it only if extending task_briefing becomes too invasive. Prefer task_briefing because the agent already uses it to understand work context.
Read surface requirements:
- must be generated from the same
WorkAgendaSourcePortandAgendaFingerprintServiceas the reconciler; - must not have a separate fingerprint implementation;
- must include no more than a compact agenda preview;
- must include
reportTokenonly for the requesting member's own agenda; - must omit raw comments and large task descriptions by default;
- must make clear that the fingerprint can become stale after task changes;
- must not expose other members' full agenda unless the caller is lead.
If task_briefing is extended, add tests that existing consumers still receive the old fields unchanged.
Prompt pollution guard:
- Do not repeat the full work-sync schema in every briefing.
- Include the report instruction only when
state === 'needs_sync'or a stale report was just rejected. - If state is
caught_up, include a tiny status line only. - If state is
valid_lease, include lease expiry only when useful for debugging. - Keep preview item text below
160chars each. - Never include raw comments in
workSync.items.
Recommended compact text rendering:
Work sync: agendaFingerprint=agenda:v1:abc123 state=needs_sync actionable=2.
When you have reviewed this agenda, call member_work_sync_report with this fingerprint and reportToken.
Do not add a second "primary queue" next to task_briefing. Work-sync is metadata about the same queue.
Input schema:
{
"type": "object",
"properties": {
"from": {
"type": "string",
"description": "Your configured teammate name."
},
"agendaFingerprint": {
"type": "string"
},
"reportToken": {
"type": "string",
"description": "Short-lived report token from your current workSync briefing."
},
"state": {
"type": "string",
"enum": ["still_working", "blocked", "caught_up"]
},
"taskIds": {
"type": "array",
"items": { "type": "string" }
},
"blockerCommentId": {
"type": "string"
},
"note": {
"type": "string"
}
},
"required": ["from", "agendaFingerprint", "reportToken", "state"]
}
Controller-side behavior:
- Resolve
fromthrough existing configured-member validation. - Reject
user,system, unknown, removed members, and unsafe provider-id aliases. - Attach trusted runtime context if the controller knows it.
- Enforce schema and size limits before writing anything.
- Prefer live app validation through the
member-work-syncapplication port when available. - If live validation is unavailable, append a raw report intent only when identity is not already terminally invalid.
- Return
pending_validationfor stored intents. - Never claim a lease was accepted unless the app-side validator accepted it for the current fingerprint, current agenda, and trusted identity.
13.2 Identity And Authority Contract
This is the highest-risk write boundary in the feature. The model can hallucinate from, stale fingerprints, or copied task ids, so controller and app validation must split responsibilities without drifting.
Recommended design: 🎯 9 🛡️ 9 🧠 6, 120-220 LOC.
Authority order:
- Trusted runtime context from the current OpenCode/Codex/Claude member session.
- Process/team metadata from the current agent runtime.
- App-generated
reportTokenbound to(teamName, memberName, agendaFingerprint). - Claimed
fromonly for rejection diagnostics, never by itself for an accepted lease.
reportToken details:
- Generated by
claude_teamwhen building theworkSyncbriefing. - Bound to
teamName, canonicalmemberName,agendaFingerprint, and an app-side secret or nonce. - Short-lived; recommended validity is the same as the current fingerprint plus
15minutes. - Omitted from the agenda fingerprint.
- Not a hard security boundary; it prevents accidental cross-member reports and stale prompt reuse.
- If missing because of cross-repo compatibility, the report returns
pending_validationoridentity_untrusted, not accepted.
Alias policy:
| Input | Allowed? | Rule |
|---|---|---|
alice |
yes | Only if alice is active in the current team. |
user |
no | Reserved human actor. |
system |
no | Reserved system actor. |
codex |
no by default | Provider id, not a member name, unless explicitly configured as a member. |
team-lead |
conditional | Maps only to the configured lead and only for the lead's own agenda. |
lead |
conditional | Same as team-lead. |
| removed member | no | Reject before writing an intent. |
Terminal identity failures:
identity_mismatch: trusted runtime says one member, payload claims another.team_mismatch: trusted runtime/session belongs to another team.reserved_author: payload usesuserorsystem.unsafe_provider_alias: payload uses a provider id that is not a configured member name.invalid_report_token: token does not validate for this team/member/fingerprint.member_inactive: member is removed or no longer configured.
Terminal failures must not write report-intents.json. A stale fingerprint can write rejected diagnostics, but identity mismatch cannot, because replay cannot make it safe.
Agent-facing response for identity failures should be minimal:
{
"ok": false,
"reason": "identity_mismatch",
"instruction": "Use this tool only as your configured teammate identity. Re-read member_briefing if you are unsure."
}
Do not include another member's agenda preview in identity-failure responses.
13.3 Validation Ownership Split
Keep SOLID boundaries explicit:
- Controller owns protocol validation: schema, payload size, raw alias rejection, trusted context attachment.
- Application owns domain validation: agenda, fingerprint, lease, task ids, blocker evidence, accepted/rejected report persistence.
- Domain owns pure decisions only: no filesystem, IPC, timers, or process/session lookups.
- Infrastructure owns stores, hash implementation, report token signing/verification, and runtime context adapters.
Do not import claude_team domain policy into the orchestrator controller. Use a port:
export interface MemberWorkSyncValidationPort {
validateAndRecordReport(input: MemberWorkSyncReportInput): Promise<MemberWorkSyncReportResult>;
}
When the port is unavailable, the controller can persist a raw intent only after controller-level identity validation has passed.
Accepted response:
{
"ok": true,
"state": "still_working",
"agendaFingerprint": "agenda:v1:abc...",
"leaseExpiresAt": "2026-04-29T12:10:00.000Z"
}
Pending response when the app validator is unavailable:
{
"ok": true,
"pendingValidation": true,
"state": "still_working",
"agendaFingerprint": "agenda:v1:abc...",
"instruction": "Report was recorded for validation. Continue concrete task work; do not treat this as a confirmed lease yet."
}
Stale response:
{
"ok": false,
"reason": "stale_fingerprint",
"currentAgendaFingerprint": "agenda:v1:def...",
"currentAgendaPreview": [
{
"taskRef": "#00d1e081",
"kind": "work",
"reason": "Owned task is in progress."
}
],
"instruction": "Read the current agenda and retry with the current fingerprint only after you understand it."
}
Caught-up rejected response:
{
"ok": false,
"reason": "caught_up_rejected_actionable_items_exist",
"currentAgendaFingerprint": "agenda:v1:def...",
"currentAgendaPreview": [
{
"taskRef": "#00d1e081",
"kind": "work",
"reason": "Owned task is in progress."
}
]
}
14. Prompt Guidance
Add minimal instructions to operational prompt surfaces after Phase 1 tool exists.
Do not over-prompt. The server enforces correctness.
Suggested text:
When you have reviewed your current assigned/review task agenda, call member_work_sync_report with the current agendaFingerprint.
Use still_working when you are continuing work on actionable tasks.
Use blocked only when the board shows a real blocker or clarification state.
Use caught_up only when your current actionable agenda is empty.
Do not use member_work_sync_report as a replacement for task_start, task_add_comment, task_complete, review tools, or visible messages.
Do not say:
Call this after every response.
That would create noise and tool spam.
15. Phase 2 Outbox Design
Phase 2 only after shadow metrics are acceptable.
15.1 Outbox Item
export interface MemberWorkSyncOutboxItem {
id: string;
teamName: string;
memberName: string;
agendaFingerprint: string;
payloadHash: string;
status:
| 'pending'
| 'claimed'
| 'delivered'
| 'superseded'
| 'failed_retryable'
| 'failed_terminal';
attemptGeneration: number;
claimedBy?: string;
claimedAt?: string;
deliveredMessageId?: string;
lastError?: string;
nextAttemptAt?: string;
createdAt: string;
updatedAt: string;
}
15.2 Idempotency Rules
Key:
member-work-sync:<teamName>:<memberName>:<agendaFingerprint>
Rules:
- same
id+ samepayloadHash-> return existing result; - same
id+ differentpayloadHash-> conflict, no send; - stale fingerprint before dispatch -> mark
superseded; - delivered item is terminal;
- retryable failures use backoff + jitter.
Dispatcher revalidation:
Before inserting an inbox nudge, dispatcher must re-read:
- current
phase2Readiness; - current roster membership;
- current agenda fingerprint;
- latest accepted report;
- busy state;
- recent watchdog cooldown.
If any no longer matches the outbox item, mark superseded. Do not send stale nudges.
Crash safety:
- Claim outbox item with
attemptGeneration. - Re-read item before marking delivered.
- Inbox insert uses deterministic message id from outbox id.
- If process crashes after inbox insert but before mark delivered, retry insert returns conflict/existing and dispatcher can mark delivered.
- If payload hash differs for same message id, mark terminal conflict and do not overwrite.
15.3 Nudge Payload
export interface MemberWorkSyncNudgePayload {
from: 'system';
to: string;
messageKind: 'member_work_sync_nudge';
source: 'member-work-sync';
actionMode: 'do';
text: string;
taskRefs: TaskRef[];
}
Suggested text:
Work sync check: you have current actionable work assigned. Review your agenda, continue the concrete task work, or report a real blocker with the task tools. Do not reply only with acknowledgement.
The nudge should be rare, deterministic, and tied to the fingerprint.
16. Interaction With Existing Systems
16.1 TaskStallMonitor
MemberWorkSync does not replace it.
| System | Question | Time horizon | Action |
|---|---|---|---|
| MemberWorkSync | Did member observe current agenda? | fast | status, later rare nudge |
| TaskStallMonitor | Is member making meaningful progress? | slow | task-specific remediation |
| Delivery ledger | Did runtime receive/respond to message? | per message | retry delivery |
| Spawn/liveness | Is runtime alive? | runtime | launch/restart status |
Rules:
- MemberWorkSync report is not progress.
- Watchdog may alert even with valid MemberWorkSync lease.
- MemberWorkSync should read watchdog cooldown in Phase 2 to avoid back-to-back nudges.
- Watchdog should not be disabled by MemberWorkSync.
16.2 OpenCode Delivery Ledger
MemberWorkSync does not mark OpenCode inbox rows read.
It may use pending delivery as a busy signal:
If OpenCode delivery to member is pending, suppress MemberWorkSync needs_sync/nudge until quiet window.
16.3 Teammate Tool Tracker
Tool activity is a trigger, not proof.
tool finish -> enqueue reconcile after quiet window
Do not infer caught_up from tool finish.
16.4 TeamChangeEvent
Phase 1 integration:
task-> enqueue relevant owner/reviewer;inbox-> maybe enqueue recipient;tool-activity finish-> enqueue member;member-spawn-> enqueue member after launch grace;config-> enqueue all active members.
Use a feature-owned queue, not direct sync work inside teamChangeEmitter.
Event routing details:
function routeTeamChangeToWorkSync(event: TeamChangeEvent): MemberWorkSyncRoute[] {
switch (event.type) {
case 'task':
return [{ teamName: event.teamName, taskId: event.taskId, scope: 'task_related_members' }];
case 'inbox':
case 'lead-message':
return [{ teamName: event.teamName, scope: 'message_recipient_if_member' }];
case 'tool-activity':
return [{ teamName: event.teamName, scope: 'tool_member_after_quiet_window' }];
case 'member-spawn':
return [{ teamName: event.teamName, scope: 'spawned_member_after_launch_grace' }];
case 'config':
return [{ teamName: event.teamName, scope: 'all_active_members' }];
default:
return [];
}
}
The router should live in member-work-sync main adapter code, not in src/main/index.ts. Main index should only call a narrow facade like memberWorkSyncService.noteTeamChange(event).
16.5 Watchdog Boundary Matrix
The two systems must stay separate.
| Situation | MemberWorkSync action | TaskStallMonitor action |
|---|---|---|
| member has new pending task and no report | NeedsSync, Phase 2 maybe rare agenda nudge |
no immediate semantic stall |
member reported still_working but no progress for long threshold |
valid lease until expiry | may still alert/remediate stall |
| member wrote weak "starting" comment | no fingerprint change | may classify weak start and later alert |
| watchdog recently nudged same member/task | Phase 2 suppresses work-sync nudge | watchdog owns that remediation |
| work-sync stale response returned to model | no immediate nudge | no change |
| delivery ledger pending | suppress as busy | delivery ledger owns retry |
Shared cooldown in Phase 2 should be advisory, not a hard dependency:
work-sync reads recent watchdog nudges to avoid duplicate nudges.
watchdog does not trust work-sync reports as progress.
This prevents a valid still_working lease from hiding a real task stall.
16.6 Current Codebase Fit Audit
This plan was checked against the current codebase shape, not only against the target design.
Existing codebase facts:
docs/FEATURE_ARCHITECTURE_STANDARD.mdalready requires a full feature slice for medium and large features.src/features/recent-projectsis the cleanest local template for contracts/core/main/preload/renderer boundaries.TeamTaskReaderalready normalizes persisted task files, deleted tasks, history events, comments, attachments, and review state.TeamKanbanManageralready owns kanban/reviewer overlay state.TeamMembersMetaStorealready owns active/removed member metadata.TeamConfigReaderalready has bounded team reads, worker fallback, launch summary projection, and soft-deleted team handling.TeamTaskStallSnapshotSourcealready contains useful provider/member resolution, but it is stall-monitor-specific and should not become the new feature core.TeamTaskStallMonitoralready has active-team registry, unref timers, startup/activation grace, and scan scheduling. MemberWorkSync should reuse the pattern, not the internals.TeamChangeEventis intentionally lightweight. Some events only providedetailstrings such asinboxes/<member>.json, so routing must handle incomplete event data safely.- MCP tools are registered through
mcp-server, but the authoritative tool catalog comes from the workspace packageagent-teams-controller.
Concrete integration decisions:
| Area | Safe fit | Avoid |
|---|---|---|
| Feature structure | src/features/member-work-sync full slice |
adding another large class under src/main/services/team |
| Agenda source | output adapter wraps TeamTaskReader, TeamKanbanManager, TeamMembersMetaStore, TeamConfigReader |
parsing task JSON again in feature core |
| Lifecycle source | output adapter composes config summary, bootstrap state, launch state, active runtime evidence | treating config existence as active team |
| Event routing | feature-owned queue receives TeamChangeEvent and re-reads state |
doing reconciliation inside teamChangeEmitter synchronously |
| MCP tool | add catalog entry plus tool registration plus controller type bridge | only adding mcp-server/src/tools/* and forgetting catalog registration |
| Watchdog | read cooldowns only in Phase 2, reports never count as progress | sharing mutable journal state with TeamTaskStallMonitor |
| Renderer | read-only adapter/view-model after main status API exists | putting agenda policy in React components |
16.7 Additional Bug Risks To Guard Explicitly
These are the places most likely to create subtle bugs during implementation.
Lightweight TeamChangeEvent Data
Current events often do not contain enough structured data to route to one exact member. For example, inbox events may carry only detail: "inboxes/bob.json" or detail: "sentMessages.json".
Rules:
- If member can be parsed confidently from
detail, enqueue only that member. - If member cannot be parsed, enqueue all active members with debounce and bounded concurrency.
- Never treat event
detailas authoritative task/member ownership. - Always re-read current tasks and members before writing status.
This keeps event routing level-triggered, not edge-triggered.
Current Reviewer Resolution
stallMonitor/reviewerResolution.ts can use historical actors because stall detection needs historical evidence. MemberWorkSync must resolve current action ownership only.
Rules:
- Prefer current kanban reviewer overlay when available and task is in
review. - Otherwise use current-cycle history only: latest
review_requestedorreview_startedin the current review cycle. - Do not use old
review_approvedactor as reviewer. - If current reviewer is ambiguous, skip review agenda item and add diagnostic.
This avoids incorrectly pinging a reviewer from a previous cycle.
Team Lifecycle Ambiguity
The codebase has multiple lifecycle signals: config, soft delete, bootstrap state, launch-state summary, active process registry, and OpenCode runtime metadata.
Rules:
- Deleted team -> inactive.
- Pending create without config -> inactive.
- Bootstrap/provisioning
cancelledor stopped runtime -> inactive. - Active process or current launch evidence -> active.
- Unknown lifecycle in Phase 1 -> do not nudge and write conservative diagnostic only.
Phase 1 must prefer false negatives over false positives.
Tool Catalog Registration
The MCP server registration loop is driven by AGENT_TEAMS_MCP_TOOL_GROUPS from agent-teams-controller.
Cut 2 must update all of these together:
agent-teams-controller/src/mcpToolCatalog.jsmcp-server/src/tools/index.tsmcp-server/src/tools/<work-sync-tools>.tsmcp-server/src/agent-teams-controller.d.ts- stdio/e2e tool list tests
If any of these are missing, the tool may compile but never appear to agents.
Recommended tool group:
workSync
teammateOperational: true
toolNames: ["member_work_sync_report", "member_work_sync_status"]
If adding a new group causes broad type churn, fallback is to temporarily place the tools in the existing process group, but only with a TODO and dedicated tests. Do not put report/status tools in task, because they are operational state, not board mutation.
Main-Service Dependency Direction
The feature application layer must not depend on TeamDataService, TeamProvisioningService, Electron, filesystem, or process APIs.
Allowed shape:
// core/application
class MemberWorkSyncReconciler {
constructor(
private readonly agendaSource: WorkAgendaSourcePort,
private readonly statusStore: MemberWorkSyncStatusStorePort,
private readonly lifecycle: TeamLifecyclePort,
private readonly clock: ClockPort
) {}
}
Main adapters may depend on existing services:
// main/adapters/output
class TeamServicesWorkAgendaSource implements WorkAgendaSourcePort {
constructor(
private readonly taskReader: TeamTaskReader,
private readonly kanbanManager: TeamKanbanManager,
private readonly membersMetaStore: TeamMembersMetaStore,
private readonly configReader: TeamConfigReader
) {}
}
This keeps DIP intact and makes core tests cheap.
Store And Locking Reuse
OpenCode has VersionedJsonStore, but it is under OpenCode-specific runtime code.
Rules:
- Do not deep-import OpenCode runtime store internals from
member-work-sync. - Extract a generic team-store helper only if it can be used without changing OpenCode behavior.
- Otherwise implement a tiny feature-local JSON store with atomic write, size cap, schema version, and per-team lock.
- All writes must be bounded and best-effort on deleted/missing team dirs.
This avoids coupling a generic feature to OpenCode runtime internals.
Task Comments And User-Visible Noise
member_work_sync_report must not create task comments, inbox messages, or user notifications.
Rules:
- Accepted report writes only work-sync status/report store.
- Rejected report writes only bounded diagnostics.
- Report note is diagnostic only and must be size-limited.
- Renderer Phase 1 shows status in details/dev diagnostics, not alarming warnings.
This prevents another "agent spammed the board" class of bugs.
Phase 2 Dispatch Safety
Nudges are intentionally out of Phase 1. When Phase 2 starts, dispatch must revalidate immediately before sending.
Required dispatch guard:
load outbox item
read current lifecycle
read current member active state
recompute agenda
compare fingerprint
check no accepted valid lease
check recent watchdog cooldown
check one-in-flight key
send nudge
mark dispatched
If any check fails, drop or supersede the item. Do not send stale nudges.
17. Store And Locking
17.1 Paths
<teamDir>/.member-work-sync/status.json
<teamDir>/.member-work-sync/reports.json
<teamDir>/.member-work-sync/report-intents.json # fallback when live validator is unavailable
<teamDir>/.member-work-sync/outbox.json # Phase 2
Phase 1 can keep reports embedded in status if simpler:
export interface MemberWorkSyncStatus {
teamName: string;
memberName: string;
agendaFingerprint: string;
agendaSummary: AgendaSummaryItem[];
latestAcceptedReport?: MemberWorkSyncReportRecord;
latestRejectedReport?: MemberWorkSyncReportRecord;
conditions: MemberWorkSyncCondition[];
metrics: MemberWorkSyncMetrics;
updatedAt: string;
}
report-intents.json is only a fallback queue for controller-side writes when the live app validator is unavailable. It is not an accepted lease store.
Rules:
- app consumes report intents and validates them with the normal
MemberWorkSyncReporter; - accepted intents become normal accepted reports in
status.jsonorreports.json; - rejected intents are retained only as bounded diagnostics;
- stale intents must not update leases;
- intent records must have stable ids so reprocessing after restart is idempotent.
Report intent shape:
export interface MemberWorkSyncReportIntent {
id: string;
teamName: string;
memberName: string;
rawInput: unknown;
receivedAt: string;
status: 'pending' | 'accepted' | 'rejected' | 'superseded';
validationReason?: string;
processedAt?: string;
}
Intent id:
member-work-sync-intent:<stableHash(teamName, memberName, normalizedRawInputWithoutReceivedAtOrReportedAt)>
Replay rules:
- process pending intents after app startup and after team load;
- re-read the current agenda before validation;
- if fingerprint is stale, mark
rejected, do not apply lease; - if identical intent was already accepted, mark duplicate as accepted with same report id;
- if member is now removed, mark
superseded; - keep a bounded intent history, recommended
100records per team or7days.
17.2 Locking Rule
Do not perform side effects while holding board locks.
Phase 1:
- read board state;
- compute agenda/fingerprint;
- write sync status under sync-store lock.
Phase 2:
- read board state;
- compute decision;
- write outbox intent;
- dispatcher later claims;
- dispatcher revalidates board state;
- dispatcher writes inbox idempotently.
Never:
hold board lock -> send message -> write sync store
That risks deadlock and long lock duration.
17.3 Lock Ordering
Lock ordering must be stable across the app:
task board read lock -> release
member-work-sync store lock -> release
outbox lock -> release
inbox/message write lock -> release
Never acquire a board write lock from inside a sync-store update callback.
Never acquire an inbox/message lock while holding sync-store lock.
If a future implementation needs multiple writes, use a durable intent:
- write intent under the feature store lock;
- release lock;
- dispatcher reads intent;
- dispatcher revalidates fresh state;
- dispatcher writes external side effect idempotently.
17.4 Corruption And Recovery
Store corruption should degrade the feature, not the team.
Rules:
- invalid JSON -> quarantine file and start with empty sync state;
- future schema -> quarantine or read-only diagnostic, no writes with unknown schema;
- lock timeout -> return previous known state if available;
- missing store -> initialize lazily;
- failed atomic rename -> keep temp file for diagnostics and do not delete existing good file;
- report intents with invalid raw payload -> mark rejected during replay.
Do not block task board operations because member-work-sync storage is broken.
18. Metrics
Phase 1 must emit developer diagnostics and store enough data to answer:
- is fingerprint stable?
- are agents sending stale reports?
- are reports useful?
- how often would nudges happen?
- are busy suppressions working?
Recommended counters:
export interface MemberWorkSyncMetrics {
reconcileCount: number;
fingerprintChangeCount: number;
staleReportCount: number;
invalidReportCount: number;
acceptedReportCount: number;
needsSyncCount: number;
suppressedBusyCount: number;
wouldNudgeCount: number;
lastReconcileAt?: string;
lastReportAt?: string;
}
For summary reports:
team: forge-labs-9
member: jack
state: needs_sync
fingerprint churn/hour: 0.4
stale report rate: 3%
would nudge count: 1
busy suppressions: 8
18.1 Phase 2 Entry Thresholds
Do not let nudges deliver until shadow metrics are stable and phase2Readiness=shadow_ready.
Recommended guardrails:
| Metric | Target before Phase 2 |
|---|---|
false NeedsSync samples |
0 high-confidence cases in sampled teams |
| fingerprint churn for stable member | less than 2 changes/hour |
| stale report rate | less than 15 percent |
| would-nudge rate | at most 2 per member/hour |
| busy suppression correctness | no known prompt during active tool/runtime turn |
| report intent replay errors | 0 lost accepted reports |
If a metric misses the target, keep phase2Readiness blocked/collecting and fix the specific source of noise. Do not compensate with a shorter lease or more nudges.
19. UI Plan
Phase 1 UI should be minimal.
Member card optional badge:
SyncedWorkingNeeds syncBlockedUnknown
Tooltip examples:
Synced with current work agenda at 12:03.
Needs sync: 2 actionable tasks changed since last member report.
Do not show alarming warning banners in Phase 1.
Details dialog can show:
- current fingerprint;
- agenda summary;
- latest report state;
- lease expiration;
- last rejected report reason;
- shadow
would nudgestatus.
20. Testing Plan
20.1 Domain Unit Tests
AgendaFingerprint.test.ts
- same agenda with different object key order -> same fingerprint;
- different actionable task -> different fingerprint;
- changed timestamp only -> same fingerprint;
- added weak comment -> same fingerprint;
- delivery retry marker -> same fingerprint;
- runtime liveness change -> same fingerprint;
- review assignment change -> different fingerprint;
- blocker change -> different fingerprint.
MemberWorkSyncReportValidator.test.ts
caught_upaccepted when agenda empty;caught_uprejected when agenda non-empty;- stale fingerprint rejected with current fingerprint;
still_workingaccepted for actionable task subset;still_workingrejected for foreign task id;blockedrejected without board evidence;- inactive member rejected.
- identity mismatch rejected and writes no accepted lease;
- invalid report token rejected;
user,system, and unsafe provider aliases rejected;- stopped/cancelled team rejected;
- duplicate identical report reuses stable report id;
- report for deleted task rejected;
blockedwith stale blocker evidence rejected.
SyncDecisionPolicy.test.ts
- empty agenda ->
caught_up; - non-empty agenda + valid lease ->
valid_lease; - non-empty agenda + busy ->
suppressed_busy; - non-empty agenda + no lease ->
needs_sync; - expired lease ->
needs_sync.
20.2 Application Tests
MemberWorkSyncReconciler.test.ts
- writes conditions for empty agenda;
- writes
NeedsSyncfor assigned task without report; - suppresses when busy source says active tool call;
- drops reconcile when lifecycle source says team is stopped;
- preserves previous report history;
- updates fingerprint when task owner changes;
- does not throw on missing team/member.
MemberWorkSyncReporter.test.ts
- accepts valid
still_working; - rejects stale report and stores rejected condition;
- rejects
caught_upwith actionable tasks; - rejects stopped team without writing accepted report;
- stores accepted lease expiration;
- normalizes member name.
- uses app
receivedAtfor lease expiry, not modelreportedAt; - does not persist pending intents as accepted reports;
- rejected identity mismatch does not write a pending intent.
20.3 Adapter Tests
TeamTaskAgendaSource.test.ts
- owned pending task maps to
work; - owner in-progress maps to
work; - review task maps to reviewer
review; - stale reviewer from an old review cycle does not become current action owner;
- old approved reviewer is not current reviewer;
- changes-requested task returns action owner to task owner;
- blocked task includes blocked evidence;
- needsClarification maps to clarification item;
- completed/deleted tasks excluded;
- non-member tasks excluded.
- uses
historyEvents, not nonexistenthistory; - does not use stall-monitor historical review actor as current reviewer;
JsonMemberWorkSyncStore.test.ts
- missing file returns empty state;
- invalid JSON quarantined;
- future schema quarantined or returns safe error;
- update is atomic under lock;
- concurrent updates do not lose records.
- extraction/reuse of versioned store preserves quarantine behavior.
MemberWorkSyncTeamChangeRouter.test.ts
- task event enqueues task-related members only;
- config event enqueues all active members;
- tool-activity event uses quiet-window trigger;
- unsupported event types are ignored;
- router does not read files or call application use cases directly.
MemberWorkSyncCrossRepoCompatibility.test.ts
- missing
member_work_sync_reportdoes not fail OpenCode readiness in Phase 1; - work-sync instructions are omitted when the tool is unavailable;
- tool available + app bridge unavailable returns
pending_validation; - no runtime flag is needed to require the tool in Phase 1.
20.4 Controller Tests
In agent-teams-controller:
member_work_sync_reportrequiresfrom;- rejects
user/system; - rejects provider id aliases such as
codexunless they are configured member names; - maps lead aliases only for the configured lead's own agenda;
- rejects lead alias reporting for a teammate;
- rejects session identity
jackwith payloadfrom: "bob"; - rejects removed member before writing a pending intent;
- rejects invalid/missing
reportTokenwithout creating an accepted lease; - rejects unknown member;
- returns structured stale fingerprint response;
- returns
pendingValidationinstead of accepted lease when app validator is unavailable; - pending validation intent replay does not update lease until app accepts;
- capability unavailable returns
capability_unavailableand does not write accepted reports; - exposes current fingerprint through the chosen read surface;
- does not write task comments or messages.
20.5 Integration Tests
- create realistic team with lead + members + tasks/reviews/blockers;
- run reconcile all members;
- verify statuses and fingerprints;
- submit reports;
- mutate task board;
- verify previous report becomes stale;
- no inbox messages are created in Phase 1.
- simulate app restart and replay report intents idempotently;
- simulate task/comment burst and verify one coalesced reconcile;
- verify watchdog alert still works when member has valid work-sync lease.
20.6 Regression Tests
Run at minimum:
pnpm vitest run test/main/services/team/stallMonitor/TeamTaskStallMonitor.test.ts
pnpm vitest run test/main/services/team/stallMonitor/TeamTaskStallPolicy.test.ts
pnpm vitest run test/main/services/team/TeamProvisioningServiceRelay.test.ts
pnpm typecheck --pretty false
git diff --check
Add feature tests once implemented:
pnpm vitest run test/features/member-work-sync
21. Rollout Plan
Phase 0: Design And Test Fixtures
🎯 10 🛡️ 10 🧠 3, 100-200 LOC.
- Add this plan.
- Add fixture team definitions for agenda/fingerprint tests.
- No runtime changes.
Phase 1: Shadow Control Plane
🎯 10 🛡️ 10 🧠 5, 850-1150 LOC.
Includes:
- feature skeleton;
- domain types;
- agenda builder;
- fingerprint;
- current agenda read surface through
task_briefingormember_work_sync_status; - report validator;
- decision policy;
- JSON status store;
- report intent fallback consumer;
- reconciler;
- reporter;
- app-side composition;
- controller MCP tool;
- tests.
Does not include:
- nudges;
- outbox;
- inbox writes;
- rate limiter.
Phase 1.5: Observability Review
🎯 9 🛡️ 10 🧠 3, 100-250 LOC.
Use real teams for 1-2 days.
Check:
- false
NeedsSync; - fingerprint churn;
- stale report rate;
- invalid caught-up attempts;
- how many nudges Phase 2 would send.
phase2Readiness.stateremainscollecting_shadow_datauntil the sample is large enough,blockedif rates are noisy, and only thenshadow_ready.
Exit criteria:
- fingerprint churn is low for stable tasks;
- no noisy churn from comments/timestamps;
NeedsSyncaligns with actual actionable work;- no user-visible behavior regression.
Phase 2: Durable Nudges
🎯 9 🛡️ 9 🧠 7, 700-1000 LOC.
Includes:
- outbox;
- dispatcher;
- idempotent inbox insert;
- payload hash;
- attempt generation fencing;
- jitter/backoff;
- per-member token bucket;
- shared cooldown with watchdog.
Implemented safety constraints:
- only queued reconciles plan outbox rows;
- read-only diagnostics never plan outbox rows;
- outbox planning requires
phase2Readiness.state === "shadow_ready"; - dispatch revalidates lifecycle, current status, current fingerprint, readiness, busy state, rate limit, and watchdog cooldown immediately before inbox insert;
- scheduled dispatch lists lifecycle-active teams only, not all stored teams;
- undelivered
supersededrows can be revived by a later fresh reconcile for the same fingerprint, whiledeliveredrows remain one-per-fingerprint.
Phase 3: Provider Accelerators
🎯 8 🛡️ 8 🧠 5, 300-600 LOC.
Includes:
- Claude Stop hook enqueue signal;
- OpenCode turn-settled enqueue signal;
- tool-finish quiet-window tuning;
- optional manual "sync now".
No accelerator is proof.
Current implementation:
- tool-finish enqueue and tool-activity busy suppression are implemented through
TeamChangeEventand the feature-owned busy signal; - Claude Stop hook settings are wired through the
member-work-syncfeature facade intoTeamProvisioningService, then merged into Anthropic launch settings; - Codex native and OpenCode turn-settled signals are wired through provider-specific runtime env/spool emitters, shared payload normalization,
RuntimeTurnSettledIngestor, and the feature-owned drain scheduler. OpenCode bridge env receives the spool root beforeOpenCodeBridgeCommandClientconstruction; - manual "sync now" remains optional because details/status reads are passive by design, and explicit manual nudges should reuse the existing outbox/dispatcher instead of bypassing readiness guards.
22. Runtime Defaults And No Feature Flags
Phase 1 shipped without feature flags.
Reason:
- Phase 1 has no nudges, no inbox writes, no task mutation, and no runtime restart behavior.
- Adding feature flags for passive status/report validation creates extra branches and makes failures harder to reason about.
- The safe boundary is architectural, not configurational: passive status/report validation stays independent from Phase 2 side effects.
Runtime defaults:
| Behavior | Default | Why |
|---|---|---|
| agenda/fingerprint/status computation | on | passive, deterministic, app-owned |
member_work_sync_status |
on | read-only diagnostics |
member_work_sync_report |
on | server-validated, no board mutation |
| pending report intent fallback | on only when identity is not terminally invalid | compatibility with old app/runtime boundaries |
| outbox planning | on only for queued reconciles and only when phase2Readiness=shadow_ready |
prevents status reads from causing side effects |
| scheduled nudge dispatch | on only for lifecycle-active teams | stopped teams must not claim or supersede pending nudges |
| inbox nudge writes | guarded by dispatcher revalidation | lifecycle, current fingerprint, readiness, busy signal, rate limit, and watchdog cooldown are checked immediately before write |
Do not add:
CLAUDE_TEAM_MEMBER_WORK_SYNC_ENABLEDCLAUDE_TEAM_MEMBER_WORK_SYNC_SHADOW_ONLY- any new member-work-sync nudge env or feature flag
If Phase 1 needs to be disabled during development, revert or patch the narrow composition wiring. Do not add a permanent product branch for a passive feature.
Phase 2 policy:
- Phase 2 is implemented as a separate outbox/dispatcher/scheduler path, not as hidden branching inside passive diagnostics.
- Phase 2 does not bypass shadow readiness. If metrics are noisy, the planner returns
phase2_not_ready. - Phase 2 uses constants/configuration for rate limits and timing, but not a broad "new vs legacy" branch.
Phase 2 runtime constants can be normal typed defaults, not feature gates:
const MEMBER_WORK_SYNC_QUIET_WINDOW_MS = 90_000;
const MEMBER_WORK_SYNC_STILL_WORKING_LEASE_MS = 10 * 60_000;
const MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR = 2;
No config/env kill switch is part of the design. Production nudge safety must come from dispatcher guards and typed runtime defaults, without disabling agenda/status/report validation.
23. Security And Abuse Controls
Threats:
- model calls
caught_upincorrectly; - model uses
from: user; - model uses provider id
codexas a fake member; - model reports for another teammate;
- prompt injection asks model to suppress work sync;
- stale report hides new task;
- copied/stale report token suppresses a new agenda;
- malicious payload floods reports.
Controls:
- controller validates
from, reserved actors, unsafe provider aliases, and payload size; - app validates active member, current fingerprint, report token, and agenda state;
- accepted lease requires trusted runtime identity or valid report token;
- server rejects
caught_upwith non-empty agenda; - identity failures write no pending intent;
- report size limits;
- note length limit;
- taskIds count limit;
- rejected reports are stored as diagnostics but do not update lease;
- nudges are rate limited in Phase 2.
Recommended limits:
note max length: 1000 chars
taskIds max count: 20
blockerCommentId max length: 128 chars
agenda preview max items in tool response: 10
report intents max retained per team: 100
report token validity: current fingerprint plus 15 minutes
status history max retained per member: 20
Sanitize output:
- never include raw hidden prompt text in a rejected response;
- never echo huge notes back to the model;
- never include other members' full agendas in a non-lead response;
- never include agenda preview when rejection reason is identity-related;
- include task refs, kind, and short reason only.
24. DRY And Reuse
Reuse:
- member provider resolution logic from stall monitor where possible;
- versioned store pattern from OpenCode runtime stores;
- existing file lock/atomic write utilities;
- existing task reader and member metadata sources;
- existing author validation in controller runtime helpers;
- existing task reference DTOs.
Do not duplicate:
- separate reviewer resolution if existing
reviewerResolutioncan be extracted cleanly; - separate provider normalization if shared util exists;
- raw file parsing if
TeamTaskReaderalready provides validated tasks.
Potential extraction:
src/main/services/team/memberResolution/
src/main/services/team/reviewerResolution/
src/main/services/team/versionedStore/
Only extract if it reduces duplication without forcing broad migration.
25. Open Questions
-
Should Phase 1 expose UI badges immediately, or keep diagnostics developer-only?
- Recommended: small details-only surface, no warning badge yet.
-
Should
member_work_sync_reportlive in task tool group or a new sync group?- Recommended: new sync/report tool group if catalog supports it; otherwise task-adjacent operational tool.
-
Should reports be allowed from lead?
- Recommended: only for lead's own agenda, not on behalf of teammates.
-
Should
blockedrequireneedsClarificationor accept dependency blockers?- Recommended: accept both, but require evidence.
-
Should
still_workingrequire explicit taskIds?- Recommended: optional. If omitted, it covers all current agenda items. If provided, validate subset.
-
Should the controller duplicate agenda validation for instant report rejection?
- Recommended: no. Keep full policy in
claude_team. Controller performs identity/schema/size validation and uses live app validation when available. If not available, it records a pending report intent that the app validates later. This avoids policy drift between JS controller code and TypeScript feature core.
- Recommended: no. Keep full policy in
-
Should Phase 1 create nudges for obvious no-report states?
- Recommended: no. Even obvious states can be noisy until fingerprint churn and busy suppression are proven on real teams.
-
Should
caught_upbe accepted when agenda source is unavailable?- Recommended: no. Unknown state is not caught-up.
-
Should lead be allowed to report for teammates?
- Recommended: no. Lead can inspect status, but reports are member-scoped and should represent the reporting actor.
-
Should report notes become task comments?
- Recommended: no. Report notes are diagnostics only. Agents should use task tools for durable task communication.
-
Should
reportTokenbe treated as authentication?- Recommended: no. It is an accidental-cross-member guard. Real authority still comes from app validation and trusted runtime context when available.
26. Acceptance Criteria
Phase 1 is complete when:
- feature follows
docs/FEATURE_ARCHITECTURE_STANDARD.md; - domain code has no filesystem/Electron/main imports;
- agents can read the current
agendaFingerprintfrom a documented read surface; - agents can read a member-scoped
reportTokenfrom the same read surface; - report validation rejects stale/caught-up-invalid cases;
- report validation rejects identity mismatch, unsafe aliases, and invalid report tokens;
- report validation and reconciler respect stopped/cancelled team lifecycle;
- report fallback cannot claim an accepted lease without app-side validation;
- reconcile creates stable conditions for realistic task boards;
- status store is versioned and safe under invalid JSON;
- no inbox messages are written by member-work-sync;
- existing watchdog tests still pass;
- typecheck passes;
- docs include rollout/Phase 2 requirements.
Phase 2 may start only when:
- shadow metrics show acceptable fingerprint churn;
- no high false-positive
NeedsSyncpattern is found; - idempotent inbox insert design is implemented and tested;
- outbox dispatcher has stale revalidation tests.
Phase 2 must not start if any of these are true:
- report validation can accept leases without app-side agenda validation;
- report validation can accept leases with claimed
fromonly and no trusted identity/report token; - queue can run more than one reconcile for the same member concurrently;
- watchdog cooldown integration is untested;
- outbox dispatcher can send without
shadow_ready; - outbox dispatcher can send for a stopped/cancelled team;
- pending intent replay can turn stale reports into accepted leases;
- fingerprint transition diagnostics are missing;
- current agenda read surface cannot produce the same fingerprint as reconciler.
Cross-repo acceptance:
- OpenCode launch/readiness remains green when work-sync tool is absent and require-tool gate is false.
- OpenCode launch/readiness fails with a clear message when require-tool gate is true and tool is absent.
task_briefingand work-sync status use the sameAgendaFingerprintService.member_work_sync_reportdoes not become a substitute fortask_start,task_add_comment,task_complete, or review tools.- No report response writes task comments, messages, or task status.
27. Implementation Order
Recommended commit sequence:
docs: add member work sync control plane plantest(team): cover current review cycle action ownershiprefactor(team): extract current review cycle resolverrefactor(team): extract reusable versioned json storefeat(member-work-sync): add domain agenda and fingerprint modelfeat(member-work-sync): add status store and shadow reconcilerfeat(member-work-sync): expose current agenda fingerprint read surfacefeat(member-work-sync): add report token adapter and validationfeat(member-work-sync): add report validation use casefeat(agent-teams): add member work sync report tooltest(member-work-sync): add shadow control plane coveragefeat(member-work-sync): wire shadow reconciler to team changes
Keep Phase 2 in separate commits/PR.
27.0 Cut 0: Fit Check Before Code
Goal:
Prove the feature can sit on existing service boundaries before creating new runtime behavior.
Estimate: 🎯 10 🛡️ 10 🧠 3, 80-180 LOC.
Step order:
-
Add test fixtures for agenda inputs using existing
TeamTaskReaderoutput shape.- Include pending owned work, active work, review, needs-fix, deleted tasks, removed members, and stale reviewer history.
- Do not test raw JSON parser details here.
-
Add current-review-cycle resolver tests.
- Prove old
review_approvedactor does not become current reviewer. - Prove kanban reviewer overlay wins when task is actively in review.
- Prove ambiguity returns
nullplus diagnostic.
- Prove old
-
Add lifecycle adapter contract tests with fakes.
- deleted team -> inactive;
- pending create -> inactive;
- cancelled bootstrap -> inactive;
- active runtime evidence -> active;
- unknown -> shadow diagnostic only.
-
Add MCP catalog registration checklist test plan.
- No implementation yet.
- Confirm adding a new operational group requires
agent-teams-controllercatalog andmcp-serverregistration changes.
Cut 0 stop criteria:
- Existing task/review types cannot represent a planned agenda item without guessing.
- Lifecycle cannot be resolved conservatively from existing sources.
- MCP catalog cannot support a new operational group without broad unrelated changes.
27.1 Cut 1: Shadow Core And Status
Goal:
Compute member work-sync status from authoritative team state and persist diagnostics.
No MCP report tool. No nudges. No renderer warning UI.
Estimate: 🎯 9 🛡️ 10 🧠 5, 450-750 LOC.
Step order:
-
Create feature shell under
src/features/member-work-sync.- Add
contracts,core/domain,core/application,main/adapters, andmain/index.ts. - Export only through feature entrypoints.
- Do not import Electron, filesystem, or
TeamDataServiceinsidecore.
- Add
-
Extract current-cycle reviewer resolver.
- Create a shared resolver near team services or inside member-work-sync adapter layer.
- Keep two separate functions:
resolveCurrentActionReviewer()for current actionable work.resolveHistoricalReviewActor()for old stall/log attribution if needed.
- Add tests before replacing existing call sites.
- Stop if replacing stall-monitor behavior changes existing tests.
-
Extract or wrap versioned JSON store.
- Prefer a generic helper under
src/main/services/team/versionedJsonStore. - Do not deep-import OpenCode runtime store internals from the new feature.
- Preserve quarantine and atomic write behavior.
- Add store tests before using it from member-work-sync.
- Prefer a generic helper under
-
Implement domain types and agenda builder.
- Input is normalized task/member snapshots from ports, not raw files.
- Include only documented actionable item kinds:
work,review,blocked,clarification. - If ownership/reviewer is ambiguous, skip the item and emit diagnostic.
- Do not guess owner from comments or log activity.
-
Implement fingerprint service.
- Domain builds canonical fingerprint payload.
- Application receives
HashPort; Node crypto lives in infrastructure. - Add transition diagnostics but keep them out of the hash.
- Verify timestamp/comment/runtime-only changes do not churn fingerprints.
-
Implement decision policy.
- Empty agenda -> caught up.
- Non-empty agenda + valid lease -> valid lease.
- Non-empty agenda + busy -> suppressed busy.
- Non-empty agenda + no valid lease -> needs sync.
- In Cut 1 there are no accepted agent reports yet, so valid lease only appears in direct unit tests or seeded fixtures.
-
Implement
TeamTaskAgendaSource.- Adapter reads through existing validated task/member services.
- Adapter resolves team lifecycle before producing agenda.
- Adapter returns no agenda for stopped/cancelled/deleted teams.
- Adapter must not mutate tasks, messages, or runtime state.
-
Implement
JsonMemberWorkSyncStatusStore.- Path:
~/.claude/teams/<teamName>/.member-work-sync/status.json. - Store bounded member status and diagnostics only.
- No report intents or outbox in Cut 1 unless required by tests as empty schema.
- Path:
-
Implement
MemberWorkSyncReconciler.- Reconciles one
(teamName, memberName)at a time. - Checks lifecycle first, then member active, then agenda.
- Writes status only through
MemberWorkSyncStatusStorePort. - Does not send any message or call runtime delivery.
- Reconciles one
-
Add shadow trigger wiring without an env gate.
- Default shadow status is on, while read-only status/diagnostics stay passive.
- Use quiet-window queue and bounded concurrency.
- Wire broad team/task change events only after domain tests are green.
- Drop queued entries when team/member is removed or stopped.
-
Add read-only diagnostics surface for tests.
- Main-process use case can return current status for a team/member.
- Renderer can remain untouched in Cut 1.
Cut 1 tests:
pnpm vitest run test/features/member-work-sync
pnpm vitest run test/main/services/team/stallMonitor/TeamTaskStallPolicy.test.ts test/main/services/team/stallMonitor/TeamTaskStallMonitor.test.ts
pnpm typecheck --pretty false
git diff --check
Cut 1 stop criteria:
- Any false-positive
NeedsSyncis found for completed/deleted/non-owned tasks. - Fingerprint changes because only timestamps/comments/runtime liveness changed.
- Reconciler mutates task board, messages, inbox, or runtime.
- Stopped team still writes fresh status as active.
27.2 Cut 2: Report Tool, Token, And App Validation
Goal:
Allow agents to report current work-sync state, but only app-side validation can accept a lease.
The orchestrator remains a thin MCP adapter.
Estimate: 🎯 8 🛡️ 9 🧠 6, 350-600 LOC.
Step order:
-
Add
ReportTokenPort.- Infrastructure adapter signs/verifies token using app-owned secret/nonce.
- Token binds
(teamName, memberName, agendaFingerprint). - Token does not affect fingerprint.
- Token expiry uses app clock.
-
Extend current agenda read surface.
- Prefer
task_briefing.workSync. - Include compact agenda preview,
agendaFingerprint, state, andreportToken. - Omit report instructions when tool or app validation capability is unavailable.
- Keep old
task_briefingfields unchanged.
- Prefer
-
Implement report validator.
- Pure domain validation first.
- Reject stale fingerprint, invalid caught-up, foreign task ids, invalid blocker evidence.
- Reject identity mismatch, unsafe provider aliases, missing/invalid report token when no trusted runtime context exists.
-
Implement reporter use case.
- Checks lifecycle first.
- Checks active member second.
- Rebuilds fresh agenda and fingerprint.
- Verifies token.
- Stores accepted or rejected report diagnostics.
- Never writes tasks/comments/messages.
-
Add app validation bridge contract.
claude_teamexposes a narrow main-process/application port.- The MCP/controller boundary calls that port when available.
- Result is structured and does not leak internal task data.
- Do not create a second validation implementation in
agent-teams-controller.
-
Add MCP/controller tool registration.
- Tool name:
member_work_sync_report. - Update
agent-teams-controller/src/mcpToolCatalog.jsso the tool is in a teammate-operational group. - Update
mcp-server/src/tools/index.tsand add a dedicated work-sync tools module. - Update
mcp-server/src/agent-teams-controller.d.ts. - Controller/MCP layer validates schema, size, reserved actors, obvious unsafe aliases.
- Controller/MCP layer attaches trusted runtime/session context when available.
- Controller/MCP layer forwards to app validation port.
- If app validation port is unavailable, write pending intent only if identity is not terminally invalid.
- Never return accepted lease unless app returned accepted lease.
- Tool name:
-
Add pending intent replay.
- Replay through the same reporter use case.
- Stale or identity-invalid intents become rejected diagnostics.
- Pending intent replay cannot update accepted lease directly.
-
Update OpenCode readiness compatibility.
- Missing
member_work_sync_reportmust not fail launch by default. - Require-tool gate stays false.
- Add compatibility tests for old orchestrator/new app and new orchestrator/old app paths.
- Missing
Cut 2 tests:
pnpm vitest run test/features/member-work-sync
pnpm vitest run test/main/services/team/TeamProvisioningServiceRelay.test.ts
pnpm --filter agent-teams-controller test
pnpm --filter agent-teams-mcp test
pnpm --filter agent-teams-mcp test:e2e
cd /Users/belief/dev/projects/claude/agent_teams_orchestrator && bun test src/services/opencode/OpenCodeBridgeCommandHandler.test.ts
cd /Users/belief/dev/projects/claude/claude_team && pnpm typecheck --pretty false
git diff --check
Cut 2 stop criteria:
- Orchestrator accepts a lease without app validation.
fromalone can create a valid lease.user,system, provider id, removed member, or session mismatch writes a pending intent.- Invalid token creates accepted or valid lease state.
- Missing new tool blocks OpenCode launch while require-tool gate is false.
27.3 Cut 3: Minimal UI And Developer Diagnostics
Goal:
Expose work-sync state without creating noisy user-facing warnings.
Estimate: 🎯 8 🛡️ 8 🧠 5, 180-320 LOC.
Step order:
-
Add read-only API contract.
- Return status for current team/member.
- Do not expose raw notes, raw comments, hidden prompt text, or other members' full agendas.
-
Add renderer adapter/view model.
- Convert domain status to compact labels:
Synced,Working,Needs sync,Blocked,Unknown. - Keep status neutral. No warning banner in Phase 1.
- Convert domain status to compact labels:
-
Add details surface.
- Show fingerprint, state, latest report, lease expiry, and short agenda preview.
- Hide developer-only diagnostics behind existing debug/dev patterns if available.
-
Add tests for UI mapping.
- Snapshot only small view-model outputs, not full prompts or full task data.
- Verify identity rejection does not leak another member's agenda.
Cut 3 tests:
pnpm vitest run test/features/member-work-sync test/renderer
pnpm typecheck --pretty false
git diff --check
Cut 3 stop criteria:
- UI shows alarming warning banners in Phase 1.
- UI implies the agent is broken when state is only shadow
NeedsSync. - UI exposes another member's full agenda or raw report notes.
- Renderer contains domain policy logic instead of view-model mapping.
27.4 Phase 2: Nudges Later, Separate Work
Do not rely on Phase 2 delivery until shadow metrics prove low noise.
Phase 2 sequence:
- Add outbox schema and idempotency key.
- Add dispatcher active by default behind internal guards.
- Add stale revalidation before dispatch.
- Add watchdog cooldown integration.
- Add one-in-flight per
(teamName, memberName, fingerprint). - Add max nudges per member/hour.
- Enable only for sampled/dev teams first.
Phase 2 tests must prove no duplicate nudges, no stopped-team nudges, no stale-fingerprint nudges, and no watchdog duplicate ping.
28. Implementation Readiness Lock
The plan is ready for implementation only if Phase 1 stays intentionally narrow.
Phase 1 includes:
- domain agenda builder;
- current-cycle reviewer resolver extraction;
- agenda fingerprint service;
- report token port and adapter;
- report validator and reporter;
- shadow reconciler;
- versioned status store;
- current agenda read surface;
- MCP report tool as optional capability;
- diagnostics and tests.
Phase 1 excludes:
- inbox nudges;
- runtime restarts;
- task status mutation;
- task comments/messages from report notes;
- watchdog semantic changes;
- renderer warning banners;
- making the MCP report tool required for OpenCode launch.
Non-negotiable implementation rules:
fromis never authority by itself.- Stopped/cancelled teams cannot accept reports.
- Accepted leases require app-side agenda validation.
- Pending intents can never become accepted without replay through app validation.
- Work-sync reports do not count as task progress for watchdog.
- The same source builds
task_briefing.workSync, reconciler agenda, and report validation agenda. - Any uncertain agenda ownership case returns no actionable item plus diagnostics, not a guessed nudge.
If any of these rules becomes hard to satisfy during implementation, stop and split the work into a smaller refactor before continuing.
Recommended first coding cut:
🎯 9 🛡️ 10 🧠 5, 450-750 LOC.
Implement only domain + adapters needed to compute and persist shadow statuses. Do not add the MCP tool until agenda/fingerprint/status tests are green. This creates a stable base and avoids debugging protocol, storage, and agenda policy at the same time.
Second coding cut:
🎯 8 🛡️ 9 🧠 6, 350-600 LOC.
Add member_work_sync_report, report token, app validation bridge, pending-intent fallback, and cross-repo compatibility tests.
Third coding cut:
🎯 8 🛡️ 8 🧠 5, 180-320 LOC.
Add minimal renderer/details surface only after main-process state is proven. No alarming UI by default.
29. Final Recommendation
Build member-work-sync as a clean feature, starting with Phase 1 shadow mode.
Do not implement nudges in the first pass.
The most important engineering quality bar:
Every decision is based on fresh authoritative board state.
Every side effect is durable and idempotent.
Every agent report is server-validated.
Every future nudge is rare, bounded, and fingerprint-scoped.
That gives us a stronger architecture than a simple nudge queue and avoids the main failure modes: spam, stale flags, duplicate messages, false completion, and watchdog conflicts.