feat: add member work sync control plane

This commit is contained in:
777genius 2026-04-29 13:06:58 +03:00
parent d99cd98153
commit c39167ece7
44 changed files with 2299 additions and 3 deletions

View file

@ -7,6 +7,7 @@ const processes = require('./internal/processes.js');
const maintenance = require('./internal/maintenance.js');
const crossTeam = require('./internal/crossTeam.js');
const runtime = require('./internal/runtime.js');
const workSync = require('./internal/workSync.js');
const agentBlocks = require('./internal/agentBlocks.js');
function bindModule(context, moduleApi) {
@ -31,6 +32,7 @@ function createController(options) {
maintenance: bindModule(context, maintenance),
crossTeam: bindModule(context, crossTeam),
runtime: bindModule(context, runtime),
workSync: bindModule(context, workSync),
};
}
@ -51,4 +53,5 @@ module.exports = {
maintenance,
crossTeam,
runtime,
workSync,
};

View file

@ -781,6 +781,14 @@ function buildMemberTaskProtocol(teamName, messagingProtocol = createMemberMessa
- If you are the one doing the implementation/fixes and the owner is missing or someone else, run task_set_owner to yourself immediately before task_start.
- Then run task_start only when you truly begin.
- If you complete fixes for a needsFix task, mark it completed and then send it back through review_request when ready for another review pass.
15. MEMBER WORK SYNC REPORTING:
- member_work_sync_status and member_work_sync_report are only for reporting whether you have seen the current actionable-work agenda. They do NOT start, complete, approve, or comment on tasks.
- Never use member_work_sync_report instead of task_start, task_complete, review_approve, review_request_changes, task_set_clarification, or task_add_comment.
- When you are about to stop, wait, or go idle because you believe your current work queue is handled, first call member_work_sync_status for yourself.
- If the returned agenda has actionable items and you are actively continuing work on them, call member_work_sync_report with state "still_working" and that exact agendaFingerprint.
- If you are blocked, report "blocked" only when the board already has blocker or clarification evidence for the listed task.
- If the returned agenda is empty, report "caught_up" with that exact agendaFingerprint.
- Do not report more than once for the same agendaFingerprint unless your state changed.
Failure to follow this protocol means the task board will show incorrect status.`);
}

View file

@ -0,0 +1,142 @@
const fs = require('fs');
const path = require('path');
const runtimeHelpers = require('./runtimeHelpers.js');
const DEFAULT_WAIT_TIMEOUT_MS = 10000;
const MIN_WAIT_TIMEOUT_MS = 1000;
const MAX_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
const TEAM_CONTROL_API_STATE_FILE = 'team-control-api.json';
function normalizeTimeoutMs(rawValue) {
const numeric =
typeof rawValue === 'number' && Number.isFinite(rawValue)
? Math.floor(rawValue)
: DEFAULT_WAIT_TIMEOUT_MS;
return Math.min(MAX_WAIT_TIMEOUT_MS, Math.max(MIN_WAIT_TIMEOUT_MS, numeric));
}
function readControlApiState(context) {
const filePath = path.join(context.claudeDir, TEAM_CONTROL_API_STATE_FILE);
try {
const raw = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(raw);
return typeof parsed?.baseUrl === 'string' && parsed.baseUrl.trim()
? parsed.baseUrl.trim()
: '';
} catch {
return '';
}
}
function resolveControlBaseUrls(context, flags = {}) {
const explicit =
(typeof flags.controlUrl === 'string' && flags.controlUrl.trim()) ||
(typeof flags['control-url'] === 'string' && flags['control-url'].trim()) ||
'';
const stateFileUrl = readControlApiState(context);
const envUrl =
typeof process.env.CLAUDE_TEAM_CONTROL_URL === 'string'
? process.env.CLAUDE_TEAM_CONTROL_URL.trim()
: '';
const candidates = [...new Set([explicit, stateFileUrl, envUrl].filter(Boolean))];
if (candidates.length === 0) {
throw new Error(
'Team control API is unavailable. Start the desktop app team runtime first so it can validate member work sync reports.'
);
}
return candidates;
}
async function requestJson(baseUrl, pathname, options = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), normalizeTimeoutMs(options.timeoutMs));
try {
const response = await fetch(`${baseUrl}${pathname}`, {
method: options.method || 'GET',
headers: {
accept: 'application/json',
...(options.body ? { 'content-type': 'application/json' } : {}),
},
...(options.body ? { body: JSON.stringify(options.body) } : {}),
signal: controller.signal,
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const detail =
payload && typeof payload.error === 'string' && payload.error.trim()
? payload.error.trim()
: `${response.status} ${response.statusText}`.trim();
throw new Error(detail || 'Team control API request failed');
}
return payload;
} finally {
clearTimeout(timer);
}
}
async function requestJsonWithFallback(baseUrls, pathname, options = {}) {
let lastError = null;
for (const baseUrl of baseUrls) {
try {
return await requestJson(baseUrl, pathname, options);
} catch (error) {
lastError = error;
}
}
throw lastError || new Error('Team control API request failed');
}
function compactReportBody(context, memberName, flags = {}) {
return {
teamName: context.teamName,
memberName,
state: flags.state,
agendaFingerprint: flags.agendaFingerprint || flags['agenda-fingerprint'],
...(Array.isArray(flags.taskIds) ? { taskIds: flags.taskIds } : {}),
...(Array.isArray(flags['task-ids']) ? { taskIds: flags['task-ids'] } : {}),
...(typeof flags.note === 'string' && flags.note.trim() ? { note: flags.note.trim() } : {}),
...(typeof flags.reportedAt === 'string' && flags.reportedAt.trim()
? { reportedAt: flags.reportedAt.trim() }
: {}),
...(typeof flags.leaseTtlMs === 'number' ? { leaseTtlMs: flags.leaseTtlMs } : {}),
};
}
async function memberWorkSyncStatus(context, flags = {}) {
const memberName = runtimeHelpers.assertExplicitTeamMemberName(
context.paths,
flags.memberName || flags.member || flags.from,
'member work sync status member'
);
const baseUrls = resolveControlBaseUrls(context, flags);
return requestJsonWithFallback(
baseUrls,
`/api/teams/${encodeURIComponent(context.teamName)}/member-work-sync/${encodeURIComponent(
memberName
)}`,
{ timeoutMs: normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms']) }
);
}
async function memberWorkSyncReport(context, flags = {}) {
const memberName = runtimeHelpers.assertExplicitTeamMemberName(
context.paths,
flags.memberName || flags.member || flags.from,
'member work sync report member'
);
const baseUrls = resolveControlBaseUrls(context, flags);
return requestJsonWithFallback(
baseUrls,
`/api/teams/${encodeURIComponent(context.teamName)}/member-work-sync/report`,
{
method: 'POST',
body: compactReportBody(context, memberName, flags),
timeoutMs: normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms']),
}
);
}
module.exports = {
memberWorkSyncStatus,
memberWorkSyncReport,
};

View file

@ -63,6 +63,8 @@ const AGENT_TEAMS_RUNTIME_TOOL_NAMES = [
'runtime_heartbeat',
];
const AGENT_TEAMS_WORK_SYNC_TOOL_NAMES = ['member_work_sync_status', 'member_work_sync_report'];
const AGENT_TEAMS_MCP_TOOL_GROUPS = [
{
id: 'team',
@ -104,6 +106,11 @@ const AGENT_TEAMS_MCP_TOOL_GROUPS = [
teammateOperational: false,
toolNames: AGENT_TEAMS_RUNTIME_TOOL_NAMES,
},
{
id: 'workSync',
teammateOperational: true,
toolNames: AGENT_TEAMS_WORK_SYNC_TOOL_NAMES,
},
{
id: 'crossTeam',
teammateOperational: true,
@ -141,6 +148,7 @@ module.exports = {
AGENT_TEAMS_PROCESS_TOOL_NAMES,
AGENT_TEAMS_KANBAN_TOOL_NAMES,
AGENT_TEAMS_RUNTIME_TOOL_NAMES,
AGENT_TEAMS_WORK_SYNC_TOOL_NAMES,
AGENT_TEAMS_MCP_TOOL_GROUPS,
AGENT_TEAMS_REGISTERED_TOOL_NAMES,
AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES,

View file

@ -148,6 +148,7 @@ describe('agent-teams-controller API', () => {
expect(briefing).toContain('Task briefing for bob:');
expect(briefing).toContain('Use task_briefing as your primary working queue whenever you need to see assigned work.');
expect(briefing).toContain('Use task_list only to search/browse inventory rows, not as your working queue.');
expect(briefing).toContain('member_work_sync_status and member_work_sync_report');
expect(briefing).toContain(
'Awareness items are watch-only context and do not authorize you to start work unless the lead reroutes the task or you become the actionOwner.'
);
@ -2250,6 +2251,80 @@ describe('agent-teams-controller API', () => {
}
});
it('forwards member work sync status and reports to the app validator', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const calls = [];
const server = await startControlServer(async ({ method, url, body }) => {
calls.push({ method, url, body });
if (method === 'GET' && url === '/api/teams/my-team/member-work-sync/bob') {
return {
body: {
teamName: 'my-team',
memberName: 'bob',
state: 'needs_sync',
agenda: {
teamName: 'my-team',
memberName: 'bob',
generatedAt: '2026-04-29T00:00:00.000Z',
fingerprint: 'agenda:v1:abc',
items: [],
diagnostics: [],
},
evaluatedAt: '2026-04-29T00:00:00.000Z',
diagnostics: ['no_current_report'],
},
};
}
if (method === 'POST' && url === '/api/teams/my-team/member-work-sync/report') {
return { body: { accepted: true, code: 'accepted', status: body } };
}
return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } };
});
try {
const status = await controller.workSync.memberWorkSyncStatus({
controlUrl: server.baseUrl,
from: 'bob',
});
const report = await controller.workSync.memberWorkSyncReport({
controlUrl: server.baseUrl,
memberName: 'bob',
state: 'still_working',
agendaFingerprint: 'agenda:v1:abc',
taskIds: ['task-1'],
note: 'Continuing work',
leaseTtlMs: 120000,
});
expect(status.state).toBe('needs_sync');
expect(report.accepted).toBe(true);
expect(calls).toEqual([
{
method: 'GET',
url: '/api/teams/my-team/member-work-sync/bob',
body: undefined,
},
{
method: 'POST',
url: '/api/teams/my-team/member-work-sync/report',
body: {
teamName: 'my-team',
memberName: 'bob',
state: 'still_working',
agendaFingerprint: 'agenda:v1:abc',
taskIds: ['task-1'],
note: 'Continuing work',
leaseTtlMs: 120000,
},
},
]);
} finally {
await server.close();
}
});
it('prefers the published control endpoint over a stale env URL', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });

View file

@ -102,6 +102,11 @@ declare module 'agent-teams-controller' {
runtimeHeartbeat(flags: Record<string, unknown>): Promise<unknown>;
}
export interface ControllerWorkSyncApi {
memberWorkSyncStatus(flags: Record<string, unknown>): Promise<unknown>;
memberWorkSyncReport(flags: Record<string, unknown>): Promise<unknown>;
}
export interface AgentTeamsController {
tasks: ControllerTaskApi;
kanban: ControllerKanbanApi;
@ -111,6 +116,7 @@ declare module 'agent-teams-controller' {
maintenance: ControllerMaintenanceApi;
crossTeam: ControllerCrossTeamApi;
runtime: ControllerRuntimeApi;
workSync: ControllerWorkSyncApi;
}
export function createController(options: ControllerContextOptions): AgentTeamsController;
@ -143,6 +149,7 @@ declare module 'agent-teams-controller' {
| 'message'
| 'process'
| 'runtime'
| 'workSync'
| 'crossTeam';
export interface AgentTeamsMcpToolGroup {
@ -159,6 +166,7 @@ declare module 'agent-teams-controller' {
export const AGENT_TEAMS_PROCESS_TOOL_NAMES: readonly string[];
export const AGENT_TEAMS_KANBAN_TOOL_NAMES: readonly string[];
export const AGENT_TEAMS_RUNTIME_TOOL_NAMES: readonly string[];
export const AGENT_TEAMS_WORK_SYNC_TOOL_NAMES: readonly string[];
export const AGENT_TEAMS_MCP_TOOL_GROUPS: readonly AgentTeamsMcpToolGroup[];
export const AGENT_TEAMS_REGISTERED_TOOL_NAMES: readonly string[];
export const AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES: readonly string[];

View file

@ -10,10 +10,17 @@ const { createController } = controllerModule;
const FORCED_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR';
type WorkSyncCapableController = ReturnType<typeof createController> & {
workSync: {
memberWorkSyncStatus(flags: Record<string, unknown>): Promise<unknown>;
memberWorkSyncReport(flags: Record<string, unknown>): Promise<unknown>;
};
};
/** Re-export agentBlocks utilities (stripAgentBlocks, wrapAgentBlock, etc.) */
export const agentBlocks = controllerModule.agentBlocks;
export function getController(teamName: string, claudeDir?: string) {
export function getController(teamName: string, claudeDir?: string): WorkSyncCapableController {
const forcedClaudeDir = process.env[FORCED_CLAUDE_DIR_ENV]?.trim();
let resolvedClaudeDir = claudeDir;
if (forcedClaudeDir) {
@ -24,5 +31,5 @@ export function getController(teamName: string, claudeDir?: string) {
teamName,
...(resolvedClaudeDir ? { claudeDir: resolvedClaudeDir } : {}),
allowUserMessageSender: false,
});
}) as WorkSyncCapableController;
}

View file

@ -14,6 +14,7 @@ import { registerReviewTools } from './reviewTools';
import { registerRuntimeTools } from './runtimeTools';
import { registerTaskTools } from './taskTools';
import { registerTeamTools } from './teamTools';
import { registerWorkSyncTools } from './workSyncTools';
const REGISTRATION_BY_GROUP = {
team: registerTeamTools,
@ -24,6 +25,7 @@ const REGISTRATION_BY_GROUP = {
message: registerMessageTools,
process: registerProcessTools,
runtime: registerRuntimeTools,
workSync: registerWorkSyncTools,
crossTeam: registerCrossTeamTools,
} as const;

View file

@ -0,0 +1,83 @@
import type { FastMCP } from 'fastmcp';
import { z } from 'zod';
import { getController } from '../controller';
import { jsonTextContent } from '../utils/format';
import { assertConfiguredTeam } from '../utils/teamConfig';
const controlContextSchema = {
teamName: z.string().min(1),
claudeDir: z.string().min(1).optional(),
controlUrl: z.string().optional(),
waitTimeoutMs: z.number().int().min(1000).max(600000).optional(),
};
const reportStateSchema = z.enum(['still_working', 'blocked', 'caught_up']);
export function registerWorkSyncTools(server: Pick<FastMCP, 'addTool'>) {
server.addTool({
name: 'member_work_sync_status',
description:
'Read your current actionable-work agenda and agendaFingerprint before reporting whether you are still working, blocked, or caught up.',
parameters: z.object({
...controlContextSchema,
memberName: z.string().min(1).optional(),
from: z.string().min(1).optional(),
}),
execute: async ({ teamName, claudeDir, controlUrl, waitTimeoutMs, memberName, from }) => {
assertConfiguredTeam(teamName, claudeDir);
return jsonTextContent(
await getController(teamName, claudeDir).workSync.memberWorkSyncStatus({
...(memberName ? { memberName } : {}),
...(from ? { from } : {}),
...(controlUrl ? { controlUrl } : {}),
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
})
);
},
});
server.addTool({
name: 'member_work_sync_report',
description:
'Report your validated work-sync state for the current agendaFingerprint. This never completes tasks. Use still_working while actively continuing, blocked only when the board has blocker evidence, and caught_up only when the status agenda is empty.',
parameters: z.object({
...controlContextSchema,
memberName: z.string().min(1).optional(),
from: z.string().min(1).optional(),
state: reportStateSchema,
agendaFingerprint: z.string().min(1),
taskIds: z.array(z.string().min(1)).optional(),
note: z.string().optional(),
leaseTtlMs: z.number().int().min(60000).max(3600000).optional(),
}),
execute: async ({
teamName,
claudeDir,
controlUrl,
waitTimeoutMs,
memberName,
from,
state,
agendaFingerprint,
taskIds,
note,
leaseTtlMs,
}) => {
assertConfiguredTeam(teamName, claudeDir);
return jsonTextContent(
await getController(teamName, claudeDir).workSync.memberWorkSyncReport({
...(memberName ? { memberName } : {}),
...(from ? { from } : {}),
state,
agendaFingerprint,
...(taskIds ? { taskIds } : {}),
...(note ? { note } : {}),
...(leaseTtlMs ? { leaseTtlMs } : {}),
...(controlUrl ? { controlUrl } : {}),
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
})
);
},
});
}

View file

@ -400,6 +400,93 @@ describe('agent-teams-mcp tools', () => {
}
});
it('forwards member work sync MCP tools through the app validator bridge', async () => {
const claudeDir = makeClaudeDir();
writeTeamConfig(claudeDir, 'alpha', {
members: [
{ name: 'lead', role: 'team-lead' },
{ name: 'alice', role: 'developer' },
],
});
const calls: Array<{ method?: string; url?: string; body?: unknown }> = [];
const server = await startControlServer(async ({ method, url, body }) => {
calls.push({ method, url, body });
if (method === 'GET' && url === '/api/teams/alpha/member-work-sync/alice') {
return {
body: {
teamName: 'alpha',
memberName: 'alice',
state: 'needs_sync',
agenda: {
teamName: 'alpha',
memberName: 'alice',
generatedAt: '2026-04-29T00:00:00.000Z',
fingerprint: 'agenda:v1:abc',
items: [],
diagnostics: [],
},
evaluatedAt: '2026-04-29T00:00:00.000Z',
diagnostics: ['no_current_report'],
},
};
}
if (method === 'POST' && url === '/api/teams/alpha/member-work-sync/report') {
return { body: { accepted: true, code: 'accepted', status: body } };
}
return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } };
});
try {
const status = parseJsonToolResult(
await getTool('member_work_sync_status').execute({
claudeDir,
teamName: 'alpha',
controlUrl: server.baseUrl,
from: 'alice',
})
);
expect(status.state).toBe('needs_sync');
const report = parseJsonToolResult(
await getTool('member_work_sync_report').execute({
claudeDir,
teamName: 'alpha',
controlUrl: server.baseUrl,
memberName: 'alice',
state: 'still_working',
agendaFingerprint: 'agenda:v1:abc',
taskIds: ['task-1'],
note: 'Still working',
leaseTtlMs: 120000,
})
);
expect(report.accepted).toBe(true);
expect(calls).toEqual([
{
method: 'GET',
url: '/api/teams/alpha/member-work-sync/alice',
body: undefined,
},
{
method: 'POST',
url: '/api/teams/alpha/member-work-sync/report',
body: {
teamName: 'alpha',
memberName: 'alice',
state: 'still_working',
agendaFingerprint: 'agenda:v1:abc',
taskIds: ['task-1'],
note: 'Still working',
leaseTtlMs: 120000,
},
},
]);
} finally {
await server.close();
}
});
it('discovers the control endpoint from the published state file', async () => {
const claudeDir = makeClaudeDir();
writeTeamConfig(claudeDir, 'alpha', {

View file

@ -0,0 +1,2 @@
export * from './ipc';
export * from './types';

View file

@ -0,0 +1,2 @@
export const MEMBER_WORK_SYNC_GET_STATUS = 'member-work-sync:getStatus';
export const MEMBER_WORK_SYNC_REPORT = 'member-work-sync:report';

View file

@ -0,0 +1,102 @@
export type MemberWorkSyncReportState = 'still_working' | 'blocked' | 'caught_up';
export type MemberWorkSyncStatusState =
| 'caught_up'
| 'needs_sync'
| 'still_working'
| 'blocked'
| 'inactive'
| 'unknown';
export type MemberWorkSyncActionableWorkKind =
| 'work'
| 'review'
| 'clarification'
| 'blocked_dependency';
export type MemberWorkSyncActionableWorkPriority =
| 'normal'
| 'review_requested'
| 'blocked'
| 'needs_clarification';
export type MemberWorkSyncProviderId = 'anthropic' | 'codex' | 'gemini' | 'opencode';
export interface MemberWorkSyncActionableWorkItem {
taskId: string;
displayId?: string;
subject: string;
kind: MemberWorkSyncActionableWorkKind;
assignee: string;
priority: MemberWorkSyncActionableWorkPriority;
reason: string;
evidence: {
status: string;
owner?: string;
reviewer?: string;
reviewState?: string;
needsClarification?: 'lead' | 'user';
blockerTaskIds?: string[];
blockedByTaskIds?: string[];
historyEventIds?: string[];
};
}
export interface MemberWorkSyncAgenda {
teamName: string;
memberName: string;
generatedAt: string;
fingerprint: string;
items: MemberWorkSyncActionableWorkItem[];
diagnostics: string[];
sourceRevision?: string;
}
export interface MemberWorkSyncReport {
state: MemberWorkSyncReportState;
agendaFingerprint: string;
memberName: string;
teamName: string;
reportedAt: string;
expiresAt?: string;
taskIds?: string[];
note?: string;
source?: 'mcp' | 'app' | 'test';
accepted: boolean;
rejectionCode?: string;
}
export interface MemberWorkSyncStatus {
teamName: string;
memberName: string;
state: MemberWorkSyncStatusState;
agenda: MemberWorkSyncAgenda;
report?: MemberWorkSyncReport;
evaluatedAt: string;
diagnostics: string[];
providerId?: MemberWorkSyncProviderId;
}
export interface MemberWorkSyncReportRequest {
teamName: string;
memberName: string;
state: MemberWorkSyncReportState;
agendaFingerprint: string;
taskIds?: string[];
note?: string;
reportedAt?: string;
leaseTtlMs?: number;
source?: 'mcp' | 'app' | 'test';
}
export interface MemberWorkSyncReportResult {
accepted: boolean;
code: string;
message: string;
status: MemberWorkSyncStatus;
}
export interface MemberWorkSyncStatusRequest {
teamName: string;
memberName: string;
}

View file

@ -0,0 +1,15 @@
import type { MemberWorkSyncStatus, MemberWorkSyncStatusRequest } from '../../contracts';
import { MemberWorkSyncReconciler } from './MemberWorkSyncReconciler';
import type { MemberWorkSyncUseCaseDeps } from './ports';
export class MemberWorkSyncDiagnosticsReader {
private readonly reconciler: MemberWorkSyncReconciler;
constructor(deps: MemberWorkSyncUseCaseDeps) {
this.reconciler = new MemberWorkSyncReconciler(deps);
}
async execute(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus> {
return this.reconciler.execute(request);
}
}

View file

@ -0,0 +1,59 @@
import {
buildAgendaFingerprintPayload,
canonicalizeAgendaFingerprintPayload,
decideMemberWorkSyncStatus,
formatAgendaFingerprint,
} from '../domain';
import type { MemberWorkSyncStatus, MemberWorkSyncStatusRequest } from '../../contracts';
import type { MemberWorkSyncAgendaSourceResult, MemberWorkSyncUseCaseDeps } from './ports';
export function finalizeMemberWorkSyncAgenda(
deps: MemberWorkSyncUseCaseDeps,
source: MemberWorkSyncAgendaSourceResult
) {
const payload = buildAgendaFingerprintPayload({
teamName: source.agenda.teamName,
memberName: source.agenda.memberName,
items: source.agenda.items,
sourceRevision: source.agenda.sourceRevision,
});
const fingerprint = formatAgendaFingerprint(
deps.hash.sha256Hex(canonicalizeAgendaFingerprintPayload(payload))
);
return {
...source.agenda,
fingerprint,
diagnostics: [...source.agenda.diagnostics, ...source.diagnostics],
};
}
export class MemberWorkSyncReconciler {
constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {}
async execute(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus> {
const source = await this.deps.agendaSource.loadAgenda(request);
const agenda = finalizeMemberWorkSyncAgenda(this.deps, source);
const previous = await this.deps.statusStore.read(request);
const nowIso = this.deps.clock.now().toISOString();
const decision = decideMemberWorkSyncStatus({
agenda,
latestAcceptedReport: previous?.report?.accepted ? previous.report : null,
nowIso,
inactive: source.inactive,
});
const status: MemberWorkSyncStatus = {
teamName: agenda.teamName,
memberName: agenda.memberName,
state: decision.state,
agenda,
...(decision.acceptedReport ? { report: decision.acceptedReport } : {}),
evaluatedAt: nowIso,
diagnostics: [...agenda.diagnostics, ...decision.diagnostics],
...(source.providerId ? { providerId: source.providerId } : {}),
};
await this.deps.statusStore.write(status);
return status;
}
}

View file

@ -0,0 +1,78 @@
import type {
MemberWorkSyncReport,
MemberWorkSyncReportRequest,
MemberWorkSyncReportResult,
} from '../../contracts';
import { validateMemberWorkSyncReport } from '../domain';
import { finalizeMemberWorkSyncAgenda, MemberWorkSyncReconciler } from './MemberWorkSyncReconciler';
import type { MemberWorkSyncUseCaseDeps } from './ports';
export class MemberWorkSyncReporter {
private readonly reconciler: MemberWorkSyncReconciler;
constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {
this.reconciler = new MemberWorkSyncReconciler(deps);
}
async execute(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult> {
const source = await this.deps.agendaSource.loadAgenda(request);
const agenda = finalizeMemberWorkSyncAgenda(this.deps, source);
const nowIso = (
request.reportedAt ? new Date(request.reportedAt) : this.deps.clock.now()
).toISOString();
const validation = validateMemberWorkSyncReport({
request,
agenda,
nowIso,
activeMemberNames: source.activeMemberNames,
});
if (!validation.ok) {
const status = await this.reconciler.execute(request);
await this.deps.reportStore?.appendPendingReport?.(request, validation.code);
return {
accepted: false,
code: validation.code,
message: validation.message,
status,
};
}
const report: MemberWorkSyncReport = {
teamName: agenda.teamName,
memberName: agenda.memberName,
state: request.state,
agendaFingerprint: agenda.fingerprint,
reportedAt: nowIso,
...(validation.expiresAt ? { expiresAt: validation.expiresAt } : {}),
...(request.taskIds ? { taskIds: [...request.taskIds] } : {}),
...(request.note ? { note: request.note } : {}),
source: request.source ?? 'app',
accepted: true,
};
const status = {
teamName: agenda.teamName,
memberName: agenda.memberName,
state:
report.state === 'caught_up'
? ('caught_up' as const)
: report.state === 'blocked'
? ('blocked' as const)
: ('still_working' as const),
agenda,
report,
evaluatedAt: nowIso,
diagnostics: [...agenda.diagnostics, 'report_accepted'],
...(source.providerId ? { providerId: source.providerId } : {}),
};
await this.deps.statusStore.write(status);
return {
accepted: true,
code: 'accepted',
message: validation.message,
status,
};
}
}

View file

@ -0,0 +1,4 @@
export * from './MemberWorkSyncDiagnosticsReader';
export * from './MemberWorkSyncReconciler';
export * from './MemberWorkSyncReporter';
export * from './ports';

View file

@ -0,0 +1,58 @@
import type {
MemberWorkSyncAgenda,
MemberWorkSyncProviderId,
MemberWorkSyncReport,
MemberWorkSyncReportRequest,
MemberWorkSyncStatus,
} from '../../contracts';
export interface MemberWorkSyncClockPort {
now(): Date;
}
export interface MemberWorkSyncHashPort {
sha256Hex(value: string): string;
}
export interface MemberWorkSyncLoggerPort {
debug(message: string, metadata?: Record<string, unknown>): void;
warn(message: string, metadata?: Record<string, unknown>): void;
error(message: string, metadata?: Record<string, unknown>): void;
}
export interface MemberWorkSyncAgendaSourceResult {
agenda: Omit<MemberWorkSyncAgenda, 'fingerprint'>;
activeMemberNames: string[];
inactive: boolean;
providerId?: MemberWorkSyncProviderId;
diagnostics: string[];
}
export interface MemberWorkSyncAgendaSourcePort {
loadAgenda(input: {
teamName: string;
memberName: string;
}): Promise<MemberWorkSyncAgendaSourceResult>;
}
export interface MemberWorkSyncStatusStorePort {
read(input: { teamName: string; memberName: string }): Promise<MemberWorkSyncStatus | null>;
write(status: MemberWorkSyncStatus): Promise<void>;
}
export interface MemberWorkSyncReportStorePort {
appendPendingReport?(request: MemberWorkSyncReportRequest, reason: string): Promise<void>;
}
export interface MemberWorkSyncUseCaseDeps {
clock: MemberWorkSyncClockPort;
hash: MemberWorkSyncHashPort;
agendaSource: MemberWorkSyncAgendaSourcePort;
statusStore: MemberWorkSyncStatusStorePort;
reportStore?: MemberWorkSyncReportStorePort;
logger?: MemberWorkSyncLoggerPort;
}
export interface LatestAcceptedReportLookup {
latestAcceptedReport?: MemberWorkSyncReport;
}

View file

@ -0,0 +1,193 @@
import type {
MemberWorkSyncActionableWorkItem,
MemberWorkSyncAgenda,
MemberWorkSyncProviderId,
} from '../../contracts';
import {
buildAgendaFingerprintPayload,
canonicalizeAgendaFingerprintPayload,
formatAgendaFingerprint,
} from './AgendaFingerprint';
import { resolveCurrentReviewOwner, type ReviewHistoryEventLike } from './currentReviewCycle';
import { isReservedMemberName, normalizeMemberName, sameMemberName } from './memberName';
export interface MemberWorkSyncTaskLike {
id: string;
displayId?: string;
subject?: string;
status: string;
owner?: string | null;
reviewState?: string | null;
needsClarification?: 'lead' | 'user' | null;
blockedBy?: string[];
blocks?: string[];
deletedAt?: string | null;
historyEvents?: ReviewHistoryEventLike[];
}
export interface MemberWorkSyncMemberLike {
name: string;
providerId?: MemberWorkSyncProviderId | string;
model?: string;
agentType?: string;
removedAt?: string | null;
}
export interface BuildActionableWorkAgendaInput {
teamName: string;
memberName: string;
generatedAt: string;
tasks: MemberWorkSyncTaskLike[];
members: MemberWorkSyncMemberLike[];
kanbanReviewersByTaskId?: Record<string, string | null | undefined>;
sourceRevision?: string;
hash: (canonicalPayload: string) => string;
}
function isCompletedOrDeleted(task: MemberWorkSyncTaskLike): boolean {
return task.status === 'completed' || task.status === 'deleted' || Boolean(task.deletedAt);
}
function getActiveMemberNames(members: MemberWorkSyncMemberLike[]): Set<string> {
return new Set(
members
.filter((member) => !member.removedAt)
.map((member) => normalizeMemberName(member.name))
.filter((name) => name.length > 0 && !isReservedMemberName(name))
);
}
function buildBaseItem(
task: MemberWorkSyncTaskLike,
memberName: string
): Omit<MemberWorkSyncActionableWorkItem, 'kind' | 'priority' | 'reason' | 'evidence'> {
return {
taskId: task.id,
...(task.displayId ? { displayId: task.displayId } : {}),
subject: task.subject?.trim() || 'Untitled task',
assignee: memberName,
};
}
export function buildActionableWorkAgenda(
input: BuildActionableWorkAgendaInput
): MemberWorkSyncAgenda {
const memberName = normalizeMemberName(input.memberName);
const diagnostics: string[] = [];
const activeMemberNames = getActiveMemberNames(input.members);
if (!memberName || isReservedMemberName(memberName)) {
diagnostics.push('member_invalid_or_reserved');
} else if (!activeMemberNames.has(memberName)) {
diagnostics.push('member_not_active');
}
const items: MemberWorkSyncActionableWorkItem[] = [];
if (activeMemberNames.has(memberName)) {
for (const task of input.tasks) {
if (!task.id || isCompletedOrDeleted(task)) {
continue;
}
const owner = normalizeMemberName(task.owner);
const base = buildBaseItem(task, memberName);
const blockedBy = [...(task.blockedBy ?? [])].filter(Boolean).sort();
const blocks = [...(task.blocks ?? [])].filter(Boolean).sort();
const reviewOwner = resolveCurrentReviewOwner({
reviewState: task.reviewState,
kanbanReviewer: input.kanbanReviewersByTaskId?.[task.id] ?? null,
historyEvents: task.historyEvents,
});
if (reviewOwner && sameMemberName(reviewOwner.reviewer, memberName)) {
items.push({
...base,
kind: 'review',
priority: 'review_requested',
reason: 'current_cycle_review_assigned',
evidence: {
status: task.status,
...(owner ? { owner } : {}),
reviewer: memberName,
...(task.reviewState ? { reviewState: task.reviewState } : {}),
...(reviewOwner.historyEventIds.length > 0
? { historyEventIds: reviewOwner.historyEventIds }
: {}),
},
});
continue;
}
if (!sameMemberName(owner, memberName)) {
continue;
}
if (task.needsClarification === 'lead' || task.needsClarification === 'user') {
items.push({
...base,
kind: 'clarification',
priority: 'needs_clarification',
reason: `task_needs_${task.needsClarification}_clarification`,
evidence: {
status: task.status,
owner: memberName,
...(task.reviewState ? { reviewState: task.reviewState } : {}),
needsClarification: task.needsClarification,
},
});
continue;
}
if (blockedBy.length > 0) {
items.push({
...base,
kind: 'blocked_dependency',
priority: 'blocked',
reason: 'owned_task_has_blocked_dependency',
evidence: {
status: task.status,
owner: memberName,
...(task.reviewState ? { reviewState: task.reviewState } : {}),
blockedByTaskIds: blockedBy,
...(blocks.length > 0 ? { blockerTaskIds: blocks } : {}),
},
});
continue;
}
if (task.status === 'pending' || task.status === 'in_progress') {
items.push({
...base,
kind: 'work',
priority: 'normal',
reason: task.status === 'pending' ? 'owned_pending_task' : 'owned_in_progress_task',
evidence: {
status: task.status,
owner: memberName,
...(task.reviewState ? { reviewState: task.reviewState } : {}),
},
});
}
}
}
const payload = buildAgendaFingerprintPayload({
teamName: input.teamName,
memberName,
items,
sourceRevision: input.sourceRevision,
});
const canonicalPayload = canonicalizeAgendaFingerprintPayload(payload);
return {
teamName: input.teamName,
memberName,
generatedAt: input.generatedAt,
fingerprint: formatAgendaFingerprint(input.hash(canonicalPayload)),
items,
diagnostics,
...(input.sourceRevision ? { sourceRevision: input.sourceRevision } : {}),
};
}

View file

@ -0,0 +1,80 @@
import type { MemberWorkSyncActionableWorkItem } from '../../contracts';
export const MEMBER_WORK_SYNC_AGENDA_FINGERPRINT_PREFIX = 'agenda:v1:';
function stableJson(value: unknown): string {
if (value == null || typeof value !== 'object') {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map((item) => stableJson(item)).join(',')}]`;
}
const record = value as Record<string, unknown>;
const keys = Object.keys(record).sort();
return `{${keys.map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(',')}}`;
}
export interface AgendaFingerprintPayload {
version: 1;
teamName: string;
memberName: string;
items: Array<{
taskId: string;
displayId?: string;
subject: string;
kind: string;
assignee: string;
priority: string;
evidence: MemberWorkSyncActionableWorkItem['evidence'];
}>;
sourceRevision?: string;
}
export function buildAgendaFingerprintPayload(input: {
teamName: string;
memberName: string;
items: MemberWorkSyncActionableWorkItem[];
sourceRevision?: string;
}): AgendaFingerprintPayload {
return {
version: 1,
teamName: input.teamName,
memberName: input.memberName,
...(input.sourceRevision ? { sourceRevision: input.sourceRevision } : {}),
items: [...input.items]
.sort((left, right) => {
const leftKey = `${left.kind}:${left.taskId}:${left.displayId ?? ''}`;
const rightKey = `${right.kind}:${right.taskId}:${right.displayId ?? ''}`;
return leftKey.localeCompare(rightKey);
})
.map((item) => ({
taskId: item.taskId,
...(item.displayId ? { displayId: item.displayId } : {}),
subject: item.subject,
kind: item.kind,
assignee: item.assignee,
priority: item.priority,
evidence: {
...item.evidence,
...(item.evidence.blockerTaskIds
? { blockerTaskIds: [...item.evidence.blockerTaskIds].sort() }
: {}),
...(item.evidence.blockedByTaskIds
? { blockedByTaskIds: [...item.evidence.blockedByTaskIds].sort() }
: {}),
...(item.evidence.historyEventIds
? { historyEventIds: [...item.evidence.historyEventIds].sort() }
: {}),
},
})),
};
}
export function canonicalizeAgendaFingerprintPayload(payload: AgendaFingerprintPayload): string {
return stableJson(payload);
}
export function formatAgendaFingerprint(hashHex: string): string {
return `${MEMBER_WORK_SYNC_AGENDA_FINGERPRINT_PREFIX}${hashHex}`;
}

