import { buildMemberWorkSyncOutboxEnsureInput } from '@features/member-work-sync/core/domain'; import { buildMemberWorkSyncRuntimeTurnSettledEnvironment, createMemberWorkSyncFeature, } from '@features/member-work-sync/main'; import { JsonMemberWorkSyncStore } from '@features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore'; import { MemberWorkSyncStorePaths } from '@features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths'; import { NodeHashAdapter } from '@features/member-work-sync/main/infrastructure/NodeHashAdapter'; import { RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV } from '@features/member-work-sync/main/infrastructure/runtimeTurnSettledEnvironment'; import { getTeamsBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder'; import fs from 'fs'; import os from 'os'; import path from 'path'; import { afterEach, describe, expect, it, vi } from 'vitest'; const tempRoots: string[] = []; function makeTempRoot(): string { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'member-work-sync-feature-')); tempRoots.push(root); return root; } afterEach(() => { setClaudeBasePathOverride(null); for (const root of tempRoots.splice(0)) { fs.rmSync(root, { recursive: true, force: true }); } }); async function seedShadowReadyMetrics(input: { teamsBasePath: string; teamName: string; memberName: string; }): Promise { const metricsPath = path.join( input.teamsBasePath, input.teamName, '.member-work-sync', 'indexes', 'metrics.json' ); await fs.promises.mkdir(path.dirname(metricsPath), { recursive: true }); await fs.promises.writeFile( metricsPath, `${JSON.stringify( { schemaVersion: 2, members: { [input.memberName]: { memberName: input.memberName, state: 'caught_up', agendaFingerprint: 'agenda:v1:seed', actionableCount: 0, evaluatedAt: '2026-01-01T00:00:00.000Z', }, }, recentEvents: Array.from({ length: 20 }, (_, index) => ({ id: `seed-status-${index}`, teamName: input.teamName, memberName: input.memberName, kind: 'status_evaluated', state: 'caught_up', agendaFingerprint: `agenda:v1:seed-${index}`, recordedAt: new Date(Date.UTC(2026, 0, 1, index)).toISOString(), actionableCount: 0, })), }, null, 2 )}\n`, 'utf8' ); } async function seedNonBlockingShadowCollectingMetrics(input: { teamsBasePath: string; teamName: string; memberName: string; }): Promise { const metricsPath = path.join( input.teamsBasePath, input.teamName, '.member-work-sync', 'indexes', 'metrics.json' ); await fs.promises.mkdir(path.dirname(metricsPath), { recursive: true }); await fs.promises.writeFile( metricsPath, `${JSON.stringify( { schemaVersion: 2, members: { [input.memberName]: { memberName: input.memberName, state: 'caught_up', agendaFingerprint: 'agenda:v1:seed', actionableCount: 0, evaluatedAt: '2026-01-01T00:00:00.000Z', }, }, recentEvents: Array.from({ length: 18 }, (_, index) => ({ id: `seed-status-${index}`, teamName: input.teamName, memberName: input.memberName, kind: 'status_evaluated', state: 'caught_up', agendaFingerprint: `agenda:v1:seed-${index}`, recordedAt: new Date(Date.UTC(2026, 0, 1, index * 6)).toISOString(), actionableCount: 0, })), }, null, 2 )}\n`, 'utf8' ); } async function seedBlockingShadowCollectingMetrics(input: { teamsBasePath: string; teamName: string; memberName: string; }): Promise { const nowMs = Date.now(); const firstObservedAt = new Date(nowMs - 1_000).toISOString(); const secondObservedAt = new Date(nowMs).toISOString(); const metricsPath = path.join( input.teamsBasePath, input.teamName, '.member-work-sync', 'indexes', 'metrics.json' ); await fs.promises.mkdir(path.dirname(metricsPath), { recursive: true }); await fs.promises.writeFile( metricsPath, `${JSON.stringify( { schemaVersion: 2, members: { [input.memberName]: { memberName: input.memberName, state: 'needs_sync', agendaFingerprint: 'agenda:v1:seed', actionableCount: 1, evaluatedAt: firstObservedAt, }, }, recentEvents: [ { id: 'seed-status-0', teamName: input.teamName, memberName: input.memberName, kind: 'status_evaluated', state: 'needs_sync', agendaFingerprint: 'agenda:v1:seed', recordedAt: firstObservedAt, actionableCount: 1, }, { id: 'seed-would-nudge-0', teamName: input.teamName, memberName: input.memberName, kind: 'would_nudge', state: 'needs_sync', agendaFingerprint: 'agenda:v1:seed', recordedAt: secondObservedAt, actionableCount: 1, }, ], }, null, 2 )}\n`, 'utf8' ); } async function seedNativeStaleInProgressBlockingMetrics(input: { teamsBasePath: string; teamName: string; memberName: string; agendaFingerprint: string; }): Promise { const nowMs = Date.now(); const staleObservedAt = new Date(nowMs - 6 * 60_000 - 1_000).toISOString(); const metricsPath = path.join( input.teamsBasePath, input.teamName, '.member-work-sync', 'indexes', 'metrics.json' ); await fs.promises.mkdir(path.dirname(metricsPath), { recursive: true }); await fs.promises.writeFile( metricsPath, `${JSON.stringify( { schemaVersion: 2, members: { [input.memberName]: { memberName: input.memberName, state: 'needs_sync', agendaFingerprint: input.agendaFingerprint, actionableCount: 1, evaluatedAt: staleObservedAt, providerId: 'codex', }, }, recentEvents: [ { id: 'native-stale-status', teamName: input.teamName, memberName: input.memberName, kind: 'status_evaluated', state: 'needs_sync', agendaFingerprint: input.agendaFingerprint, recordedAt: staleObservedAt, actionableCount: 1, providerId: 'codex', }, ...Array.from({ length: 12 }, (_, index) => ({ id: `native-stale-would-nudge-${index}`, teamName: input.teamName, memberName: input.memberName, kind: 'would_nudge', state: 'needs_sync', agendaFingerprint: input.agendaFingerprint, recordedAt: new Date(nowMs - 5 * 60_000 + index * 5_000).toISOString(), actionableCount: 1, providerId: 'codex', })), ], }, null, 2 )}\n`, 'utf8' ); } async function waitForAssertion(assertion: () => Promise | void): Promise { const deadline = Date.now() + 5_000; let lastError: unknown; while (Date.now() < deadline) { try { await assertion(); return; } catch (error) { lastError = error; await new Promise((resolve) => setTimeout(resolve, 10)); } } if (lastError) { throw lastError; } await assertion(); } async function waitForQueueIdle( feature: ReturnType ): Promise { await waitForAssertion(() => { expect(feature.getQueueDiagnostics()).toMatchObject({ queued: 0, running: 0, }); }); } async function readInboxMessages(input: { teamsBasePath: string; teamName: string; memberName: string; }): Promise> { const inboxPath = path.join( input.teamsBasePath, input.teamName, 'inboxes', `${input.memberName}.json` ); let raw: string; try { raw = await fs.promises.readFile(inboxPath, 'utf8'); } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code === 'ENOENT' || code === 'EISDIR') { return []; } throw error; } const parsed = JSON.parse(raw) as unknown; return Array.isArray(parsed) ? parsed.filter( (item): item is { messageId?: string; messageKind?: string; text?: string } => Boolean(item) && typeof item === 'object' ) : []; } async function readMemberOutboxItems(input: { teamsBasePath: string; teamName: string; memberName: string; }): Promise< Record< string, { status?: string; lastError?: string; nextAttemptAt?: string; deliveredMessageId?: string } > > { const outboxPath = path.join( input.teamsBasePath, input.teamName, 'members', input.memberName, '.member-work-sync', 'outbox.json' ); let raw: string; try { raw = await fs.promises.readFile(outboxPath, 'utf8'); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return {}; } throw error; } const parsed = JSON.parse(raw) as { items?: Record; }; return parsed.items ?? {}; } async function forceRetryableOutboxDue(input: { teamsBasePath: string; teamName: string; memberName: string; nextAttemptAt: string; }): Promise { const outboxPath = path.join( input.teamsBasePath, input.teamName, 'members', input.memberName, '.member-work-sync', 'outbox.json' ); const parsed = JSON.parse(await fs.promises.readFile(outboxPath, 'utf8')) as { items?: Record; }; let touched = 0; for (const item of Object.values(parsed.items ?? {})) { if (item.status === 'failed_retryable') { item.nextAttemptAt = input.nextAttemptAt; item.updatedAt = input.nextAttemptAt; touched += 1; } } expect(touched).toBeGreaterThan(0); await fs.promises.writeFile(outboxPath, `${JSON.stringify(parsed, null, 2)}\n`, 'utf8'); await fs.promises.rm( path.join( input.teamsBasePath, input.teamName, '.member-work-sync', 'indexes', 'outbox-index.json' ), { force: true } ); } async function backdateDeliveredOutboxItems(input: { teamsBasePath: string; teamName: string; memberName: string; updatedAt: string; }): Promise { const outboxPath = path.join( input.teamsBasePath, input.teamName, 'members', input.memberName, '.member-work-sync', 'outbox.json' ); const parsed = JSON.parse(await fs.promises.readFile(outboxPath, 'utf8')) as { items?: Record; }; const touchedIds: string[] = []; for (const [id, item] of Object.entries(parsed.items ?? {})) { if (item.status === 'delivered') { item.updatedAt = input.updatedAt; touchedIds.push(id); } } expect(touchedIds.length).toBeGreaterThan(0); await fs.promises.writeFile(outboxPath, `${JSON.stringify(parsed, null, 2)}\n`, 'utf8'); const indexPath = path.join( input.teamsBasePath, input.teamName, '.member-work-sync', 'indexes', 'outbox-index.json' ); const index = JSON.parse(await fs.promises.readFile(indexPath, 'utf8')) as { items?: Record; }; for (const id of touchedIds) { if (index.items?.[id]) { index.items[id].updatedAt = input.updatedAt; } } await fs.promises.writeFile(indexPath, `${JSON.stringify(index, null, 2)}\n`, 'utf8'); } describe('createMemberWorkSyncFeature composition', () => { it('schedules proof-missing recovery through the work-sync queue', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-a'; const memberName = 'bob'; const feature = createMemberWorkSyncFeature({ teamsBasePath, configReader: { getConfig: vi.fn(async () => ({ name: teamName, members: [{ name: memberName }], })), } as never, taskReader: { getTasks: vi.fn(async () => []) } as never, kanbanManager: { getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {} })), } as never, membersMetaStore: { getMembers: vi.fn(async () => []) } as never, }); try { await expect( feature.scheduleProofMissingRecovery({ teamName, memberName, originalMessageId: 'message-1', taskRefs: [{ taskId: 'task-1', displayId: '11111111', teamName }], reason: 'OpenCode proof missing', }) ).resolves.toMatchObject({ scheduled: true, reason: 'scheduled', intentKey: 'proof-missing:message-1', }); expect(feature.getQueueDiagnostics()).toMatchObject({ queued: 1, queuedItems: [ { teamName, memberName, triggerReasons: ['proof_missing_recovery'], }, ], }); await expect(readInboxMessages({ teamsBasePath, teamName, memberName })).resolves.toEqual( [] ); } finally { await feature.dispose(); } }); it('coalesces proof-missing recovery when a recent matching outbox item exists', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-a'; const memberName = 'bob'; const feature = createMemberWorkSyncFeature({ teamsBasePath, configReader: { getConfig: vi.fn(async () => ({ name: teamName, members: [{ name: memberName }], })), } as never, taskReader: { getTasks: vi.fn(async () => []) } as never, kanbanManager: { getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {} })), } as never, membersMetaStore: { getMembers: vi.fn(async () => []) } as never, }); try { const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath)); await store.ensurePending({ id: 'member-work-sync:team-a:bob:proof-missing:message-1', teamName, memberName, agendaFingerprint: 'agenda:v1:test', payloadHash: 'payload-hash', payload: { from: 'system', to: memberName, messageKind: 'member_work_sync_nudge', source: 'member-work-sync', actionMode: 'do', workSyncIntent: 'agenda_sync', workSyncIntentKey: 'proof-missing:message-1', text: 'Recover proof', taskRefs: [{ taskId: 'task-1', displayId: '11111111', teamName }], }, nowIso: new Date().toISOString(), }); await expect( feature.scheduleProofMissingRecovery({ teamName, memberName, originalMessageId: 'message-1', taskRefs: [{ taskId: 'task-1', displayId: '11111111', teamName }], }) ).resolves.toMatchObject({ scheduled: false, reason: 'coalesced_recent', existingOutboxId: 'member-work-sync:team-a:bob:proof-missing:message-1', }); expect(feature.getQueueDiagnostics()).toMatchObject({ queued: 0 }); } finally { await feature.dispose(); } }); it('does not schedule broad proof-missing recovery without task refs', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-a'; const memberName = 'bob'; const feature = createMemberWorkSyncFeature({ teamsBasePath, configReader: { getConfig: vi.fn(async () => ({ name: teamName, members: [{ name: memberName }], })), } as never, taskReader: { getTasks: vi.fn(async () => []) } as never, kanbanManager: { getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {} })), } as never, membersMetaStore: { getMembers: vi.fn(async () => []) } as never, }); try { await expect( feature.scheduleProofMissingRecovery({ teamName, memberName, originalMessageId: 'message-1', }) ).resolves.toMatchObject({ scheduled: false, reason: 'invalid', }); expect(feature.getQueueDiagnostics()).toMatchObject({ queued: 0 }); await expect(readInboxMessages({ teamsBasePath, teamName, memberName })).resolves.toEqual( [] ); } finally { await feature.dispose(); } }); it('dispatches a due nudge through the real outbox and inbox by default', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-a'; const memberName = 'bob'; const feature = createMemberWorkSyncFeature({ teamsBasePath, configReader: { getConfig: vi.fn(async () => ({ name: teamName, members: [{ name: memberName }], })), } as never, taskReader: { getTasks: vi.fn(async () => [ { id: 'task-1', displayId: '11111111', subject: 'Ship sync', status: 'pending', owner: memberName, }, ]), } as never, kanbanManager: { getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {}, })), } as never, membersMetaStore: { getMembers: vi.fn(async () => []), } as never, }); try { await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); const status = await feature.refreshStatus({ teamName, memberName }); expect(status).toMatchObject({ state: 'needs_sync', shadow: { wouldNudge: true }, }); await expect(feature.getMetrics({ teamName })).resolves.toMatchObject({ phase2Readiness: { state: 'shadow_ready' }, }); const outboxInput = buildMemberWorkSyncOutboxEnsureInput({ status, hash: new NodeHashAdapter(), nowIso: status.evaluatedAt, }); expect(outboxInput).not.toBeNull(); const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath)); await expect(store.ensurePending(outboxInput!)).resolves.toMatchObject({ ok: true, outcome: 'existing', }); await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({ claimed: 1, delivered: 1, superseded: 0, retryable: 0, terminal: 0, }); await expect( fs.promises.readFile(path.join(teamsBasePath, teamName, 'inboxes', `${memberName}.json`), { encoding: 'utf8', }) ).resolves.toContain(outboxInput!.id); } finally { await feature.dispose(); } }); it('dispatches existing due nudges before background stale refresh work', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-a'; const memberName = 'bob'; let postSeedGetConfigCalls = 0; let refreshBlocked = false; let releaseRefresh: () => void = () => undefined; const refreshBlocker = new Promise((resolve) => { releaseRefresh = resolve; }); const getConfig = vi.fn(async () => { postSeedGetConfigCalls += 1; if (postSeedGetConfigCalls === 2) { refreshBlocked = true; await refreshBlocker; } return { name: teamName, members: [{ name: memberName }], }; }); const feature = createMemberWorkSyncFeature({ teamsBasePath, configReader: { getConfig, } as never, taskReader: { getTasks: vi.fn(async () => [ { id: 'task-1', displayId: '11111111', subject: 'Ship sync', status: 'pending', owner: memberName, }, ]), } as never, kanbanManager: { getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {}, })), } as never, membersMetaStore: { getMembers: vi.fn(async () => []), } as never, }); let dispatchPromise: Promise | null = null; try { await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); const status = await feature.refreshStatus({ teamName, memberName }); const outboxInput = buildMemberWorkSyncOutboxEnsureInput({ status, hash: new NodeHashAdapter(), nowIso: status.evaluatedAt, }); expect(outboxInput).not.toBeNull(); const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath)); await expect(store.ensurePending(outboxInput!)).resolves.toMatchObject({ ok: true, outcome: 'existing', }); postSeedGetConfigCalls = 0; dispatchPromise = feature.dispatchDueNudges([teamName]); await waitForAssertion(() => { expect(refreshBlocked).toBe(true); }); await expect(readInboxMessages({ teamsBasePath, teamName, memberName })).resolves.toEqual( expect.arrayContaining([expect.objectContaining({ messageId: outboxInput!.id })]) ); releaseRefresh(); await expect(dispatchPromise).resolves.toMatchObject({ claimed: 1, delivered: 1, }); } finally { releaseRefresh(); await dispatchPromise?.catch(() => undefined); await feature.dispose(); } }); it('suppresses queued proof-missing recovery when the original delivery is no longer proof-missing', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-a'; const memberName = 'bob'; const proofMissingRecoveryGuard = { shouldDispatch: vi.fn(async () => ({ ok: false as const, reason: 'proof_missing_recovery_suppressed', retryable: false, })), }; const feature = createMemberWorkSyncFeature({ teamsBasePath, configReader: { getConfig: vi.fn(async () => ({ name: teamName, members: [{ name: memberName }], })), } as never, taskReader: { getTasks: vi.fn(async () => [ { id: 'task-1', displayId: '11111111', subject: 'Ship sync', status: 'pending', owner: memberName, }, ]), } as never, kanbanManager: { getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {}, })), } as never, membersMetaStore: { getMembers: vi.fn(async () => []), } as never, proofMissingRecoveryGuard, }); try { await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); const status = await feature.refreshStatus({ teamName, memberName }); const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath)); await expect( store.ensurePending({ id: 'member-work-sync:team-a:bob:proof-missing:message-1', teamName, memberName, agendaFingerprint: status.agenda.fingerprint, payloadHash: 'payload-hash', payload: { from: 'system', to: memberName, messageKind: 'member_work_sync_nudge', source: 'member-work-sync', actionMode: 'do', workSyncIntent: 'agenda_sync', workSyncIntentKey: 'proof-missing:message-1', text: 'Recover proof', taskRefs: [{ taskId: 'task-1', displayId: '11111111', teamName }], }, nowIso: status.evaluatedAt, }) ).resolves.toMatchObject({ ok: true, outcome: 'created', }); await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({ claimed: 2, delivered: 1, superseded: 1, retryable: 0, terminal: 0, }); expect(proofMissingRecoveryGuard.shouldDispatch).toHaveBeenCalledWith( expect.objectContaining({ teamName, memberName, intentKey: 'proof-missing:message-1', originalMessageId: 'message-1', taskIds: ['task-1'], }) ); const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( (message) => message.messageKind === 'member_work_sync_nudge' ); expect(nudges).toHaveLength(1); expect(nudges[0]?.text).toContain('Required sync action'); expect(nudges[0]?.text).not.toContain('Recover proof'); } finally { await feature.dispose(); } }); it('does not deliver pending nudges until the team is ready for nudge dispatch', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-a'; const memberName = 'bob'; let canDispatchNudges = false; const feature = createMemberWorkSyncFeature({ teamsBasePath, configReader: { getConfig: vi.fn(async () => ({ name: teamName, members: [{ name: memberName }], })), } as never, taskReader: { getTasks: vi.fn(async () => [ { id: 'task-1', displayId: '11111111', subject: 'Ship sync', status: 'pending', owner: memberName, }, ]), } as never, kanbanManager: { getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {}, })), } as never, membersMetaStore: { getMembers: vi.fn(async () => []), } as never, canDispatchNudges: vi.fn(async () => canDispatchNudges), }); try { await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); const status = await feature.refreshStatus({ teamName, memberName }); const outboxInput = buildMemberWorkSyncOutboxEnsureInput({ status, hash: new NodeHashAdapter(), nowIso: status.evaluatedAt, }); expect(outboxInput).not.toBeNull(); const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath)); await expect(store.ensurePending(outboxInput!)).resolves.toMatchObject({ ok: true, outcome: 'existing', }); await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({ claimed: 0, delivered: 0, superseded: 0, retryable: 0, terminal: 0, }); await expect(readInboxMessages({ teamsBasePath, teamName, memberName })).resolves.toEqual([]); await expect( readMemberOutboxItems({ teamsBasePath, teamName, memberName }) ).resolves.toMatchObject({ [outboxInput!.id]: { status: 'pending' }, }); canDispatchNudges = true; await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({ claimed: 1, delivered: 1, superseded: 0, retryable: 0, terminal: 0, }); await expect( readInboxMessages({ teamsBasePath, teamName, memberName }) ).resolves.toMatchObject([{ messageId: outboxInput!.id }]); } finally { await feature.dispose(); } }); it('plans and dispatches due nudges after queued reconcile by default', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-a'; const memberName = 'bob'; const feature = createMemberWorkSyncFeature({ teamsBasePath, configReader: { getConfig: vi.fn(async () => ({ name: teamName, members: [{ name: memberName }], })), } as never, taskReader: { getTasks: vi.fn(async () => [ { id: 'task-1', displayId: '11111111', subject: 'Ship sync', status: 'pending', owner: memberName, }, ]), } as never, kanbanManager: { getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {}, })), } as never, membersMetaStore: { getMembers: vi.fn(async () => []), } as never, queueQuietWindowMs: 1, }); try { await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1', } as never); await waitForAssertion(async () => { expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 }); const inbox = await fs.promises.readFile( path.join(teamsBasePath, teamName, 'inboxes', `${memberName}.json`), 'utf8' ); expect(inbox).toContain('member_work_sync_nudge'); expect(inbox).toContain(`member-work-sync:${teamName}:${memberName}:agenda:v1:`); }); } finally { await feature.dispose(); } }); it('drains runtime turn-settled files into queued reconcile and nudge delivery', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-a'; const memberName = 'bob'; const feature = createMemberWorkSyncFeature({ teamsBasePath, configReader: { getConfig: vi.fn(async () => ({ name: teamName, members: [{ name: memberName, providerId: 'opencode' }], })), } as never, taskReader: { getTasks: vi.fn(async () => [ { id: 'task-1', displayId: '11111111', subject: 'Ship sync after settled turn', status: 'pending', owner: memberName, }, ]), } as never, kanbanManager: { getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {}, })), } as never, membersMetaStore: { getMembers: vi.fn(async () => []), } as never, isTeamActive: vi.fn(async () => true), queueQuietWindowMs: 1, resolveControlUrl: vi.fn(async () => 'http://127.0.0.1:43123'), }); try { await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); const env = await feature.buildRuntimeTurnSettledEnvironment({ provider: 'opencode' }); const spoolRoot = env?.[RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]; expect(spoolRoot).toBeTruthy(); const eventFileName = '20260505T120000000Z-test.opencode.json'; await fs.promises.writeFile( path.join(spoolRoot!, 'incoming', eventFileName), `${JSON.stringify({ schemaVersion: 1, provider: 'opencode', source: 'agent-teams-orchestrator-opencode', eventName: 'runtime_turn_settled', hookEventName: 'Stop', sessionId: 'ses-opencode-1', runtimePromptMessageId: 'msg_123', laneId: 'secondary:opencode:bob', memberName, teamName, cwd: claudeRoot, outcome: 'success', recordedAt: '2026-05-05T12:00:00.000Z', })}\n`, 'utf8' ); const drain = await feature.drainRuntimeTurnSettledEvents(); expect(drain).toMatchObject({ invalid: 0, unresolved: 0, }); await waitForAssertion(async () => { expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 }); const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( (message) => message.messageKind === 'member_work_sync_nudge' ); expect(nudges).toHaveLength(1); expect(nudges[0]?.text).toContain('11111111'); const status = await feature.getStatus({ teamName, memberName }); expect(status).toMatchObject({ state: 'needs_sync', providerId: 'opencode', shadow: { wouldNudge: true, triggerReasons: ['turn_settled'], }, }); }); const processedMeta = JSON.parse( await fs.promises.readFile( path.join(spoolRoot!, 'processed', `${eventFileName}.meta.json`), 'utf8' ) ) as { outcome?: string; teamName?: string; memberName?: string }; expect(processedMeta).toMatchObject({ outcome: 'enqueued', teamName, memberName, }); } finally { await feature.dispose(); } }); it('delivers a status-only recovery nudge when a delivered Codex nudge settles without report proof', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-codex-status-only-recovery'; const memberName = 'bob'; const feature = createMemberWorkSyncFeature({ teamsBasePath, configReader: { getConfig: vi.fn(async () => ({ name: teamName, members: [{ name: memberName, providerId: 'codex' }], })), } as never, taskReader: { getTasks: vi.fn(async () => [ { id: 'task-1', displayId: '11111111', subject: 'Ship sync after status-only turn', status: 'pending', owner: memberName, }, ]), } as never, kanbanManager: { getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {}, })), } as never, membersMetaStore: { getMembers: vi.fn(async () => []), } as never, isTeamActive: vi.fn(async () => true), queueQuietWindowMs: 1, resolveControlUrl: vi.fn(async () => 'http://127.0.0.1:43123'), }); try { await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); await waitForAssertion(async () => { const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( (message) => message.messageKind === 'member_work_sync_nudge' ); expect(nudges).toHaveLength(1); expect(nudges[0]?.text).toContain('11111111'); expect(nudges[0]?.text).toContain('controlUrl "http://127.0.0.1:43123"'); const outboxItems = Object.values( await readMemberOutboxItems({ teamsBasePath, teamName, memberName }) ); expect(outboxItems).toEqual([ expect.objectContaining({ status: 'delivered', }), ]); const deliveredOutboxItem = outboxItems[0] as { payload?: { workSyncIntentKey?: string }; }; expect(deliveredOutboxItem.payload?.workSyncIntentKey).toBeUndefined(); }); feature.noteTeamChange({ type: 'tool-activity', teamName, detail: JSON.stringify({ action: 'start', activity: { memberName, toolUseId: 'status-tool-1', toolName: 'member_work_sync_status', startedAt: '2026-05-05T12:00:00.000Z', source: 'runtime', }, }), } as never); feature.noteTeamChange({ type: 'tool-activity', teamName, detail: JSON.stringify({ action: 'finish', memberName, toolUseId: 'status-tool-1', finishedAt: new Date().toISOString(), }), } as never); const env = await feature.buildRuntimeTurnSettledEnvironment({ provider: 'codex' }); const spoolRoot = env?.[RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]; expect(spoolRoot).toBeTruthy(); const eventFileName = '20260505T120001000Z-status-only.codex.json'; await fs.promises.writeFile( path.join(spoolRoot!, 'incoming', eventFileName), `${JSON.stringify({ schemaVersion: 1, provider: 'codex', source: 'agent-teams-orchestrator-codex-native', eventName: 'runtime_turn_settled', hookEventName: 'Stop', sessionId: 'ses-codex-1', memberName, teamName, cwd: claudeRoot, outcome: 'success', recordedAt: '2026-05-05T12:00:01.000Z', })}\n`, 'utf8' ); await expect(feature.drainRuntimeTurnSettledEvents()).resolves.toMatchObject({ invalid: 0, unresolved: 0, }); await waitForAssertion(async () => { const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( (message) => message.messageKind === 'member_work_sync_nudge' ); expect(nudges).toHaveLength(2); expect(nudges[1]?.messageId).toContain('status-only'); expect(nudges[1]?.text).toContain('previous work-sync turn appears to have stopped'); expect( Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })) ).toEqual( expect.arrayContaining([ expect.objectContaining({ status: 'delivered', payload: expect.objectContaining({ workSyncIntentKey: expect.stringContaining('status-only:'), }), }), ]) ); }); } finally { await feature.dispose(); } }); it('delivers targeted OpenCode nudges during shadow collection and schedules a delivery wake', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-opencode-targeted'; const memberName = 'alice'; const nudgeDeliveryWake = { schedule: vi.fn(async () => undefined), }; const feature = createMemberWorkSyncFeature({ teamsBasePath, configReader: { getConfig: vi.fn(async () => ({ name: teamName, members: [{ name: memberName, providerId: 'opencode' }], })), } as never, taskReader: { getTasks: vi.fn(async () => [ { id: 'task-1', displayId: '11111111', subject: 'Ship OpenCode targeted nudge', status: 'pending', owner: memberName, }, ]), } as never, kanbanManager: { getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {}, })), } as never, membersMetaStore: { getMembers: vi.fn(async () => []), } as never, isTeamActive: vi.fn(async () => true), nudgeDeliveryWake, queueQuietWindowMs: 1, }); try { await seedNonBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName }); feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); await waitForAssertion(async () => { expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 }); const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( (message) => message.messageKind === 'member_work_sync_nudge' ); expect(nudges).toHaveLength(1); expect(nudges[0]?.text).toContain('11111111'); expect(nudgeDeliveryWake.schedule).toHaveBeenCalledTimes(1); expect(nudgeDeliveryWake.schedule).toHaveBeenCalledWith({ teamName, memberName, messageId: nudges[0]?.messageId, providerId: 'opencode', reason: 'member_work_sync_nudge_inserted', delayMs: 500, }); await expect(feature.getMetrics({ teamName })).resolves.toMatchObject({ phase2Readiness: { state: 'collecting_shadow_data' }, }); await expect(feature.getStatus({ teamName, memberName })).resolves.toMatchObject({ state: 'needs_sync', providerId: 'opencode', shadow: { wouldNudge: true }, }); expect( Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })) ).toEqual([ expect.objectContaining({ status: 'delivered', deliveredMessageId: nudges[0]?.messageId, }), ]); }); const journal = await fs.promises.readFile( path.join( teamsBasePath, teamName, 'members', memberName, '.member-work-sync', 'journal.jsonl' ), 'utf8' ); expect(journal).toContain('"event":"nudge_delivered"'); expect(journal).not.toContain('"reason":"phase2_not_ready"'); } finally { await feature.dispose(); } }); it('delivers Codex inbox-watch nudges while shadow data is still collecting', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-codex-shadow-gated'; const memberName = 'bob'; const nudgeDeliveryWake = { schedule: vi.fn(async () => undefined), }; const feature = createMemberWorkSyncFeature({ teamsBasePath, configReader: { getConfig: vi.fn(async () => ({ name: teamName, members: [{ name: memberName, providerId: 'codex' }], })), } as never, taskReader: { getTasks: vi.fn(async () => [ { id: 'task-1', displayId: '11111111', subject: 'Keep Codex gated during shadow collection', status: 'pending', owner: memberName, }, ]), } as never, kanbanManager: { getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {}, })), } as never, membersMetaStore: { getMembers: vi.fn(async () => []), } as never, isTeamActive: vi.fn(async () => true), nudgeDeliveryWake, queueQuietWindowMs: 1, }); try { await seedNonBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName }); feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); await waitForAssertion(async () => { expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 }); const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( (message) => message.messageKind === 'member_work_sync_nudge' ); expect(nudges).toHaveLength(1); expect(nudges[0]?.text).toContain('11111111'); expect(nudges[0]?.text).toContain('mcp__agent-teams__member_work_sync_report'); expect(nudgeDeliveryWake.schedule).toHaveBeenCalledWith({ teamName, memberName, messageId: nudges[0]?.messageId, providerId: 'codex', reason: 'member_work_sync_nudge_inserted', delayMs: 500, }); expect( Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })) ).toEqual([ expect.objectContaining({ status: 'delivered', deliveredMessageId: nudges[0]?.messageId, }), ]); await expect(feature.getMetrics({ teamName })).resolves.toMatchObject({ phase2Readiness: { state: 'collecting_shadow_data' }, }); await expect(feature.getStatus({ teamName, memberName })).resolves.toMatchObject({ state: 'needs_sync', providerId: 'codex', shadow: { wouldNudge: true }, }); }); const journal = await fs.promises.readFile( path.join( teamsBasePath, teamName, 'members', memberName, '.member-work-sync', 'journal.jsonl' ), 'utf8' ); expect(journal).toContain('"event":"nudge_delivered"'); expect(journal).not.toContain('"reason":"phase2_not_ready"'); } finally { await feature.dispose(); } }); it('delivers native stale in-progress recovery nudges despite noisy global metrics', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-native-stale-in-progress'; const memberName = 'alice'; const nudgeDeliveryWake = { schedule: vi.fn(async () => undefined), }; const feature = createMemberWorkSyncFeature({ teamsBasePath, configReader: { getConfig: vi.fn(async () => ({ name: teamName, members: [{ name: memberName, providerId: 'codex' }], })), } as never, taskReader: { getTasks: vi.fn(async () => [ { id: 'task-1', displayId: '11111111', subject: 'Review landing', status: 'in_progress', owner: memberName, }, ]), } as never, kanbanManager: { getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {}, })), } as never, membersMetaStore: { getMembers: vi.fn(async () => []), } as never, isTeamActive: vi.fn(async () => true), nudgeDeliveryWake, queueQuietWindowMs: 1, }); try { feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); let agendaFingerprint = ''; await waitForAssertion(async () => { const status = await feature.getStatus({ teamName, memberName }); expect(status).toMatchObject({ state: 'needs_sync', providerId: 'codex', diagnostics: expect.arrayContaining(['no_current_report']), agenda: { items: [ expect.objectContaining({ reason: 'owned_in_progress_task', evidence: expect.objectContaining({ status: 'in_progress' }), }), ], }, }); agendaFingerprint = status.agenda.fingerprint; }); expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toEqual([]); expect(nudgeDeliveryWake.schedule).not.toHaveBeenCalled(); await seedNativeStaleInProgressBlockingMetrics({ teamsBasePath, teamName, memberName, agendaFingerprint, }); feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); await waitForAssertion(async () => { const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( (message) => message.messageKind === 'member_work_sync_nudge' ); expect(nudges).toHaveLength(1); expect(nudges[0]?.text).toContain('Work sync check'); expect(nudges[0]?.text).toContain('11111111'); expect(nudgeDeliveryWake.schedule).toHaveBeenCalledTimes(1); expect(nudgeDeliveryWake.schedule).toHaveBeenCalledWith({ teamName, memberName, messageId: nudges[0]?.messageId, providerId: 'codex', reason: 'member_work_sync_nudge_inserted', delayMs: 500, }); await expect(feature.getMetrics({ teamName })).resolves.toMatchObject({ phase2Readiness: { reasons: expect.arrayContaining(['would_nudge_rate_high']), }, }); expect( Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })) ).toEqual([ expect.objectContaining({ status: 'delivered', deliveredMessageId: nudges[0]?.messageId, }), ]); }); const journal = await fs.promises.readFile( path.join( teamsBasePath, teamName, 'members', memberName, '.member-work-sync', 'journal.jsonl' ), 'utf8' ); expect(journal).toContain('"event":"nudge_delivered"'); expect(journal).toContain('"reason":"created"'); } finally { await feature.dispose(); } }); it('delivers native stale pending-work recovery nudges despite noisy global metrics', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-native-stale-pending'; const memberName = 'alice'; const nudgeDeliveryWake = { schedule: vi.fn(async () => undefined), }; const feature = createMemberWorkSyncFeature({ teamsBasePath, configReader: { getConfig: vi.fn(async () => ({ name: teamName, members: [{ name: memberName, providerId: 'codex' }], })), } as never, taskReader: { getTasks: vi.fn(async () => [ { id: 'task-1', displayId: '11111111', subject: 'Start assigned pending work', status: 'pending', owner: memberName, }, ]), } as never, kanbanManager: { getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {}, })), } as never, membersMetaStore: { getMembers: vi.fn(async () => []), } as never, isTeamActive: vi.fn(async () => true), nudgeDeliveryWake, queueQuietWindowMs: 1, }); try { feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); let agendaFingerprint = ''; await waitForAssertion(async () => { const status = await feature.getStatus({ teamName, memberName }); expect(status).toMatchObject({ state: 'needs_sync', providerId: 'codex', diagnostics: expect.arrayContaining(['no_current_report']), agenda: { items: [ expect.objectContaining({ reason: 'owned_pending_task', evidence: expect.objectContaining({ status: 'pending' }), }), ], }, }); agendaFingerprint = status.agenda.fingerprint; }); expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toEqual([]); expect(nudgeDeliveryWake.schedule).not.toHaveBeenCalled(); await seedNativeStaleInProgressBlockingMetrics({ teamsBasePath, teamName, memberName, agendaFingerprint, }); feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); await waitForAssertion(async () => { const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( (message) => message.messageKind === 'member_work_sync_nudge' ); expect(nudges).toHaveLength(1); expect(nudges[0]?.text).toContain('Work sync check'); expect(nudges[0]?.text).toContain('11111111'); expect(nudgeDeliveryWake.schedule).toHaveBeenCalledTimes(1); await expect(feature.getMetrics({ teamName })).resolves.toMatchObject({ phase2Readiness: { reasons: expect.arrayContaining(['would_nudge_rate_high']), }, }); }); const journal = await fs.promises.readFile( path.join( teamsBasePath, teamName, 'members', memberName, '.member-work-sync', 'journal.jsonl' ), 'utf8' ); expect(journal).toContain('"event":"nudge_delivered"'); expect(journal).not.toContain('"reason":"blocking_metrics"'); } finally { await feature.dispose(); } }); it('delivers still-stuck recovery from json outbox when a delivered agenda nudge gets no report', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-json-still-stuck-recovery'; const memberName = 'alice'; const nudgeDeliveryWake = { schedule: vi.fn(async () => undefined), }; const feature = createMemberWorkSyncFeature({ teamsBasePath, configReader: { getConfig: vi.fn(async () => ({ name: teamName, members: [{ name: memberName, providerId: 'codex' }], })), } as never, taskReader: { getTasks: vi.fn(async () => [ { id: 'task-1', displayId: '11111111', subject: 'Recover ignored delivered sync', status: 'pending', owner: memberName, }, ]), } as never, kanbanManager: { getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {}, })), } as never, membersMetaStore: { getMembers: vi.fn(async () => []), } as never, isTeamActive: vi.fn(async () => true), nudgeDeliveryWake, queueQuietWindowMs: 1, }); try { await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); let agendaFingerprint = ''; await waitForAssertion(async () => { const status = await feature.getStatus({ teamName, memberName }); agendaFingerprint = status.agenda.fingerprint; const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( (message) => message.messageKind === 'member_work_sync_nudge' ); expect(nudges).toHaveLength(1); expect( Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })) ).toEqual([ expect.objectContaining({ status: 'delivered', deliveredMessageId: nudges[0]?.messageId, }), ]); }); await backdateDeliveredOutboxItems({ teamsBasePath, teamName, memberName, updatedAt: new Date(Date.now() - 10 * 60_000).toISOString(), }); await seedNativeStaleInProgressBlockingMetrics({ teamsBasePath, teamName, memberName, agendaFingerprint, }); feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); await waitForAssertion(async () => { const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( (message) => message.messageKind === 'member_work_sync_nudge' ); expect(nudges).toHaveLength(2); expect(nudges[1]?.messageId).toContain('agenda-sync-still-stuck'); expect(nudges[1]?.text).toContain('still no accepted member_work_sync_report'); expect( Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })) ).toEqual( expect.arrayContaining([ expect.objectContaining({ status: 'delivered', deliveredMessageId: nudges[1]?.messageId, }), ]) ); }); } finally { await feature.dispose(); } }); it('delivers targeted OpenCode nudges even when global phase2 metrics are noisy', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-opencode-blocking-metrics'; const memberName = 'alice'; const nudgeDeliveryWake = { schedule: vi.fn(async () => undefined), }; const feature = createMemberWorkSyncFeature({ teamsBasePath, configReader: { getConfig: vi.fn(async () => ({ name: teamName, members: [{ name: memberName, providerId: 'opencode' }], })), } as never, taskReader: { getTasks: vi.fn(async () => [ { id: 'task-1', displayId: '11111111', subject: 'Nudge OpenCode despite noisy global metrics', status: 'pending', owner: memberName, }, ]), } as never, kanbanManager: { getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {}, })), } as never, membersMetaStore: { getMembers: vi.fn(async () => []), } as never, isTeamActive: vi.fn(async () => true), nudgeDeliveryWake, queueQuietWindowMs: 1, }); try { await seedBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName }); feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); await waitForAssertion(async () => { expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 }); const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( (message) => message.messageKind === 'member_work_sync_nudge' ); expect(nudges).toHaveLength(1); expect(nudges[0]?.text).toContain('11111111'); expect(nudgeDeliveryWake.schedule).toHaveBeenCalledTimes(1); expect(nudgeDeliveryWake.schedule).toHaveBeenCalledWith({ teamName, memberName, messageId: nudges[0]?.messageId, providerId: 'opencode', reason: 'member_work_sync_nudge_inserted', delayMs: 500, }); await expect(feature.getMetrics({ teamName })).resolves.toMatchObject({ phase2Readiness: { reasons: expect.arrayContaining(['would_nudge_rate_high']), }, }); }); const journal = await fs.promises.readFile( path.join( teamsBasePath, teamName, 'members', memberName, '.member-work-sync', 'journal.jsonl' ), 'utf8' ); expect(journal).toContain('"event":"nudge_delivered"'); expect(journal).not.toContain('"reason":"blocking_metrics"'); } finally { await feature.dispose(); } }); it('delivers targeted lead nudges even when global phase2 metrics are noisy', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-lead-blocking-metrics'; const memberName = 'team-lead'; const nudgeDeliveryWake = { schedule: vi.fn(async () => undefined), }; const feature = createMemberWorkSyncFeature({ teamsBasePath, configReader: { getConfig: vi.fn(async () => ({ name: teamName, members: [{ name: memberName, providerId: 'codex', agentType: 'team-lead' }], })), } as never, taskReader: { getTasks: vi.fn(async () => [ { id: 'task-1', displayId: '11111111', subject: 'Resolve lead clarification', status: 'pending', owner: memberName, }, ]), } as never, kanbanManager: { getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {}, })), } as never, membersMetaStore: { getMembers: vi.fn(async () => []), } as never, isTeamActive: vi.fn(async () => true), nudgeDeliveryWake, queueQuietWindowMs: 1, }); try { await seedBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName }); feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); await waitForAssertion(async () => { expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 }); const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( (message) => message.messageKind === 'member_work_sync_nudge' ); expect(nudges).toHaveLength(1); expect(nudges[0]?.text).toContain('11111111'); expect(nudgeDeliveryWake.schedule).toHaveBeenCalledTimes(1); expect(nudgeDeliveryWake.schedule).toHaveBeenCalledWith({ teamName, memberName, messageId: nudges[0]?.messageId, providerId: 'codex', reason: 'member_work_sync_nudge_inserted', delayMs: 500, }); await expect(feature.getMetrics({ teamName })).resolves.toMatchObject({ phase2Readiness: { reasons: expect.arrayContaining(['would_nudge_rate_high']), }, }); }); const journal = await fs.promises.readFile( path.join( teamsBasePath, teamName, 'members', memberName, '.member-work-sync', 'journal.jsonl' ), 'utf8' ); expect(journal).toContain('"event":"nudge_delivered"'); expect(journal).not.toContain('"reason":"blocking_metrics"'); } finally { await feature.dispose(); } }); it('keeps targeted OpenCode nudge idempotent after noisy metrics become ready', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-opencode-metrics-recovery'; const memberName = 'alice'; const nudgeDeliveryWake = { schedule: vi.fn(async () => undefined), }; const feature = createMemberWorkSyncFeature({ teamsBasePath, configReader: { getConfig: vi.fn(async () => ({ name: teamName, members: [{ name: memberName, providerId: 'opencode' }], })), } as never, taskReader: { getTasks: vi.fn(async () => [ { id: 'task-1', displayId: '11111111', subject: 'Keep OpenCode nudge idempotent after metrics ready', status: 'pending', owner: memberName, }, ]), } as never, kanbanManager: { getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {}, })), } as never, membersMetaStore: { getMembers: vi.fn(async () => []), } as never, isTeamActive: vi.fn(async () => true), nudgeDeliveryWake, queueQuietWindowMs: 1, }); try { await seedBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName }); feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); await waitForAssertion(async () => { expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 }); const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( (message) => message.messageKind === 'member_work_sync_nudge' ); expect(nudges).toHaveLength(1); expect(nudgeDeliveryWake.schedule).toHaveBeenCalledTimes(1); }); await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); await waitForAssertion(async () => { const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( (message) => message.messageKind === 'member_work_sync_nudge' ); expect(nudges).toHaveLength(1); expect(nudges[0]?.text).toContain('11111111'); expect(nudgeDeliveryWake.schedule).toHaveBeenCalledTimes(1); expect(nudgeDeliveryWake.schedule).toHaveBeenLastCalledWith({ teamName, memberName, messageId: nudges[0]?.messageId, providerId: 'opencode', reason: 'member_work_sync_nudge_inserted', delayMs: 500, }); expect( Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })) ).toEqual([ expect.objectContaining({ status: 'delivered', deliveredMessageId: nudges[0]?.messageId, }), ]); }); } finally { await feature.dispose(); } }); it('keeps targeted OpenCode nudges retryable when prompt delivery is busy', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-opencode-busy'; const memberName = 'alice'; const nudgeDeliveryWake = { schedule: vi.fn(async () => undefined), }; let promptDeliveryBusy = true; const promptDeliveryBusySignal = { isBusy: vi.fn(async () => promptDeliveryBusy ? { busy: true, reason: 'opencode_prompt_delivery_active', retryAfterIso: '2026-05-05T12:05:00.000Z', } : { busy: false } ), }; const feature = createMemberWorkSyncFeature({ teamsBasePath, configReader: { getConfig: vi.fn(async () => ({ name: teamName, members: [{ name: memberName, providerId: 'opencode' }], })), } as never, taskReader: { getTasks: vi.fn(async () => [ { id: 'task-1', displayId: '11111111', subject: 'Ship OpenCode busy nudge', status: 'pending', owner: memberName, }, ]), } as never, kanbanManager: { getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {}, })), } as never, membersMetaStore: { getMembers: vi.fn(async () => []), } as never, isTeamActive: vi.fn(async () => true), extraBusySignals: [promptDeliveryBusySignal], nudgeDeliveryWake, queueQuietWindowMs: 1, }); try { await seedNonBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName }); feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); await waitForAssertion(async () => { expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 }); expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toEqual([]); expect(nudgeDeliveryWake.schedule).not.toHaveBeenCalled(); expect( Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })) ).toEqual([ expect.objectContaining({ status: 'failed_retryable', lastError: 'member_busy:opencode_prompt_delivery_active', nextAttemptAt: '2026-05-05T12:05:00.000Z', }), ]); }); const journal = await fs.promises.readFile( path.join( teamsBasePath, teamName, 'members', memberName, '.member-work-sync', 'journal.jsonl' ), 'utf8' ); expect(journal).toContain('"event":"member_busy"'); expect(journal).toContain('"reason":"member_busy:opencode_prompt_delivery_active"'); expect(journal).not.toContain('"event":"nudge_delivered"'); promptDeliveryBusy = false; await forceRetryableOutboxDue({ teamsBasePath, teamName, memberName, nextAttemptAt: new Date(Date.now() - 1_000).toISOString(), }); await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({ claimed: 1, delivered: 1, superseded: 0, retryable: 0, terminal: 0, }); await waitForAssertion(async () => { const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( (message) => message.messageKind === 'member_work_sync_nudge' ); expect(nudges).toHaveLength(1); expect(nudges[0]?.text).toContain('11111111'); expect(nudgeDeliveryWake.schedule).toHaveBeenCalledTimes(1); expect(nudgeDeliveryWake.schedule).toHaveBeenCalledWith({ teamName, memberName, messageId: nudges[0]?.messageId, providerId: 'opencode', reason: 'member_work_sync_nudge_inserted', delayMs: 500, }); expect( Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })) ).toEqual([ expect.objectContaining({ status: 'delivered', deliveredMessageId: nudges[0]?.messageId, }), ]); }); const recoveredJournal = await fs.promises.readFile( path.join( teamsBasePath, teamName, 'members', memberName, '.member-work-sync', 'journal.jsonl' ), 'utf8' ); expect(recoveredJournal).toContain('"event":"nudge_delivered"'); } finally { await feature.dispose(); } }); it('keeps nudges gated until shadow readiness is reached, then delivers on the next reconcile', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-a'; const memberName = 'bob'; const feature = createMemberWorkSyncFeature({ teamsBasePath, configReader: { getConfig: vi.fn(async () => ({ name: teamName, members: [{ name: memberName, providerId: 'codex' }], })), } as never, taskReader: { getTasks: vi.fn(async () => [ { id: 'task-1', displayId: '11111111', subject: 'Ship sync after readiness', status: 'pending', owner: memberName, }, ]), } as never, kanbanManager: { getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {}, })), } as never, membersMetaStore: { getMembers: vi.fn(async () => []), } as never, isTeamActive: vi.fn(async () => true), queueQuietWindowMs: 1, }); try { feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); await waitForAssertion(async () => { expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 }); expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toEqual([]); expect(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })).toEqual({}); await expect(feature.getMetrics({ teamName })).resolves.toMatchObject({ phase2Readiness: { state: 'collecting_shadow_data' }, }); }); await waitForAssertion(async () => { const journal = await fs.promises.readFile( path.join( teamsBasePath, teamName, 'members', memberName, '.member-work-sync', 'journal.jsonl' ), 'utf8' ); expect(journal).toContain('"event":"nudge_skipped"'); expect(journal).toContain('"reason":"blocking_metrics"'); }); await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); await waitForAssertion(async () => { const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( (message) => message.messageKind === 'member_work_sync_nudge' ); expect(nudges).toHaveLength(1); expect(nudges[0]?.text).toContain('11111111'); const outboxItems = Object.values( await readMemberOutboxItems({ teamsBasePath, teamName, memberName }) ); expect(outboxItems).toEqual([ expect.objectContaining({ status: 'delivered', }), ]); }); } finally { await feature.dispose(); } }); it('runs the active bounded loop without duplicate nudges across report and fingerprint changes', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-a'; const memberName = 'bob'; let tasks = [ { id: 'task-1', displayId: '11111111', subject: 'Ship sync', status: 'pending', owner: memberName, }, ]; const feature = createMemberWorkSyncFeature({ teamsBasePath, configReader: { getConfig: vi.fn(async () => ({ name: teamName, members: [{ name: memberName, providerId: 'codex' }], })), } as never, taskReader: { getTasks: vi.fn(async () => tasks), } as never, kanbanManager: { getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {}, })), } as never, membersMetaStore: { getMembers: vi.fn(async () => []), } as never, isTeamActive: vi.fn(async () => true), queueQuietWindowMs: 1, }); try { await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); let firstStatus = await feature.getStatus({ teamName, memberName }); await waitForAssertion(async () => { const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( (message) => message.messageKind === 'member_work_sync_nudge' ); expect(nudges).toHaveLength(1); firstStatus = await feature.getStatus({ teamName, memberName }); expect(firstStatus).toMatchObject({ state: 'needs_sync', providerId: 'codex', shadow: { wouldNudge: true }, }); expect(firstStatus.reportToken).toBeTruthy(); }); const firstFingerprint = firstStatus.agenda.fingerprint; await expect( feature.report({ teamName, memberName, state: 'still_working', agendaFingerprint: firstFingerprint, reportToken: firstStatus.reportToken, taskIds: ['task-1'], source: 'test', }) ).resolves.toMatchObject({ accepted: true, status: { state: 'still_working', report: { accepted: true, state: 'still_working' }, }, }); await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({ claimed: 0, delivered: 0, superseded: 0, retryable: 0, terminal: 0, }); expect( (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( (message) => message.messageKind === 'member_work_sync_nudge' ) ).toHaveLength(1); tasks = [ ...tasks, { id: 'task-2', displayId: '22222222', subject: 'Ship follow-up sync', status: 'pending', owner: memberName, }, ]; feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-2' } as never); let secondStatus = firstStatus; await waitForAssertion(async () => { const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( (message) => message.messageKind === 'member_work_sync_nudge' ); expect(nudges).toHaveLength(2); expect(new Set(nudges.map((message) => message.messageId)).size).toBe(2); expect(nudges.at(-1)?.text).toContain('22222222'); secondStatus = await feature.getStatus({ teamName, memberName }); expect(secondStatus.state).toBe('needs_sync'); expect(secondStatus.agenda.fingerprint).not.toBe(firstFingerprint); expect(secondStatus.shadow).toMatchObject({ wouldNudge: true, fingerprintChanged: true, previousFingerprint: firstFingerprint, }); }); const secondTaskIds = secondStatus.agenda.items.map((item) => item.taskId); await expect( feature.report({ teamName, memberName, state: 'still_working', agendaFingerprint: secondStatus.agenda.fingerprint, reportToken: secondStatus.reportToken, taskIds: secondTaskIds, source: 'test', }) ).resolves.toMatchObject({ accepted: true, status: { state: 'still_working', report: { accepted: true, taskIds: secondTaskIds }, }, }); await expect(feature.dispatchDueNudges([teamName])).resolves.toMatchObject({ claimed: 0, delivered: 0, }); tasks = tasks.map((task) => ({ ...task, status: 'completed' })); const clearedStatus = await feature.refreshStatus({ teamName, memberName }); expect(clearedStatus).toMatchObject({ state: 'caught_up', agenda: { items: [] }, shadow: { wouldNudge: false }, }); await expect( feature.report({ teamName, memberName, state: 'caught_up', agendaFingerprint: clearedStatus.agenda.fingerprint, reportToken: clearedStatus.reportToken, source: 'test', }) ).resolves.toMatchObject({ accepted: true, status: { state: 'caught_up', report: { accepted: true, state: 'caught_up' }, }, }); await expect(feature.dispatchDueNudges([teamName])).resolves.toMatchObject({ claimed: 0, delivered: 0, }); expect( (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( (message) => message.messageKind === 'member_work_sync_nudge' ) ).toHaveLength(2); const journal = await fs.promises.readFile( path.join( teamsBasePath, teamName, 'members', memberName, '.member-work-sync', 'journal.jsonl' ), 'utf8' ); const events = journal .trim() .split('\n') .map((line) => (JSON.parse(line) as { event: string }).event); expect(events.filter((event) => event === 'nudge_delivered')).toHaveLength(2); expect(events.filter((event) => event === 'report_accepted')).toHaveLength(3); } finally { await feature.dispose(); } }); it('supersedes stale file-backed nudges and rejects stale reports before accepting the current fingerprint', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-a'; const memberName = 'bob'; let tasks = [ { id: 'task-1', displayId: '11111111', subject: 'Ship sync', status: 'pending', owner: memberName, }, ]; const feature = createMemberWorkSyncFeature({ teamsBasePath, configReader: { getConfig: vi.fn(async () => ({ name: teamName, members: [{ name: memberName, providerId: 'codex' }], })), } as never, taskReader: { getTasks: vi.fn(async () => tasks), } as never, kanbanManager: { getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {}, })), } as never, membersMetaStore: { getMembers: vi.fn(async () => []), } as never, isTeamActive: vi.fn(async () => true), }); try { await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); const staleStatus = await feature.refreshStatus({ teamName, memberName }); expect(staleStatus).toMatchObject({ state: 'needs_sync', shadow: { wouldNudge: true }, }); const outboxInput = buildMemberWorkSyncOutboxEnsureInput({ status: staleStatus, hash: new NodeHashAdapter(), nowIso: staleStatus.evaluatedAt, }); expect(outboxInput).not.toBeNull(); const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath)); await expect(store.ensurePending(outboxInput!)).resolves.toMatchObject({ ok: true, outcome: 'existing', }); const staleOutboxId = `member-work-sync:${teamName}:${memberName}:${staleStatus.agenda.fingerprint}`; await expect( readMemberOutboxItems({ teamsBasePath, teamName, memberName }) ).resolves.toMatchObject({ [staleOutboxId]: { status: 'pending' }, }); tasks = tasks.map((task) => ({ ...task, status: 'completed' })); await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({ claimed: 1, delivered: 0, superseded: 1, retryable: 0, terminal: 0, }); await expect(readInboxMessages({ teamsBasePath, teamName, memberName })).resolves.toEqual([]); await expect( readMemberOutboxItems({ teamsBasePath, teamName, memberName }) ).resolves.toMatchObject({ [staleOutboxId]: { status: 'superseded', lastError: 'status_no_longer_matches_outbox', }, }); await expect( feature.report({ teamName, memberName, state: 'still_working', agendaFingerprint: staleStatus.agenda.fingerprint, reportToken: staleStatus.reportToken, taskIds: ['task-1'], source: 'test', }) ).resolves.toMatchObject({ accepted: false, code: 'stale_fingerprint', status: { state: 'caught_up', report: { accepted: false, rejectionCode: 'stale_fingerprint', }, }, }); const currentStatus = await feature.getStatus({ teamName, memberName }); await expect( feature.report({ teamName, memberName, state: 'caught_up', agendaFingerprint: currentStatus.agenda.fingerprint, reportToken: currentStatus.reportToken, source: 'test', }) ).resolves.toMatchObject({ accepted: true, status: { state: 'caught_up', report: { accepted: true, state: 'caught_up' }, }, }); await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({ claimed: 0, delivered: 0, superseded: 0, retryable: 0, terminal: 0, }); const journal = await fs.promises.readFile( path.join( teamsBasePath, teamName, 'members', memberName, '.member-work-sync', 'journal.jsonl' ), 'utf8' ); const events = journal .trim() .split('\n') .map((line) => (JSON.parse(line) as { event: string }).event); expect(events).toContain('nudge_superseded'); expect(events).toContain('report_rejected'); expect(events).toContain('report_accepted'); } finally { await feature.dispose(); } }); it('supersedes pending nudges without delivery when the team becomes inactive', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-a'; const memberName = 'bob'; let teamActive = true; const feature = createMemberWorkSyncFeature({ teamsBasePath, configReader: { getConfig: vi.fn(async () => ({ name: teamName, members: [{ name: memberName, providerId: 'codex' }], })), } as never, taskReader: { getTasks: vi.fn(async () => [ { id: 'task-1', displayId: '11111111', subject: 'Ship sync before shutdown', status: 'pending', owner: memberName, }, ]), } as never, kanbanManager: { getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {}, })), } as never, membersMetaStore: { getMembers: vi.fn(async () => []), } as never, isTeamActive: vi.fn(async () => teamActive), }); try { await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); const status = await feature.refreshStatus({ teamName, memberName }); expect(status).toMatchObject({ state: 'needs_sync', shadow: { wouldNudge: true }, }); const outboxInput = buildMemberWorkSyncOutboxEnsureInput({ status, hash: new NodeHashAdapter(), nowIso: status.evaluatedAt, }); expect(outboxInput).not.toBeNull(); const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath)); await expect(store.ensurePending(outboxInput!)).resolves.toMatchObject({ ok: true, outcome: 'existing', }); teamActive = false; await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({ claimed: 1, delivered: 0, superseded: 1, retryable: 0, terminal: 0, }); await expect(readInboxMessages({ teamsBasePath, teamName, memberName })).resolves.toEqual([]); await expect( readMemberOutboxItems({ teamsBasePath, teamName, memberName }) ).resolves.toMatchObject({ [outboxInput!.id]: { status: 'superseded', lastError: 'team_inactive', }, }); const journal = await fs.promises.readFile( path.join( teamsBasePath, teamName, 'members', memberName, '.member-work-sync', 'journal.jsonl' ), 'utf8' ); expect(journal).toContain('"event":"nudge_superseded"'); expect(journal).toContain('"reason":"team_inactive"'); expect(journal).not.toContain('"event":"nudge_delivered"'); } finally { await feature.dispose(); } }); it('replays legacy controller pending report intents through the real app validator', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-a'; const memberName = 'bob'; const feature = createMemberWorkSyncFeature({ teamsBasePath, configReader: { getConfig: vi.fn(async () => ({ name: teamName, members: [{ name: memberName, providerId: 'codex' }], })), } as never, taskReader: { getTasks: vi.fn(async () => [ { id: 'task-1', displayId: '11111111', subject: 'Ship sync after offline report', status: 'pending', owner: memberName, }, ]), } as never, kanbanManager: { getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {}, })), } as never, membersMetaStore: { getMembers: vi.fn(async () => []), } as never, isTeamActive: vi.fn(async () => true), }); try { const status = await feature.refreshStatus({ teamName, memberName }); expect(status).toMatchObject({ state: 'needs_sync', agenda: { items: [expect.objectContaining({ taskId: 'task-1' })] }, }); expect(status.reportToken).toBeTruthy(); const legacyIntentPath = path.join( teamsBasePath, teamName, '.member-work-sync', 'pending-reports.json' ); const intentId = 'legacy-intent-1'; await fs.promises.mkdir(path.dirname(legacyIntentPath), { recursive: true }); await fs.promises.writeFile( legacyIntentPath, `${JSON.stringify( { schemaVersion: 1, intents: { [intentId]: { id: intentId, teamName, memberName, status: 'pending', reason: 'control_api_unavailable', recordedAt: '2026-05-05T12:00:00.000Z', request: { teamName, memberName, state: 'still_working', agendaFingerprint: status.agenda.fingerprint, reportToken: status.reportToken, taskIds: ['task-1'], source: 'mcp', }, }, }, }, null, 2 )}\n`, 'utf8' ); await expect(feature.replayPendingReports([teamName])).resolves.toEqual({ processed: 1, accepted: 1, rejected: 0, superseded: 0, }); const finalStatus = await feature.getStatus({ teamName, memberName }); expect(finalStatus).toMatchObject({ state: 'still_working', report: { accepted: true, state: 'still_working', taskIds: ['task-1'], source: 'mcp', }, }); const memberReports = JSON.parse( await fs.promises.readFile( path.join( teamsBasePath, teamName, 'members', memberName, '.member-work-sync', 'reports.json' ), 'utf8' ) ) as { intents?: Record }; expect(memberReports.intents?.[intentId]).toMatchObject({ status: 'accepted', resultCode: 'accepted', }); const journal = await fs.promises.readFile( path.join( teamsBasePath, teamName, 'members', memberName, '.member-work-sync', 'journal.jsonl' ), 'utf8' ); expect(journal).toContain('"event":"legacy_fallback_used"'); expect(journal).toContain('"event":"report_accepted"'); } finally { await feature.dispose(); } }); it('defers nudges while a member is busy and recovers on the next agenda change', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-a'; const memberName = 'bob'; let tasks = [ { id: 'task-1', displayId: '11111111', subject: 'Ship sync while busy', status: 'pending', owner: memberName, }, ]; const feature = createMemberWorkSyncFeature({ teamsBasePath, configReader: { getConfig: vi.fn(async () => ({ name: teamName, members: [{ name: memberName, providerId: 'codex' }], })), } as never, taskReader: { getTasks: vi.fn(async () => tasks), } as never, kanbanManager: { getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {}, })), } as never, membersMetaStore: { getMembers: vi.fn(async () => []), } as never, isTeamActive: vi.fn(async () => true), queueQuietWindowMs: 1, }); try { await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); feature.noteTeamChange({ type: 'tool-activity', teamName, detail: JSON.stringify({ action: 'start', activity: { memberName, toolUseId: 'tool-1', toolName: 'bash', startedAt: new Date(Date.now()).toISOString(), source: 'runtime', }, }), } as never); feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); await waitForAssertion(async () => { expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 }); expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toEqual([]); const outboxItems = Object.values( await readMemberOutboxItems({ teamsBasePath, teamName, memberName }) ); expect(outboxItems).toEqual([ expect.objectContaining({ status: 'failed_retryable', lastError: 'member_busy:active_tool_activity', }), ]); }); feature.noteTeamChange({ type: 'tool-activity', teamName, detail: JSON.stringify({ action: 'reset', memberName, toolUseIds: ['tool-1'], }), } as never); tasks = [ ...tasks, { id: 'task-2', displayId: '22222222', subject: 'Ship sync after busy clears', status: 'pending', owner: memberName, }, ]; feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-2' } as never); await waitForAssertion(async () => { const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( (message) => message.messageKind === 'member_work_sync_nudge' ); expect(nudges).toHaveLength(1); expect(nudges[0]?.text).toContain('22222222'); const outboxItems = Object.values( await readMemberOutboxItems({ teamsBasePath, teamName, memberName }) ); expect(outboxItems).toEqual( expect.arrayContaining([ expect.objectContaining({ status: 'failed_retryable', lastError: 'member_busy:active_tool_activity', }), expect.objectContaining({ status: 'delivered', }), ]) ); }); await waitForAssertion(async () => { const journal = await fs.promises.readFile( path.join( teamsBasePath, teamName, 'members', memberName, '.member-work-sync', 'journal.jsonl' ), 'utf8' ); expect(journal).toContain('"event":"member_busy"'); expect(journal).toContain('"event":"nudge_delivered"'); }); } finally { await feature.dispose(); } }); it('clears stale retry delay and recovers when tool activity finishes without agenda changes', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-a'; const memberName = 'bob'; const tasks = [ { id: 'task-1', displayId: '11111111', subject: 'Ship sync after tool finish', status: 'pending', owner: memberName, }, ]; const feature = createMemberWorkSyncFeature({ teamsBasePath, configReader: { getConfig: vi.fn(async () => ({ name: teamName, members: [{ name: memberName, providerId: 'codex' }], })), } as never, taskReader: { getTasks: vi.fn(async () => tasks), } as never, kanbanManager: { getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {}, })), } as never, membersMetaStore: { getMembers: vi.fn(async () => []), } as never, isTeamActive: vi.fn(async () => true), queueQuietWindowMs: 1, }); try { await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); feature.noteTeamChange({ type: 'tool-activity', teamName, detail: JSON.stringify({ action: 'start', activity: { memberName, toolUseId: 'tool-1', toolName: 'bash', startedAt: new Date(Date.now()).toISOString(), source: 'runtime', }, }), } as never); feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); await waitForAssertion(async () => { expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toEqual([]); const outboxItems = Object.values( await readMemberOutboxItems({ teamsBasePath, teamName, memberName }) ); expect(outboxItems).toEqual([ expect.objectContaining({ status: 'failed_retryable', lastError: 'member_busy:active_tool_activity', nextAttemptAt: expect.any(String), }), ]); }); feature.noteTeamChange({ type: 'tool-activity', teamName, detail: JSON.stringify({ action: 'finish', memberName, toolUseId: 'tool-1', finishedAt: new Date(Date.now() - 120_000).toISOString(), }), } as never); await waitForAssertion(async () => { const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( (message) => message.messageKind === 'member_work_sync_nudge' ); expect(nudges).toHaveLength(1); expect(nudges[0]?.text).toContain('11111111'); const outboxItems = Object.values( await readMemberOutboxItems({ teamsBasePath, teamName, memberName }) ); expect(outboxItems).toEqual([ expect.objectContaining({ status: 'delivered', }), ]); }); } finally { await feature.dispose(); } }); it('rate-limits the active loop after two delivered nudges per member per hour', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-a'; const memberName = 'bob'; let tasks = [ { id: 'task-1', displayId: '11111111', subject: 'Ship sync first', status: 'pending', owner: memberName, }, ]; const feature = createMemberWorkSyncFeature({ teamsBasePath, configReader: { getConfig: vi.fn(async () => ({ name: teamName, members: [{ name: memberName, providerId: 'codex' }], })), } as never, taskReader: { getTasks: vi.fn(async () => tasks), } as never, kanbanManager: { getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {}, })), } as never, membersMetaStore: { getMembers: vi.fn(async () => []), } as never, isTeamActive: vi.fn(async () => true), queueQuietWindowMs: 1, }); try { await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); await waitForAssertion(async () => { const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( (message) => message.messageKind === 'member_work_sync_nudge' ); expect(nudges).toHaveLength(1); expect(nudges[0]?.text).toContain('11111111'); }); tasks = [ ...tasks, { id: 'task-2', displayId: '22222222', subject: 'Ship sync second', status: 'pending', owner: memberName, }, ]; feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-2' } as never); await waitForAssertion(async () => { const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( (message) => message.messageKind === 'member_work_sync_nudge' ); expect(nudges).toHaveLength(2); expect(nudges.at(-1)?.text).toContain('22222222'); const outboxItems = Object.values( await readMemberOutboxItems({ teamsBasePath, teamName, memberName }) ); expect(outboxItems.filter((item) => item.status === 'delivered')).toHaveLength(2); }); tasks = [ ...tasks, { id: 'task-3', displayId: '33333333', subject: 'Ship sync third', status: 'pending', owner: memberName, }, ]; feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-3' } as never); await waitForAssertion(async () => { const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( (message) => message.messageKind === 'member_work_sync_nudge' ); expect(nudges).toHaveLength(2); expect(nudges.some((message) => message.text?.includes('33333333'))).toBe(false); const outboxItems = Object.values( await readMemberOutboxItems({ teamsBasePath, teamName, memberName }) ); expect(outboxItems.filter((item) => item.status === 'delivered')).toHaveLength(2); expect(outboxItems).toEqual( expect.arrayContaining([ expect.objectContaining({ status: 'failed_retryable', lastError: 'member_nudge_rate_limited', }), ]) ); }); await waitForAssertion(async () => { const journal = await fs.promises.readFile( path.join( teamsBasePath, teamName, 'members', memberName, '.member-work-sync', 'journal.jsonl' ), 'utf8' ); const events = journal .trim() .split('\n') .map((line) => JSON.parse(line) as { event: string; reason?: string }); expect(events.filter((event) => event.event === 'nudge_delivered')).toHaveLength(2); expect(events).toEqual( expect.arrayContaining([ expect.objectContaining({ event: 'nudge_skipped', reason: 'member_nudge_rate_limited', }), ]) ); }); } finally { await feature.dispose(); } }); it('recovers retryable inbox delivery failures without duplicate nudges', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-a'; const memberName = 'bob'; const feature = createMemberWorkSyncFeature({ teamsBasePath, configReader: { getConfig: vi.fn(async () => ({ name: teamName, members: [{ name: memberName, providerId: 'codex' }], })), } as never, taskReader: { getTasks: vi.fn(async () => [ { id: 'task-1', displayId: '11111111', subject: 'Ship sync after inbox retry', status: 'pending', owner: memberName, }, ]), } as never, kanbanManager: { getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {}, })), } as never, membersMetaStore: { getMembers: vi.fn(async () => []), } as never, isTeamActive: vi.fn(async () => true), queueQuietWindowMs: 1, }); try { await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); const inboxPath = path.join(teamsBasePath, teamName, 'inboxes', `${memberName}.json`); await fs.promises.mkdir(inboxPath, { recursive: true }); feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); await waitForAssertion(async () => { expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toHaveLength(0); const outboxItems = Object.values( await readMemberOutboxItems({ teamsBasePath, teamName, memberName }) ); expect(outboxItems).toEqual( expect.arrayContaining([ expect.objectContaining({ status: 'failed_retryable', lastError: expect.stringMatching(/EISDIR|ENOTDIR|EEXIST/), }), ]) ); }); await waitForQueueIdle(feature); await fs.promises.rm(inboxPath, { recursive: true, force: true }); await forceRetryableOutboxDue({ teamsBasePath, teamName, memberName, nextAttemptAt: new Date(Date.now() - 1_000).toISOString(), }); await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({ claimed: 1, delivered: 1, superseded: 0, retryable: 0, terminal: 0, }); const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( (message) => message.messageKind === 'member_work_sync_nudge' ); expect(nudges).toHaveLength(1); expect(nudges[0]?.text).toContain('11111111'); expect( Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })) ).toEqual( expect.arrayContaining([ expect.objectContaining({ status: 'delivered', deliveredMessageId: expect.any(String), }), ]) ); const journal = await fs.promises.readFile( path.join( teamsBasePath, teamName, 'members', memberName, '.member-work-sync', 'journal.jsonl' ), 'utf8' ); expect(journal).toContain('"event":"nudge_retryable"'); expect(journal).toContain('"event":"nudge_delivered"'); } finally { await feature.dispose(); } }); it('respects watchdog cooldown and delivers after the retry window is due', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-a'; const memberName = 'bob'; const feature = createMemberWorkSyncFeature({ teamsBasePath, configReader: { getConfig: vi.fn(async () => ({ name: teamName, members: [{ name: memberName, providerId: 'codex' }], })), } as never, taskReader: { getTasks: vi.fn(async () => [ { id: 'task-1', displayId: '11111111', subject: 'Ship sync after watchdog cooldown', status: 'pending', owner: memberName, }, ]), } as never, kanbanManager: { getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {}, })), } as never, membersMetaStore: { getMembers: vi.fn(async () => []), } as never, isTeamActive: vi.fn(async () => true), queueQuietWindowMs: 1, }); try { await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); const stallJournalPath = path.join(teamsBasePath, teamName, 'stall-monitor-journal.json'); await fs.promises.mkdir(path.dirname(stallJournalPath), { recursive: true }); await fs.promises.writeFile( stallJournalPath, `${JSON.stringify([ { taskId: 'task-1', state: 'alerted', alertedAt: new Date().toISOString(), }, ])}\n`, 'utf8' ); feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); await waitForAssertion(async () => { const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( (message) => message.messageKind === 'member_work_sync_nudge' ); expect(nudges).toHaveLength(0); const outboxItems = Object.values( await readMemberOutboxItems({ teamsBasePath, teamName, memberName }) ); expect(outboxItems).toEqual( expect.arrayContaining([ expect.objectContaining({ status: 'failed_retryable', lastError: 'watchdog_cooldown_active', }), ]) ); }); await waitForQueueIdle(feature); await fs.promises.writeFile( stallJournalPath, `${JSON.stringify([ { taskId: 'task-1', state: 'alerted', alertedAt: new Date(Date.now() - 11 * 60_000).toISOString(), }, ])}\n`, 'utf8' ); await forceRetryableOutboxDue({ teamsBasePath, teamName, memberName, nextAttemptAt: new Date(Date.now() - 1_000).toISOString(), }); await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({ claimed: 1, delivered: 1, superseded: 0, retryable: 0, terminal: 0, }); const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( (message) => message.messageKind === 'member_work_sync_nudge' ); expect(nudges).toHaveLength(1); expect(nudges[0]?.text).toContain('11111111'); const journal = await fs.promises.readFile( path.join( teamsBasePath, teamName, 'members', memberName, '.member-work-sync', 'journal.jsonl' ), 'utf8' ); expect(journal).toContain('"event":"watchdog_cooldown_active"'); expect(journal).toContain('"reason":"watchdog_cooldown_active"'); expect(journal).toContain('"event":"nudge_delivered"'); } finally { await feature.dispose(); } }); it('supersedes retryable nudges when the member reports before retry delivery', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-a'; const memberName = 'bob'; const feature = createMemberWorkSyncFeature({ teamsBasePath, configReader: { getConfig: vi.fn(async () => ({ name: teamName, members: [{ name: memberName, providerId: 'codex' }], })), } as never, taskReader: { getTasks: vi.fn(async () => [ { id: 'task-1', displayId: '11111111', subject: 'Ship sync without stale retry', status: 'pending', owner: memberName, }, ]), } as never, kanbanManager: { getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {}, })), } as never, membersMetaStore: { getMembers: vi.fn(async () => []), } as never, isTeamActive: vi.fn(async () => true), queueQuietWindowMs: 1, }); try { await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); const stallJournalPath = path.join(teamsBasePath, teamName, 'stall-monitor-journal.json'); await fs.promises.mkdir(path.dirname(stallJournalPath), { recursive: true }); await fs.promises.writeFile( stallJournalPath, `${JSON.stringify([ { taskId: 'task-1', state: 'alerted', alertedAt: new Date().toISOString(), }, ])}\n`, 'utf8' ); feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); let status = await feature.getStatus({ teamName, memberName }); await waitForAssertion(async () => { status = await feature.getStatus({ teamName, memberName }); expect(status).toMatchObject({ state: 'needs_sync', shadow: { wouldNudge: true }, }); expect(status.reportToken).toBeTruthy(); expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toHaveLength(0); expect( Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })) ).toEqual( expect.arrayContaining([ expect.objectContaining({ status: 'failed_retryable', lastError: 'watchdog_cooldown_active', }), ]) ); }); await waitForQueueIdle(feature); await expect( feature.report({ teamName, memberName, state: 'still_working', agendaFingerprint: status.agenda.fingerprint, reportToken: status.reportToken, taskIds: ['task-1'], source: 'test', }) ).resolves.toMatchObject({ accepted: true, status: { state: 'still_working', report: { accepted: true } }, }); await forceRetryableOutboxDue({ teamsBasePath, teamName, memberName, nextAttemptAt: new Date(Date.now() - 1_000).toISOString(), }); await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({ claimed: 1, delivered: 0, superseded: 1, retryable: 0, terminal: 0, }); expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toHaveLength(0); expect( Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })) ).toEqual( expect.arrayContaining([ expect.objectContaining({ status: 'superseded', lastError: 'status_no_longer_matches_outbox', }), ]) ); const journal = await fs.promises.readFile( path.join( teamsBasePath, teamName, 'members', memberName, '.member-work-sync', 'journal.jsonl' ), 'utf8' ); expect(journal).toContain('"event":"watchdog_cooldown_active"'); expect(journal).toContain('"event":"report_accepted"'); expect(journal).toContain('"event":"nudge_superseded"'); expect(journal).not.toContain('"event":"nudge_delivered"'); } finally { await feature.dispose(); } }); it('refreshes an expired still_working lease during nudge dispatch without a status read', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-a'; const memberName = 'bob'; const feature = createMemberWorkSyncFeature({ teamsBasePath, configReader: { getConfig: vi.fn(async () => ({ name: teamName, members: [{ name: memberName, providerId: 'codex' }], })), } as never, taskReader: { getTasks: vi.fn(async () => [ { id: 'task-1', displayId: '11111111', subject: 'Wake after lease expiry', status: 'in_progress', owner: memberName, }, ]), } as never, kanbanManager: { getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {}, })), } as never, membersMetaStore: { getMembers: vi.fn(async () => []), } as never, isTeamActive: vi.fn(async () => true), canDispatchNudges: vi.fn(async () => true), }); try { await seedBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName }); const current = await feature.refreshStatus({ teamName, memberName }); expect(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })).toEqual({}); await expect( feature.report({ teamName, memberName, state: 'still_working', agendaFingerprint: current.agenda.fingerprint, reportToken: current.reportToken, taskIds: ['task-1'], source: 'test', }) ).resolves.toMatchObject({ accepted: true, status: { state: 'still_working', report: { accepted: true } }, }); const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath)); const acceptedStatus = await store.read({ teamName, memberName }); expect(acceptedStatus?.report?.accepted).toBe(true); const expiredReportedAt = new Date(Date.now() - 7 * 60_000).toISOString(); const expiredAt = new Date(Date.now() - 6 * 60_000).toISOString(); await store.write({ ...acceptedStatus!, evaluatedAt: expiredReportedAt, report: { ...acceptedStatus!.report!, reportedAt: expiredReportedAt, expiresAt: expiredAt, }, }); await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({ claimed: 1, delivered: 1, superseded: 0, retryable: 0, terminal: 0, }); expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toHaveLength(1); } finally { await feature.dispose(); } }); it('refreshes a legacy still_working report without a lease during nudge dispatch', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-a'; const memberName = 'bob'; const feature = createMemberWorkSyncFeature({ teamsBasePath, configReader: { getConfig: vi.fn(async () => ({ name: teamName, members: [{ name: memberName, providerId: 'codex' }], })), } as never, taskReader: { getTasks: vi.fn(async () => [ { id: 'task-1', displayId: '11111111', subject: 'Wake after missing lease', status: 'in_progress', owner: memberName, }, ]), } as never, kanbanManager: { getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {}, })), } as never, membersMetaStore: { getMembers: vi.fn(async () => []), } as never, isTeamActive: vi.fn(async () => true), canDispatchNudges: vi.fn(async () => true), }); try { await seedBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName }); const current = await feature.refreshStatus({ teamName, memberName }); await expect( feature.report({ teamName, memberName, state: 'still_working', agendaFingerprint: current.agenda.fingerprint, reportToken: current.reportToken, taskIds: ['task-1'], source: 'test', }) ).resolves.toMatchObject({ accepted: true, status: { state: 'still_working', report: { accepted: true } }, }); const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath)); const acceptedStatus = await store.read({ teamName, memberName }); const legacyReport = { ...acceptedStatus!.report! }; delete legacyReport.expiresAt; await store.write({ ...acceptedStatus!, evaluatedAt: new Date(Date.now() - 7 * 60_000).toISOString(), report: legacyReport, }); await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({ claimed: 1, delivered: 1, superseded: 0, retryable: 0, terminal: 0, }); expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toHaveLength(1); } finally { await feature.dispose(); } }); it('repairs a legacy working status when the stored agenda is empty', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-a'; const memberName = 'bob'; const feature = createMemberWorkSyncFeature({ teamsBasePath, configReader: { getConfig: vi.fn(async () => ({ name: teamName, members: [{ name: memberName, providerId: 'codex' }], })), } as never, taskReader: { getTasks: vi.fn(async () => []), } as never, kanbanManager: { getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {}, })), } as never, membersMetaStore: { getMembers: vi.fn(async () => []), } as never, isTeamActive: vi.fn(async () => true), canDispatchNudges: vi.fn(async () => true), }); try { const current = await feature.refreshStatus({ teamName, memberName }); expect(current.state).toBe('caught_up'); const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath)); await store.write({ ...current, state: 'still_working', report: { teamName, memberName, state: 'still_working', agendaFingerprint: current.agenda.fingerprint, reportedAt: current.evaluatedAt, expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), accepted: true, }, }); await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({ claimed: 0, delivered: 0, superseded: 0, retryable: 0, terminal: 0, }); const repaired = await store.read({ teamName, memberName }); expect(repaired).toMatchObject({ state: 'caught_up', diagnostics: expect.arrayContaining(['agenda_empty']), }); expect(repaired?.report).toBeUndefined(); expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toHaveLength(0); } finally { await feature.dispose(); } }); it('refreshes stale caught_up status during nudge dispatch when new work appears', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-a'; const memberName = 'bob'; let tasks: Array<{ id: string; displayId: string; subject: string; status: 'pending'; owner: string; }> = []; const feature = createMemberWorkSyncFeature({ teamsBasePath, configReader: { getConfig: vi.fn(async () => ({ name: teamName, members: [{ name: memberName, providerId: 'codex' }], })), } as never, taskReader: { getTasks: vi.fn(async () => tasks), } as never, kanbanManager: { getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {}, })), } as never, membersMetaStore: { getMembers: vi.fn(async () => []), } as never, isTeamActive: vi.fn(async () => true), canDispatchNudges: vi.fn(async () => true), }); try { await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); const current = await feature.refreshStatus({ teamName, memberName }); expect(current.state).toBe('caught_up'); const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath)); await store.write({ ...current, evaluatedAt: new Date(Date.now() - 7 * 60_000).toISOString(), }); tasks = [ { id: 'task-1', displayId: '11111111', subject: 'Wake after missed task event', status: 'pending', owner: memberName, }, ]; await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({ claimed: 1, delivered: 1, superseded: 0, retryable: 0, terminal: 0, }); const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( (message) => message.messageKind === 'member_work_sync_nudge' ); expect(nudges).toHaveLength(1); expect(nudges[0]?.text).toContain('11111111'); await expect(store.read({ teamName, memberName })).resolves.toMatchObject({ state: 'needs_sync', agenda: { items: [expect.objectContaining({ taskId: 'task-1' })], }, }); } finally { await feature.dispose(); } }); it('materializes a missing active-member status during nudge dispatch', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-a'; const memberName = 'bob'; const feature = createMemberWorkSyncFeature({ teamsBasePath, configReader: { getConfig: vi.fn(async () => ({ name: teamName, members: [{ name: memberName, providerId: 'codex' }], })), } as never, taskReader: { getTasks: vi.fn(async () => [ { id: 'task-1', displayId: '11111111', subject: 'Wake after app restart', status: 'pending', owner: memberName, }, ]), } as never, kanbanManager: { getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {}, })), } as never, membersMetaStore: { getMembers: vi.fn(async () => []), } as never, isTeamActive: vi.fn(async () => true), canDispatchNudges: vi.fn(async () => true), }); try { await seedNonBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName }); await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({ claimed: 1, delivered: 1, superseded: 0, retryable: 0, terminal: 0, }); expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toHaveLength(1); await expect( new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath)).read({ teamName, memberName, }) ).resolves.toMatchObject({ state: 'needs_sync', shadow: { triggerReasons: ['startup_scan'] }, }); } finally { await feature.dispose(); } }); it('refreshes stale needs_sync into inactive after the whole team stops', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); const teamsBasePath = getTeamsBasePath(); const teamName = 'team-stopped'; const memberName = 'bob'; let teamActive = true; const feature = createMemberWorkSyncFeature({ teamsBasePath, configReader: { getConfig: vi.fn(async () => ({ name: teamName, members: [{ name: memberName, providerId: 'codex' }], })), } as never, taskReader: { getTasks: vi.fn(async () => [ { id: 'task-1', displayId: '11111111', subject: 'Finish work after sleep', status: 'pending', owner: memberName, }, ]), } as never, kanbanManager: { getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {}, })), } as never, membersMetaStore: { getMembers: vi.fn(async () => []), } as never, isTeamActive: vi.fn(async () => teamActive), canDispatchNudges: vi.fn(async () => teamActive), }); try { const current = await feature.refreshStatus({ teamName, memberName }); expect(current.state).toBe('needs_sync'); const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath)); await store.write({ ...current, evaluatedAt: new Date(Date.now() - 3 * 60_000).toISOString(), }); teamActive = false; await expect(feature.getStatus({ teamName, memberName })).resolves.toMatchObject({ state: 'needs_sync', diagnostics: expect.arrayContaining(['status_stale_refresh_enqueued']), }); await waitForQueueIdle(feature); await expect(store.read({ teamName, memberName })).resolves.toMatchObject({ state: 'inactive', diagnostics: expect.arrayContaining(['team_runtime_inactive']), shadow: { reconciledBy: 'queue', triggerReasons: ['manual_refresh'] }, }); } finally { await feature.dispose(); } }); it('uses snapshot config reads for startup roster materialization', async () => { const getConfig = vi.fn(async () => ({ members: [] })); const getConfigSnapshot = vi.fn(async () => ({ members: [{ name: 'alice' }], })); const feature = createMemberWorkSyncFeature({ teamsBasePath: makeTempRoot(), configReader: { getConfig, getConfigSnapshot, } as never, taskReader: {} as never, kanbanManager: {} as never, membersMetaStore: { getMembers: vi.fn(async () => []), } as never, }); try { await feature.enqueueStartupScan(['my-team']); expect(getConfigSnapshot).toHaveBeenCalledWith('my-team'); expect(getConfig).not.toHaveBeenCalled(); } finally { await feature.dispose(); } }); it('builds Claude Stop hook settings with nudges active by default', async () => { const root = makeTempRoot(); const feature = createMemberWorkSyncFeature({ teamsBasePath: root, configReader: {} as never, taskReader: {} as never, kanbanManager: {} as never, membersMetaStore: {} as never, }); try { const settings = await feature.buildRuntimeTurnSettledHookSettings({ provider: 'claude' }); expect(settings).toMatchObject({ hooks: { Stop: [ { hooks: [ { type: 'command', command: expect.stringContaining('agent-teams:member-work-sync-turn-settled:v1'), }, ], }, ], }, }); await expect( fs.promises.stat( path.join(root, '.member-work-sync/runtime-hooks/bin/turn-settled-hook-v1.sh') ) ).resolves.toMatchObject({ mode: expect.any(Number) }); } finally { await feature.dispose(); } }); it('builds Codex turn-settled environment with nudges active by default', async () => { const root = makeTempRoot(); const feature = createMemberWorkSyncFeature({ teamsBasePath: root, configReader: {} as never, taskReader: {} as never, kanbanManager: {} as never, membersMetaStore: {} as never, }); try { const env = await feature.buildRuntimeTurnSettledEnvironment({ provider: 'codex' }); expect(env).toEqual({ [RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: path.join(root, '.member-work-sync/runtime-hooks'), }); await expect( fs.promises.stat(path.join(root, '.member-work-sync/runtime-hooks/incoming')) ).resolves.toMatchObject({ mode: expect.any(Number) }); } finally { await feature.dispose(); } }); it('builds OpenCode turn-settled environment with nudges active by default', async () => { const root = makeTempRoot(); const feature = createMemberWorkSyncFeature({ teamsBasePath: root, configReader: {} as never, taskReader: {} as never, kanbanManager: {} as never, membersMetaStore: {} as never, }); try { const env = await feature.buildRuntimeTurnSettledEnvironment({ provider: 'opencode' }); expect(env).toEqual({ [RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: path.join(root, '.member-work-sync/runtime-hooks'), }); await expect( fs.promises.stat(path.join(root, '.member-work-sync/runtime-hooks/incoming')) ).resolves.toMatchObject({ mode: expect.any(Number) }); } finally { await feature.dispose(); } }); it('builds OpenCode bridge environment before feature facade initialization', async () => { const root = makeTempRoot(); const env = await buildMemberWorkSyncRuntimeTurnSettledEnvironment({ teamsBasePath: root, provider: 'opencode', }); expect(env).toEqual({ [RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: path.join(root, '.member-work-sync/runtime-hooks'), }); await expect( fs.promises.stat(path.join(root, '.member-work-sync/runtime-hooks/incoming')) ).resolves.toMatchObject({ mode: expect.any(Number) }); }); });