View file

@ -0,0 +1,114 @@
import type {
MemberWorkSyncAgenda,
MemberWorkSyncReportRequest,
MemberWorkSyncReportState,
} from '../../contracts';
import { isReservedMemberName, normalizeMemberName, sameMemberName } from './memberName';
export interface MemberWorkSyncReportValidation {
ok: boolean;
code: string;
message: string;
expiresAt?: string;
}
const DEFAULT_STILL_WORKING_LEASE_MS = 15 * 60 * 1000;
const DEFAULT_BLOCKED_LEASE_MS = 30 * 60 * 1000;
const MIN_LEASE_MS = 60_000;
const MAX_LEASE_MS = 60 * 60 * 1000;
function clampLeaseTtlMs(
value: number | undefined,
state: MemberWorkSyncReportState
): number | undefined {
if (state === 'caught_up') {
return undefined;
}
const fallback = state === 'blocked' ? DEFAULT_BLOCKED_LEASE_MS : DEFAULT_STILL_WORKING_LEASE_MS;
const numeric = Number.isFinite(value) ? Math.floor(Number(value)) : fallback;
return Math.min(MAX_LEASE_MS, Math.max(MIN_LEASE_MS, numeric));
}
function agendaHasBlockedEvidence(
agenda: MemberWorkSyncAgenda,
taskIds: string[] | undefined
): boolean {
const targetIds = new Set((taskIds ?? []).filter(Boolean));
return agenda.items.some((item) => {
if (targetIds.size > 0 && !targetIds.has(item.taskId)) {
return false;
}
return item.kind === 'blocked_dependency' || item.priority === 'blocked';
});
}
export function validateMemberWorkSyncReport(input: {
request: MemberWorkSyncReportRequest;
agenda: MemberWorkSyncAgenda;
nowIso: string;
activeMemberNames: string[];
}): MemberWorkSyncReportValidation {
const memberName = normalizeMemberName(input.request.memberName);
const activeMemberNames = new Set(input.activeMemberNames.map(normalizeMemberName));
if (!memberName || isReservedMemberName(memberName)) {
return { ok: false, code: 'reserved_or_invalid_member', message: 'Invalid member identity.' };
}
if (!sameMemberName(memberName, input.agenda.memberName)) {
return {
ok: false,
code: 'identity_mismatch',
message: 'Report member does not match agenda.',
};
}
if (!activeMemberNames.has(memberName)) {
return { ok: false, code: 'member_inactive', message: 'Member is not active in this team.' };
}
if (input.request.agendaFingerprint !== input.agenda.fingerprint) {
return {
ok: false,
code: 'stale_fingerprint',
message: 'Report fingerprint is stale. Read current member work sync status and retry.',
};
}
const agendaTaskIds = new Set(input.agenda.items.map((item) => item.taskId));
for (const taskId of input.request.taskIds ?? []) {
if (!agendaTaskIds.has(taskId)) {
return {
ok: false,
code: 'foreign_task_id',
message: `Task ${taskId} is not in the current actionable agenda.`,
};
}
}
if (input.request.state === 'caught_up' && input.agenda.items.length > 0) {
return {
ok: false,
code: 'caught_up_rejected_actionable_items_exist',
message: 'Cannot report caught_up while actionable work remains.',
};
}
if (
input.request.state === 'blocked' &&
!agendaHasBlockedEvidence(input.agenda, input.request.taskIds)
) {
return {
ok: false,
code: 'blocked_without_evidence',
message: 'Blocked report requires current blocker evidence in the task board.',
};
}
const leaseTtlMs = clampLeaseTtlMs(input.request.leaseTtlMs, input.request.state);
return {
ok: true,
code: 'accepted',
message: 'Member work sync report accepted.',
...(leaseTtlMs
? { expiresAt: new Date(Date.parse(input.nowIso) + leaseTtlMs).toISOString() }
: {}),
};
}

View file

@ -0,0 +1,52 @@
import type {
MemberWorkSyncAgenda,
MemberWorkSyncReport,
MemberWorkSyncStatusState,
} from '../../contracts';
export interface SyncDecision {
state: MemberWorkSyncStatusState;
acceptedReport?: MemberWorkSyncReport;
diagnostics: string[];
}
export function decideMemberWorkSyncStatus(input: {
agenda: MemberWorkSyncAgenda;
latestAcceptedReport?: MemberWorkSyncReport | null;
nowIso: string;
inactive?: boolean;
}): SyncDecision {
if (input.inactive) {
return { state: 'inactive', diagnostics: ['member_or_team_inactive'] };
}
if (input.agenda.items.length === 0) {
return {
state: 'caught_up',
diagnostics: ['agenda_empty'],
acceptedReport:
input.latestAcceptedReport?.agendaFingerprint === input.agenda.fingerprint
? input.latestAcceptedReport
: undefined,
};
}
const report = input.latestAcceptedReport ?? null;
if (!report) {
return { state: 'needs_sync', diagnostics: ['no_current_report'] };
}
if (report.agendaFingerprint !== input.agenda.fingerprint) {
return { state: 'needs_sync', diagnostics: ['report_fingerprint_stale'] };
}
if (report.expiresAt && Date.parse(report.expiresAt) <= Date.parse(input.nowIso)) {
return { state: 'needs_sync', diagnostics: ['report_lease_expired'] };
}
if (report.state === 'still_working') {
return { state: 'still_working', acceptedReport: report, diagnostics: ['lease_still_working'] };
}
if (report.state === 'blocked') {
return { state: 'blocked', acceptedReport: report, diagnostics: ['lease_blocked'] };
}
return { state: 'needs_sync', diagnostics: ['caught_up_report_not_valid_for_non_empty_agenda'] };
}

View file

@ -0,0 +1,75 @@
import { normalizeMemberName } from './memberName';
export interface ReviewHistoryEventLike {
id?: string;
type: string;
timestamp?: string;
actor?: string;
reviewer?: string;
}
export interface CurrentReviewOwner {
reviewer: string;
historyEventIds: string[];
}
function compareEventsByTimestamp(
left: ReviewHistoryEventLike,
right: ReviewHistoryEventLike
): number {
const leftTime = Date.parse(left.timestamp ?? '');
const rightTime = Date.parse(right.timestamp ?? '');
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
return leftTime - rightTime;
}
return 0;
}
export function resolveCurrentReviewOwner(input: {
reviewState?: string | null;
kanbanReviewer?: string | null;
historyEvents?: ReviewHistoryEventLike[];
}): CurrentReviewOwner | null {
if (input.reviewState !== 'review') {
return null;
}
const historyEvents = [...(input.historyEvents ?? [])]
.filter((event) =>
[
'review_requested',
'review_started',
'review_approved',
'review_changes_requested',
].includes(event.type)
)
.sort(compareEventsByTimestamp);
const latest = historyEvents.at(-1);
if (latest?.type === 'review_approved' || latest?.type === 'review_changes_requested') {
return null;
}
const latestStarted = [...historyEvents]
.reverse()
.find((event) => event.type === 'review_started');
const latestRequested = [...historyEvents]
.reverse()
.find((event) => event.type === 'review_requested');
const reviewer =
normalizeMemberName(latestStarted?.actor) ||
normalizeMemberName(latestRequested?.reviewer) ||
normalizeMemberName(input.kanbanReviewer);
if (!reviewer) {
return null;
}
return {
reviewer,
historyEventIds: [latestStarted?.id, latestRequested?.id].filter(
(id): id is string => typeof id === 'string' && id.length > 0
),
};
}

View file

@ -0,0 +1,6 @@
export * from './ActionableWorkAgenda';
export * from './AgendaFingerprint';
export * from './currentReviewCycle';
export * from './memberName';
export * from './MemberWorkSyncReportValidator';
export * from './SyncDecisionPolicy';

View file

@ -0,0 +1,15 @@
const RESERVED_MEMBER_NAMES = new Set(['', 'user', 'system']);
export function normalizeMemberName(value: unknown): string {
return typeof value === 'string' ? value.trim().toLowerCase() : '';
}
export function isReservedMemberName(value: unknown): boolean {
return RESERVED_MEMBER_NAMES.has(normalizeMemberName(value));
}
export function sameMemberName(left: unknown, right: unknown): boolean {
const normalizedLeft = normalizeMemberName(left);
const normalizedRight = normalizeMemberName(right);
return normalizedLeft.length > 0 && normalizedLeft === normalizedRight;
}

View file

@ -0,0 +1 @@
export * from './contracts';

View file

@ -0,0 +1,48 @@
import {
MEMBER_WORK_SYNC_GET_STATUS,
MEMBER_WORK_SYNC_REPORT,
type MemberWorkSyncReportRequest,
type MemberWorkSyncReportResult,
type MemberWorkSyncStatus,
type MemberWorkSyncStatusRequest,
} from '../../../contracts';
import { createLogger } from '@shared/utils/logger';
import type { MemberWorkSyncFeatureFacade } from '../../composition/createMemberWorkSyncFeature';
import type { IpcMain } from 'electron';
const logger = createLogger('Feature:MemberWorkSync:IPC');
export function registerMemberWorkSyncIpc(
ipcMain: IpcMain,
feature: MemberWorkSyncFeatureFacade
): void {
ipcMain.handle(
MEMBER_WORK_SYNC_GET_STATUS,
async (_event, request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus> => {
try {
return await feature.getStatus(request);
} catch (error) {
logger.error('Failed to get member work sync status', error);
throw error;
}
}
);
ipcMain.handle(
MEMBER_WORK_SYNC_REPORT,
async (_event, request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult> => {
try {
return await feature.report(request);
} catch (error) {
logger.error('Failed to submit member work sync report', error);
throw error;
}
}
);
}
export function removeMemberWorkSyncIpc(ipcMain: IpcMain): void {
ipcMain.removeHandler(MEMBER_WORK_SYNC_GET_STATUS);
ipcMain.removeHandler(MEMBER_WORK_SYNC_REPORT);
}

View file

@ -0,0 +1,125 @@
import {
buildActionableWorkAgenda,
normalizeMemberName,
type MemberWorkSyncMemberLike,
} from '../../../core/domain';
import type {
MemberWorkSyncAgendaSourcePort,
MemberWorkSyncAgendaSourceResult,
MemberWorkSyncHashPort,
} from '../../../core/application';
import {
inferTeamProviderIdFromModel,
normalizeOptionalTeamProviderId,
} from '@shared/utils/teamProvider';
import type { TeamConfigReader } from '@main/services/team/TeamConfigReader';
import type { TeamKanbanManager } from '@main/services/team/TeamKanbanManager';
import type { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaStore';
import type { TeamTaskReader } from '@main/services/team/TeamTaskReader';
import type { TeamMember } from '@shared/types';
export interface TeamTaskAgendaSourceDeps {
configReader: TeamConfigReader;
taskReader: TeamTaskReader;
kanbanManager: TeamKanbanManager;
membersMetaStore: TeamMembersMetaStore;
hash: MemberWorkSyncHashPort;
clock: { now(): Date };
}
function memberKey(member: Pick<TeamMember, 'name'>): string {
return normalizeMemberName(member.name);
}
function mergeMembers(configMembers: TeamMember[], metaMembers: TeamMember[]): TeamMember[] {
const byName = new Map<string, TeamMember>();
for (const member of configMembers) {
const key = memberKey(member);
if (key) {
byName.set(key, member);
}
}
for (const member of metaMembers) {
const key = memberKey(member);
if (key) {
byName.set(key, { ...byName.get(key), ...member });
}
}
return [...byName.values()];
}
function toMemberLike(member: TeamMember): MemberWorkSyncMemberLike {
const providerId =
normalizeOptionalTeamProviderId(member.providerId) ??
inferTeamProviderIdFromModel(member.model);
return {
name: member.name,
...(providerId ? { providerId } : {}),
...(member.model ? { model: member.model } : {}),
...(member.agentType ? { agentType: member.agentType } : {}),
...(member.removedAt ? { removedAt: String(member.removedAt) } : {}),
};
}
export class TeamTaskAgendaSource implements MemberWorkSyncAgendaSourcePort {
constructor(private readonly deps: TeamTaskAgendaSourceDeps) {}
async loadAgenda(input: {
teamName: string;
memberName: string;
}): Promise<MemberWorkSyncAgendaSourceResult> {
const config = await this.deps.configReader.getConfig(input.teamName);
if (!config || config.deletedAt) {
const nowIso = this.deps.clock.now().toISOString();
return {
agenda: {
teamName: input.teamName,
memberName: normalizeMemberName(input.memberName),
generatedAt: nowIso,
items: [],
diagnostics: config?.deletedAt ? ['team_deleted'] : ['team_config_missing'],
},
activeMemberNames: [],
inactive: true,
diagnostics: [],
};
}
const [tasks, kanban, metaMembers] = await Promise.all([
this.deps.taskReader.getTasks(input.teamName),
this.deps.kanbanManager.getState(input.teamName),
this.deps.membersMetaStore.getMembers(input.teamName),
]);
const members = mergeMembers(config.members ?? [], metaMembers);
const activeMemberNames = members
.filter((member) => !member.removedAt)
.map((member) => normalizeMemberName(member.name))
.filter(Boolean);
const normalizedMemberName = normalizeMemberName(input.memberName);
const member = members.find((candidate) => memberKey(candidate) === normalizedMemberName);
const providerId =
normalizeOptionalTeamProviderId(member?.providerId) ??
inferTeamProviderIdFromModel(member?.model);
const agenda = buildActionableWorkAgenda({
teamName: input.teamName,
memberName: input.memberName,
generatedAt: this.deps.clock.now().toISOString(),
tasks,
members: members.map(toMemberLike),
kanbanReviewersByTaskId: Object.fromEntries(
Object.entries(kanban.tasks).map(([taskId, value]) => [taskId, value.reviewer ?? null])
),
hash: this.deps.hash.sha256Hex.bind(this.deps.hash),
});
return {
agenda,
activeMemberNames,
inactive: !activeMemberNames.includes(normalizedMemberName),
...(providerId ? { providerId } : {}),
diagnostics: [],
};
}
}

View file

@ -0,0 +1,59 @@
import type {
MemberWorkSyncReportRequest,
MemberWorkSyncReportResult,
MemberWorkSyncStatus,
MemberWorkSyncStatusRequest,
} from '../../contracts';
import { MemberWorkSyncDiagnosticsReader, MemberWorkSyncReporter } from '../../core/application';
import { TeamTaskAgendaSource } from '../adapters/output/TeamTaskAgendaSource';
import { JsonMemberWorkSyncStore } from '../infrastructure/JsonMemberWorkSyncStore';
import { MemberWorkSyncStorePaths } from '../infrastructure/MemberWorkSyncStorePaths';
import { NodeHashAdapter } from '../infrastructure/NodeHashAdapter';
import { SystemClockAdapter } from '../infrastructure/SystemClockAdapter';
import type { TeamConfigReader } from '@main/services/team/TeamConfigReader';
import type { TeamKanbanManager } from '@main/services/team/TeamKanbanManager';
import type { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaStore';
import type { TeamTaskReader } from '@main/services/team/TeamTaskReader';
import type { MemberWorkSyncLoggerPort } from '../../core/application';
export interface MemberWorkSyncFeatureFacade {
getStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>;
report(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult>;
}
export function createMemberWorkSyncFeature(deps: {
teamsBasePath: string;
configReader: TeamConfigReader;
taskReader: TeamTaskReader;
kanbanManager: TeamKanbanManager;
membersMetaStore: TeamMembersMetaStore;
logger?: MemberWorkSyncLoggerPort;
}): MemberWorkSyncFeatureFacade {
const clock = new SystemClockAdapter();
const hash = new NodeHashAdapter();
const agendaSource = new TeamTaskAgendaSource({
configReader: deps.configReader,
taskReader: deps.taskReader,
kanbanManager: deps.kanbanManager,
membersMetaStore: deps.membersMetaStore,
hash,
clock,
});
const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(deps.teamsBasePath));
const useCaseDeps = {
clock,
hash,
agendaSource,
statusStore: store,
reportStore: store,
logger: deps.logger,
};
const diagnosticsReader = new MemberWorkSyncDiagnosticsReader(useCaseDeps);
const reporter = new MemberWorkSyncReporter(useCaseDeps);
return {
getStatus: (request) => diagnosticsReader.execute(request),
report: (request) => reporter.execute(request),
};
}

View file

@ -0,0 +1,6 @@
export {
registerMemberWorkSyncIpc,
removeMemberWorkSyncIpc,
} from './adapters/input/registerMemberWorkSyncIpc';
export { createMemberWorkSyncFeature } from './composition/createMemberWorkSyncFeature';
export type { MemberWorkSyncFeatureFacade } from './composition/createMemberWorkSyncFeature';

View file

@ -0,0 +1,95 @@
import { atomicWriteAsync } from '@main/utils/atomicWrite';
import { mkdir, readFile, appendFile } from 'fs/promises';
import type { MemberWorkSyncReportRequest, MemberWorkSyncStatus } from '../../contracts';
import type {
MemberWorkSyncReportStorePort,
MemberWorkSyncStatusStorePort,
} from '../../core/application';
import type { MemberWorkSyncStorePaths } from './MemberWorkSyncStorePaths';
interface StoreFile {
schemaVersion: 1;
members: Record<string, MemberWorkSyncStatus>;
}
function normalizeMemberKey(memberName: string): string {
return memberName.trim().toLowerCase();
}
function isStoreFile(value: unknown): value is StoreFile {
return (
value != null &&
typeof value === 'object' &&
(value as StoreFile).schemaVersion === 1 &&
(value as StoreFile).members != null &&
typeof (value as StoreFile).members === 'object' &&
!Array.isArray((value as StoreFile).members)
);
}
export class JsonMemberWorkSyncStore
implements MemberWorkSyncStatusStorePort, MemberWorkSyncReportStorePort
{
private readonly writeQueues = new Map<string, Promise<void>>();
constructor(private readonly paths: MemberWorkSyncStorePaths) {}
async read(input: {
teamName: string;
memberName: string;
}): Promise<MemberWorkSyncStatus | null> {
const file = await this.readFile(input.teamName);
return file.members[normalizeMemberKey(input.memberName)] ?? null;
}
async write(status: MemberWorkSyncStatus): Promise<void> {
await this.enqueue(status.teamName, async () => {
const existing = await this.readFile(status.teamName);
existing.members[normalizeMemberKey(status.memberName)] = status;
await mkdir(this.paths.getTeamDir(status.teamName), { recursive: true });
await atomicWriteAsync(
this.paths.getStatusPath(status.teamName),
JSON.stringify(existing, null, 2)
);
});
}
async appendPendingReport(request: MemberWorkSyncReportRequest, reason: string): Promise<void> {
await mkdir(this.paths.getTeamDir(request.teamName), { recursive: true });
await appendFile(
this.paths.getPendingReportsPath(request.teamName),
`${JSON.stringify({ schemaVersion: 1, reason, request, recordedAt: new Date().toISOString() })}\n`,
'utf8'
);
}
private async readFile(teamName: string): Promise<StoreFile> {
try {
const raw = await readFile(this.paths.getStatusPath(teamName), 'utf8');
const parsed = JSON.parse(raw);
if (isStoreFile(parsed)) {
return parsed;
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
}
return { schemaVersion: 1, members: {} };
}
private async enqueue(teamName: string, operation: () => Promise<void>): Promise<void> {
const previous = this.writeQueues.get(teamName) ?? Promise.resolve();
const next = previous.then(operation, operation);
this.writeQueues.set(
teamName,
next.finally(() => {
if (this.writeQueues.get(teamName) === next) {
this.writeQueues.delete(teamName);
}
})
);
await next;
}
}

View file

@ -0,0 +1,17 @@
import { join } from 'path';
export class MemberWorkSyncStorePaths {
constructor(private readonly teamsBasePath: string) {}
getTeamDir(teamName: string): string {
return join(this.teamsBasePath, teamName, '.member-work-sync');
}
getStatusPath(teamName: string): string {
return join(this.getTeamDir(teamName), 'status.json');
}
getPendingReportsPath(teamName: string): string {
return join(this.getTeamDir(teamName), 'pending-reports.jsonl');
}
}

View file

@ -0,0 +1,9 @@
import { createHash } from 'crypto';
import type { MemberWorkSyncHashPort } from '../../core/application';
export class NodeHashAdapter implements MemberWorkSyncHashPort {
sha256Hex(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
}

View file

@ -0,0 +1,7 @@
import type { MemberWorkSyncClockPort } from '../../core/application';
export class SystemClockAdapter implements MemberWorkSyncClockPort {
now(): Date {
return new Date();
}
}

View file

@ -0,0 +1,22 @@
import {
MEMBER_WORK_SYNC_GET_STATUS,
MEMBER_WORK_SYNC_REPORT,
type MemberWorkSyncReportRequest,
type MemberWorkSyncReportResult,
type MemberWorkSyncStatus,
type MemberWorkSyncStatusRequest,
} from '../contracts';
import type { IpcRenderer } from 'electron';
export interface MemberWorkSyncElectronApi {
getStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>;
report(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult>;
}
export function createMemberWorkSyncBridge(ipcRenderer: IpcRenderer): MemberWorkSyncElectronApi {
return {
getStatus: (request) => ipcRenderer.invoke(MEMBER_WORK_SYNC_GET_STATUS, request),
report: (request) => ipcRenderer.invoke(MEMBER_WORK_SYNC_REPORT, request),
};
}

View file

@ -9,6 +9,7 @@ import {
type RecentProjectsFeatureFacade,
registerRecentProjectsHttp,
} from '@features/recent-projects/main';
import type { MemberWorkSyncFeatureFacade } from '@features/member-work-sync/main';
import { createLogger } from '@shared/utils/logger';
import { registerConfigRoutes } from './config';
@ -46,6 +47,7 @@ export interface HttpServices {
chunkBuilder: ChunkBuilder;
dataCache: DataCache;
recentProjectsFeature?: RecentProjectsFeatureFacade;
memberWorkSyncFeature?: MemberWorkSyncFeatureFacade;
updaterService: UpdaterService;
sshConnectionManager: SshConnectionManager;
teamDataService?: TeamDataService;

View file

@ -14,6 +14,7 @@ import { access } from 'fs/promises';
import { isAbsolute, join } from 'path';
import type { HttpServices } from './index';
import type { MemberWorkSyncReportState } from '@features/member-work-sync/contracts';
import type {
EffortLevel,
TeamCreateConfigRequest,
@ -31,6 +32,10 @@ type CreateTeamBody = TeamCreateConfigRequest;
class HttpBadRequestError extends Error {}
class HttpFeatureUnavailableError extends Error {}
function isMemberWorkSyncReportState(value: string): value is MemberWorkSyncReportState {
return value === 'still_working' || value === 'blocked' || value === 'caught_up';
}
function getTeamProvisioningService(
services: HttpServices
): NonNullable<HttpServices['teamProvisioningService']> {
@ -466,6 +471,15 @@ function withRuntimeTeamName(teamName: string, body: unknown): Record<string, un
return { ...payload, teamName };
}
function getMemberWorkSyncFeature(
services: HttpServices
): NonNullable<HttpServices['memberWorkSyncFeature']> {
if (!services.memberWorkSyncFeature) {
throw new HttpBadRequestError('Member work sync feature is unavailable');
}
return services.memberWorkSyncFeature;
}
export function registerTeamRoutes(app: FastifyInstance, services: HttpServices): void {
app.get('/api/teams', async (_request, reply) => {
try {
@ -736,4 +750,85 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices)
}
}
);
app.get<{ Params: { teamName: string; memberName: string } }>(
'/api/teams/:teamName/member-work-sync/:memberName',
async (request, reply) => {
try {
const validatedTeamName = validateTeamName(request.params.teamName);
if (!validatedTeamName.valid) {
return reply.status(400).send({ error: validatedTeamName.error });
}
const memberName = request.params.memberName?.trim();
if (!memberName) {
return reply.status(400).send({ error: 'memberName is required' });
}
return reply.send(
await getMemberWorkSyncFeature(services).getStatus({
teamName: validatedTeamName.value!,
memberName,
})
);
} catch (error) {
if (shouldLogError(error)) {
logger.error(
`Error in GET /api/teams/${request.params.teamName}/member-work-sync/${request.params.memberName}:`,
getErrorMessage(error)
);
}
return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) });
}
}
);
app.post<{ Params: { teamName: string }; Body: Record<string, unknown> }>(
'/api/teams/:teamName/member-work-sync/report',
async (request, reply) => {
try {
const validatedTeamName = validateTeamName(request.params.teamName);
if (!validatedTeamName.valid) {
return reply.status(400).send({ error: validatedTeamName.error });
}
const payload = withRuntimeTeamName(validatedTeamName.value!, request.body);
const memberName = typeof payload.memberName === 'string' ? payload.memberName.trim() : '';
const state = typeof payload.state === 'string' ? payload.state.trim() : '';
const agendaFingerprint =
typeof payload.agendaFingerprint === 'string' ? payload.agendaFingerprint.trim() : '';
if (!memberName || !state || !agendaFingerprint) {
return reply.status(400).send({
error: 'memberName, state, and agendaFingerprint are required',
});
}
if (!isMemberWorkSyncReportState(state)) {
return reply
.status(400)
.send({ error: 'state must be still_working, blocked, or caught_up' });
}
const taskIds = Array.isArray(payload.taskIds)
? payload.taskIds.filter((taskId): taskId is string => typeof taskId === 'string')
: undefined;
return reply.send(
await getMemberWorkSyncFeature(services).report({
teamName: validatedTeamName.value!,
memberName,
state,
agendaFingerprint,
...(taskIds ? { taskIds } : {}),
...(typeof payload.note === 'string' ? { note: payload.note } : {}),
...(typeof payload.reportedAt === 'string' ? { reportedAt: payload.reportedAt } : {}),
...(typeof payload.leaseTtlMs === 'number' ? { leaseTtlMs: payload.leaseTtlMs } : {}),
source: 'mcp',
})
);
} catch (error) {
if (shouldLogError(error)) {
logger.error(
`Error in POST /api/teams/${request.params.teamName}/member-work-sync/report:`,
getErrorMessage(error)
);
}
return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) });
}
}
);
}

View file

@ -36,6 +36,12 @@ import {
registerRecentProjectsIpc,
removeRecentProjectsIpc,
} from '@features/recent-projects/main';
import {
createMemberWorkSyncFeature,
type MemberWorkSyncFeatureFacade,
registerMemberWorkSyncIpc,
removeMemberWorkSyncIpc,
} from '@features/member-work-sync/main';
import {
createRuntimeProviderManagementFeature,
registerRuntimeProviderManagementIpc,
@ -178,11 +184,14 @@ import {
TeamMemberLogsFinder,
TeamProvisioningService,
TeamRuntimeAdapterRegistry,
TeamKanbanManager,
TeamMembersMetaStore,
TeamTaskStallJournal,
TeamTaskStallMonitor,
TeamTaskStallNotifier,
TeamTaskStallPolicy,
TeamTaskStallSnapshotSource,
TeamTaskReader,
UpdaterService,
} from './services';
@ -558,6 +567,7 @@ let codexAccountFeature: CodexAccountFeatureFacade | null = null;
let codexModelCatalogFeature: CodexModelCatalogFeatureFacade | null = null;
let recentProjectsFeature: RecentProjectsFeatureFacade;
let runtimeProviderManagementFeature: RuntimeProviderManagementFeatureFacade;
let memberWorkSyncFeature: MemberWorkSyncFeatureFacade;
let teamDataService: TeamDataService;
let teamProvisioningService: TeamProvisioningService;
let cliInstallerService: CliInstallerService;
@ -1215,6 +1225,14 @@ async function initializeServices(): Promise<void> {
logger: createLogger('Feature:RecentProjects'),
});
runtimeProviderManagementFeature = createRuntimeProviderManagementFeature();
memberWorkSyncFeature = createMemberWorkSyncFeature({
teamsBasePath: getTeamsBasePath(),
configReader: new TeamConfigReader(),
taskReader: new TeamTaskReader(),
kanbanManager: new TeamKanbanManager(),
membersMetaStore: new TeamMembersMetaStore(),
logger: createLogger('Feature:MemberWorkSync'),
});
codexAccountFeature = createCodexAccountFeature({
logger: createLogger('Feature:CodexAccount'),
configManager,
@ -1282,6 +1300,7 @@ async function initializeServices(): Promise<void> {
registerCodexAccountIpc(ipcMain, codexAccountFeature);
registerRecentProjectsIpc(ipcMain, recentProjectsFeature);
registerRuntimeProviderManagementIpc(ipcMain, runtimeProviderManagementFeature);
registerMemberWorkSyncIpc(ipcMain, memberWorkSyncFeature);
// Forward SSH state changes to renderer and HTTP SSE clients
sshConnectionManager.on('state-change', (status: unknown) => {
@ -1335,6 +1354,7 @@ async function startHttpServer(
chunkBuilder: activeContext.chunkBuilder,
dataCache: activeContext.dataCache,
recentProjectsFeature,
memberWorkSyncFeature,
updaterService,
sshConnectionManager,
teamDataService,
@ -1467,6 +1487,7 @@ async function shutdownServices(): Promise<void> {
removeCodexAccountIpc(ipcMain);
removeRecentProjectsIpc(ipcMain);
removeRuntimeProviderManagementIpc(ipcMain);
removeMemberWorkSyncIpc(ipcMain);
});
await runShutdownStep('team backup dispose', () => teamBackupService?.dispose());

View file

@ -1,4 +1,5 @@
import { createCodexAccountBridge } from '@features/codex-account/preload';
import { createMemberWorkSyncBridge } from '@features/member-work-sync/preload';
import { createRecentProjectsBridge } from '@features/recent-projects/preload';
import { createRuntimeProviderManagementBridge } from '@features/runtime-provider-management/preload';
import { createTmuxInstallerBridge } from '@features/tmux-installer/preload';
@ -471,6 +472,7 @@ const electronAPI: ElectronAPI = {
}),
...createRecentProjectsBridge(),
runtimeProviderManagement: createRuntimeProviderManagementBridge(ipcRenderer),
memberWorkSync: createMemberWorkSyncBridge(ipcRenderer),
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
getProjects: () => ipcRenderer.invoke('get-projects'),
getSessions: (projectId: string) => ipcRenderer.invoke('get-sessions', projectId),

View file

@ -82,7 +82,7 @@ import type {
WaterfallData,
WslClaudeRootCandidate,
} from '@shared/types';
import type { AgentConfig } from '@shared/types/api';
import type { AgentConfig, MemberWorkSyncElectronApi } from '@shared/types/api';
import type { EditorAPI, ProjectAPI } from '@shared/types/editor';
import type { TerminalAPI } from '@shared/types/terminal';
@ -1289,6 +1289,20 @@ export class HttpAPIClient implements ElectronAPI {
}),
};
memberWorkSync: MemberWorkSyncElectronApi = {
getStatus: (request) =>
this.get(
`/api/teams/${encodeURIComponent(request.teamName)}/member-work-sync/${encodeURIComponent(
request.memberName
)}`
),
report: (request) =>
this.post(
`/api/teams/${encodeURIComponent(request.teamName)}/member-work-sync/report`,
request
),
};
tmux: TmuxAPI = {
getStatus: async (): Promise<TmuxStatus> => ({
platform: 'unknown',

View file

@ -97,6 +97,12 @@ import type { WaterfallData } from './visualization';
import type { CodexAccountElectronApi } from '@features/codex-account/contracts';
import type { RecentProjectsElectronApi } from '@features/recent-projects/contracts';
import type { RuntimeProviderManagementApi } from '@features/runtime-provider-management/contracts';
import type {
MemberWorkSyncReportRequest,
MemberWorkSyncReportResult,
MemberWorkSyncStatus,
MemberWorkSyncStatusRequest,
} from '@features/member-work-sync/contracts';
import type {
ConversationGroup,
FileChangeEvent,
@ -604,6 +610,11 @@ export interface TeamsAPI {
readFileForToolApproval: (filePath: string) => Promise<ToolApprovalFileContent>;
}
export interface MemberWorkSyncElectronApi {
getStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>;
report(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult>;
}
// =============================================================================
// Cross-Team Communication API
// =============================================================================
@ -876,6 +887,9 @@ export interface ElectronAPI extends RecentProjectsElectronApi, CodexAccountElec
// Runtime nested provider management API
runtimeProviderManagement: RuntimeProviderManagementApi;
// Member actionable-work sync diagnostics API
memberWorkSync: MemberWorkSyncElectronApi;
// tmux runtime diagnostics API
tmux: TmuxAPI;

View file

@ -0,0 +1,173 @@
import { describe, expect, it } from 'vitest';
import { buildActionableWorkAgenda } from '@features/member-work-sync/core/domain';
const hash = (value: string) => `h${value.length}`;
describe('buildActionableWorkAgenda', () => {
it('includes owned pending and in-progress work but excludes completed tasks', () => {
const agenda = buildActionableWorkAgenda({
teamName: 'team-a',
memberName: 'bob',
generatedAt: '2026-04-29T00:00:00.000Z',
members: [{ name: 'bob' }],
tasks: [
{
id: 'task-1',
displayId: '#11111111',
subject: 'Pending',
status: 'pending',
owner: 'bob',
},
{
id: 'task-2',
displayId: '#22222222',
subject: 'In progress',
status: 'in_progress',
owner: 'Bob',
},
{
id: 'task-3',
displayId: '#33333333',
subject: 'Done',
status: 'completed',
owner: 'bob',
},
],
hash,
});
expect(agenda.items.map((item) => [item.taskId, item.kind, item.reason])).toEqual([
['task-1', 'work', 'owned_pending_task'],
['task-2', 'work', 'owned_in_progress_task'],
]);
});
it('assigns active review work to the current-cycle reviewer only', () => {
const agenda = buildActionableWorkAgenda({
teamName: 'team-a',
memberName: 'alice',
generatedAt: '2026-04-29T00:00:00.000Z',
members: [{ name: 'alice' }, { name: 'bob' }],
tasks: [
{
id: 'task-1',
subject: 'Review me',
status: 'in_progress',
owner: 'bob',
reviewState: 'review',
historyEvents: [
{
id: 'evt-1',
type: 'review_requested',
timestamp: '2026-04-29T00:00:00.000Z',
reviewer: 'alice',
},
],
},
],
hash,
});
expect(agenda.items).toHaveLength(1);
expect(agenda.items[0]).toMatchObject({
taskId: 'task-1',
kind: 'review',
assignee: 'alice',
evidence: { reviewer: 'alice' },
});
});
it('does not resurrect a stale reviewer after review was approved', () => {
const agenda = buildActionableWorkAgenda({
teamName: 'team-a',
memberName: 'alice',
generatedAt: '2026-04-29T00:00:00.000Z',
members: [{ name: 'alice' }, { name: 'bob' }],
tasks: [
{
id: 'task-1',
subject: 'Old review',
status: 'in_progress',
owner: 'bob',
reviewState: 'review',
historyEvents: [
{
id: 'evt-1',
type: 'review_requested',
timestamp: '2026-04-29T00:00:00.000Z',
reviewer: 'alice',
},
{
id: 'evt-2',
type: 'review_approved',
timestamp: '2026-04-29T00:01:00.000Z',
actor: 'alice',
},
],
},
],
hash,
});
expect(agenda.items).toEqual([]);
});
it('projects clarification and blocked dependency work for the owner', () => {
const agenda = buildActionableWorkAgenda({
teamName: 'team-a',
memberName: 'bob',
generatedAt: '2026-04-29T00:00:00.000Z',
members: [{ name: 'bob' }],
tasks: [
{
id: 'task-1',
subject: 'Need user',
status: 'in_progress',
owner: 'bob',
needsClarification: 'user',
},
{
id: 'task-2',
subject: 'Blocked',
status: 'in_progress',
owner: 'bob',
blockedBy: ['task-3'],
},
],
hash,
});
expect(agenda.items.map((item) => [item.taskId, item.kind, item.priority])).toEqual([
['task-1', 'clarification', 'needs_clarification'],
['task-2', 'blocked_dependency', 'blocked'],
]);
});
it('keeps fingerprint stable across generatedAt changes and changes it on owner change', () => {
const base = {
teamName: 'team-a',
memberName: 'bob',
members: [{ name: 'bob' }, { name: 'alice' }],
hash,
};
const first = buildActionableWorkAgenda({
...base,
generatedAt: '2026-04-29T00:00:00.000Z',
tasks: [{ id: 'task-1', subject: 'Work', status: 'pending', owner: 'bob' }],
});
const second = buildActionableWorkAgenda({
...base,
generatedAt: '2026-04-29T00:05:00.000Z',
tasks: [{ id: 'task-1', subject: 'Work', status: 'pending', owner: 'bob' }],
});
const third = buildActionableWorkAgenda({
...base,
generatedAt: '2026-04-29T00:05:00.000Z',
tasks: [{ id: 'task-1', subject: 'Work', status: 'pending', owner: 'alice' }],
});
expect(first.fingerprint).toBe(second.fingerprint);
expect(first.fingerprint).not.toBe(third.fingerprint);
});
});

View file

@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest';
import {
buildActionableWorkAgenda,
validateMemberWorkSyncReport,
} from '@features/member-work-sync/core/domain';
const nowIso = '2026-04-29T00:00:00.000Z';
const hash = (value: string) => `h${value.length}`;
function agendaWithWork() {
return buildActionableWorkAgenda({
teamName: 'team-a',
memberName: 'bob',
generatedAt: nowIso,
members: [{ name: 'bob' }],
tasks: [{ id: 'task-1', subject: 'Work', status: 'pending', owner: 'bob' }],
hash,
});
}
describe('validateMemberWorkSyncReport', () => {
it('accepts still_working for the current agenda fingerprint', () => {
const agenda = agendaWithWork();
const result = validateMemberWorkSyncReport({
request: {
teamName: 'team-a',
memberName: 'bob',
state: 'still_working',
agendaFingerprint: agenda.fingerprint,
},
agenda,
nowIso,
activeMemberNames: ['bob'],
});
expect(result.ok).toBe(true);
expect(result.expiresAt).toBe('2026-04-29T00:15:00.000Z');
});
it('rejects caught_up while actionable work remains', () => {
const agenda = agendaWithWork();
const result = validateMemberWorkSyncReport({
request: {
teamName: 'team-a',
memberName: 'bob',
state: 'caught_up',
agendaFingerprint: agenda.fingerprint,
},
agenda,
nowIso,
activeMemberNames: ['bob'],
});
expect(result).toMatchObject({
ok: false,
code: 'caught_up_rejected_actionable_items_exist',
});
});
it('rejects blocked without current blocker evidence', () => {
const agenda = agendaWithWork();
const result = validateMemberWorkSyncReport({
request: {
teamName: 'team-a',
memberName: 'bob',
state: 'blocked',
agendaFingerprint: agenda.fingerprint,
},
agenda,
nowIso,
activeMemberNames: ['bob'],
});
expect(result).toMatchObject({ ok: false, code: 'blocked_without_evidence' });
});
it('rejects stale fingerprints and foreign task ids', () => {
const agenda = agendaWithWork();
const stale = validateMemberWorkSyncReport({
request: {
teamName: 'team-a',
memberName: 'bob',
state: 'still_working',
agendaFingerprint: 'agenda:v1:old',
},
agenda,
nowIso,
activeMemberNames: ['bob'],
});
const foreign = validateMemberWorkSyncReport({
request: {
teamName: 'team-a',
memberName: 'bob',
state: 'still_working',
agendaFingerprint: agenda.fingerprint,
taskIds: ['other-task'],
},
agenda,
nowIso,
activeMemberNames: ['bob'],
});
expect(stale.code).toBe('stale_fingerprint');
expect(foreign.code).toBe('foreign_task_id');
});
it('rejects reserved and inactive member identities', () => {
const agenda = agendaWithWork();
const reserved = validateMemberWorkSyncReport({
request: {
teamName: 'team-a',
memberName: 'user',
state: 'still_working',
agendaFingerprint: agenda.fingerprint,
},
agenda,
nowIso,
activeMemberNames: ['bob'],
});
const inactive = validateMemberWorkSyncReport({
request: {
teamName: 'team-a',
memberName: 'bob',
state: 'still_working',
agendaFingerprint: agenda.fingerprint,
},
agenda,
nowIso,
activeMemberNames: [],
});
expect(reserved.code).toBe('reserved_or_invalid_member');
expect(inactive.code).toBe('member_inactive');
});
});

View file

@ -0,0 +1,172 @@
import { describe, expect, it } from 'vitest';
import {
MemberWorkSyncDiagnosticsReader,
MemberWorkSyncReporter,
type MemberWorkSyncAgendaSourceResult,
type MemberWorkSyncStatusStorePort,
type MemberWorkSyncUseCaseDeps,
} from '@features/member-work-sync/core/application';
import type {
MemberWorkSyncActionableWorkItem,
MemberWorkSyncReportRequest,
MemberWorkSyncStatus,
} from '@features/member-work-sync/contracts';
const workItem: MemberWorkSyncActionableWorkItem = {
taskId: 'task-1',
displayId: '11111111',
subject: 'Ship sync',
kind: 'work',
assignee: 'bob',
priority: 'normal',
reason: 'owned_pending_task',
evidence: {
status: 'pending',
owner: 'bob',
},
};
class MutableClock {
private current = new Date('2026-04-29T00:00:00.000Z');
now(): Date {
return this.current;
}
set(iso: string): void {
this.current = new Date(iso);
}
}
class InMemoryStatusStore implements MemberWorkSyncStatusStorePort {
readonly writes: MemberWorkSyncStatus[] = [];
readonly pendingReports: Array<{ request: MemberWorkSyncReportRequest; reason: string }> = [];
async read(): Promise<MemberWorkSyncStatus | null> {
return this.writes.at(-1) ?? null;
}
async write(status: MemberWorkSyncStatus): Promise<void> {
this.writes.push(status);
}
async appendPendingReport(request: MemberWorkSyncReportRequest, reason: string): Promise<void> {
this.pendingReports.push({ request, reason });
}
}
function createDeps(options?: {
items?: MemberWorkSyncActionableWorkItem[];
activeMemberNames?: string[];
inactive?: boolean;
providerId?: 'opencode' | 'codex';
}) {
const clock = new MutableClock();
const store = new InMemoryStatusStore();
const source: MemberWorkSyncAgendaSourceResult = {
agenda: {
teamName: 'team-a',
memberName: 'bob',
generatedAt: '2026-04-29T00:00:00.000Z',
items: options?.items ?? [workItem],
diagnostics: [],
},
activeMemberNames: options?.activeMemberNames ?? ['bob'],
inactive: options?.inactive ?? false,
...(options?.providerId ? { providerId: options.providerId } : {}),
diagnostics: [],
};
const deps: MemberWorkSyncUseCaseDeps = {
clock,
hash: {
sha256Hex: (value) => `hash-${value.length}`,
},
agendaSource: {
loadAgenda: async () => source,
},
statusStore: store,
reportStore: store,
};
return { clock, deps, source, store };
}
describe('MemberWorkSync use cases', () => {
it('reconciles actionable work into needs_sync without side effects', async () => {
const { deps, store } = createDeps();
const status = await new MemberWorkSyncDiagnosticsReader(deps).execute({
teamName: 'team-a',
memberName: 'bob',
});
expect(status.state).toBe('needs_sync');
expect(status.agenda.items).toEqual([workItem]);
expect(status.diagnostics).toContain('no_current_report');
expect(store.pendingReports).toEqual([]);
});
it('accepts still_working as a bounded lease for the current fingerprint', async () => {
const { clock, deps } = createDeps();
const reader = new MemberWorkSyncDiagnosticsReader(deps);
const reporter = new MemberWorkSyncReporter(deps);
const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' });
const result = await reporter.execute({
teamName: 'team-a',
memberName: 'bob',
state: 'still_working',
agendaFingerprint: current.agenda.fingerprint,
taskIds: ['task-1'],
leaseTtlMs: 120_000,
source: 'test',
});
expect(result.accepted).toBe(true);
expect(result.status.state).toBe('still_working');
clock.set('2026-04-29T00:01:59.000Z');
expect((await reader.execute({ teamName: 'team-a', memberName: 'bob' })).state).toBe(
'still_working'
);
clock.set('2026-04-29T00:02:00.000Z');
const expired = await reader.execute({ teamName: 'team-a', memberName: 'bob' });
expect(expired.state).toBe('needs_sync');
expect(expired.diagnostics).toContain('report_lease_expired');
});
it('rejects stale or unsafe reports and records pending intent only', async () => {
const { deps, store } = createDeps();
const result = await new MemberWorkSyncReporter(deps).execute({
teamName: 'team-a',
memberName: 'bob',
state: 'caught_up',
agendaFingerprint: 'agenda:v1:stale',
source: 'test',
});
expect(result.accepted).toBe(false);
expect(result.code).toBe('stale_fingerprint');
expect(result.status.state).toBe('needs_sync');
expect(store.pendingReports).toHaveLength(1);
expect(store.pendingReports[0].reason).toBe('stale_fingerprint');
});
it('accepts caught_up only when the app-side agenda is empty', async () => {
const { deps } = createDeps({ items: [] });
const reader = new MemberWorkSyncDiagnosticsReader(deps);
const reporter = new MemberWorkSyncReporter(deps);
const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' });
const result = await reporter.execute({
teamName: 'team-a',
memberName: 'bob',
state: 'caught_up',
agendaFingerprint: current.agenda.fingerprint,
source: 'test',
});
expect(result.accepted).toBe(true);
expect(result.status.state).toBe('caught_up');
});
});