From c5a97fd796b29d1bf1fd1bf5be8340dc727c3b6c Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 15:07:19 +0300 Subject: [PATCH] feat(member-work-sync): assess phase two readiness --- .../member-work-sync-control-plane-plan.md | 2 + .../member-work-sync/contracts/types.ts | 39 ++++++ .../MemberWorkSyncMetricsReader.ts | 16 ++- .../domain/MemberWorkSyncPhase2Readiness.ts | 131 ++++++++++++++++++ .../member-work-sync/core/domain/index.ts | 1 + .../infrastructure/JsonMemberWorkSyncStore.ts | 10 +- .../MemberWorkSyncPhase2Readiness.test.ts | 77 ++++++++++ .../main/JsonMemberWorkSyncStore.test.ts | 7 + 8 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 src/features/member-work-sync/core/domain/MemberWorkSyncPhase2Readiness.ts create mode 100644 test/features/member-work-sync/core/MemberWorkSyncPhase2Readiness.test.ts diff --git a/docs/team-management/member-work-sync-control-plane-plan.md b/docs/team-management/member-work-sync-control-plane-plan.md index 87ec0f8d..5a17c0b3 100644 --- a/docs/team-management/member-work-sync-control-plane-plan.md +++ b/docs/team-management/member-work-sync-control-plane-plan.md @@ -33,6 +33,7 @@ Current implementation note: - Phase 1 is intentionally shadow-only: it computes agendas, fingerprints, report tokens, reports, persisted status, passive queue reconciliation, startup replay, diagnostics, metrics, and a neutral read-only member details surface. - Phase 1 does not insert inbox messages, send nudges, mark tasks/messages read, or change `TeamTaskStallMonitor` semantics. +- Phase 1.5 exposes a machine-readable `phase2Readiness` assessment from shadow metrics. It can say `collecting_shadow_data`, `blocked`, or `shadow_ready`; it still does not dispatch nudges. - Phase 2 must not start until real shadow metrics confirm that `needs_sync` churn and false positives are acceptably low. Patterns used: @@ -2981,6 +2982,7 @@ Check: - stale report rate; - invalid caught-up attempts; - how many nudges Phase 2 would send. +- `phase2Readiness.state` remains `collecting_shadow_data` until the sample is large enough, `blocked` if rates are noisy, and only then `shadow_ready`. Exit criteria: diff --git a/src/features/member-work-sync/contracts/types.ts b/src/features/member-work-sync/contracts/types.ts index 3139a921..d7a8566b 100644 --- a/src/features/member-work-sync/contracts/types.ts +++ b/src/features/member-work-sync/contracts/types.ts @@ -136,6 +136,45 @@ export interface MemberWorkSyncTeamMetrics { reportAcceptedCount: number; reportRejectedCount: number; recentEvents: MemberWorkSyncMetricEvent[]; + phase2Readiness: MemberWorkSyncPhase2ReadinessAssessment; +} + +export type MemberWorkSyncPhase2ReadinessState = + | 'collecting_shadow_data' + | 'shadow_ready' + | 'blocked'; + +export type MemberWorkSyncPhase2ReadinessReason = + | 'insufficient_members' + | 'insufficient_status_events' + | 'insufficient_observation_window' + | 'would_nudge_rate_high' + | 'fingerprint_churn_high' + | 'report_rejection_rate_high'; + +export interface MemberWorkSyncPhase2ReadinessThresholds { + minObservedMembers: number; + minStatusEvents: number; + minObservationHours: number; + maxWouldNudgesPerMemberHour: number; + maxFingerprintChangesPerMemberHour: number; + maxReportRejectionRate: number; +} + +export interface MemberWorkSyncPhase2ReadinessRates { + observationHours: number; + statusEventCount: number; + wouldNudgesPerMemberHour: number; + fingerprintChangesPerMemberHour: number; + reportRejectionRate: number; +} + +export interface MemberWorkSyncPhase2ReadinessAssessment { + state: MemberWorkSyncPhase2ReadinessState; + reasons: MemberWorkSyncPhase2ReadinessReason[]; + thresholds: MemberWorkSyncPhase2ReadinessThresholds; + rates: MemberWorkSyncPhase2ReadinessRates; + diagnostics: string[]; } export interface MemberWorkSyncReportRequest { diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncMetricsReader.ts b/src/features/member-work-sync/core/application/MemberWorkSyncMetricsReader.ts index 41cb9d4a..702cb81a 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncMetricsReader.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncMetricsReader.ts @@ -1,4 +1,5 @@ import type { MemberWorkSyncMetricsRequest, MemberWorkSyncTeamMetrics } from '../../contracts'; +import { assessMemberWorkSyncPhase2Readiness } from '../domain'; import type { MemberWorkSyncUseCaseDeps } from './ports'; function emptyMetrics(teamName: string, generatedAt: string): MemberWorkSyncTeamMetrics { @@ -20,6 +21,10 @@ function emptyMetrics(teamName: string, generatedAt: string): MemberWorkSyncTeam reportAcceptedCount: 0, reportRejectedCount: 0, recentEvents: [], + phase2Readiness: assessMemberWorkSyncPhase2Readiness({ + memberCount: 0, + recentEvents: [], + }), }; } @@ -30,6 +35,15 @@ export class MemberWorkSyncMetricsReader { if (!this.deps.statusStore.readTeamMetrics) { return emptyMetrics(request.teamName, this.deps.clock.now().toISOString()); } - return this.deps.statusStore.readTeamMetrics(request.teamName); + const metrics = await this.deps.statusStore.readTeamMetrics(request.teamName); + return { + ...metrics, + phase2Readiness: + metrics.phase2Readiness ?? + assessMemberWorkSyncPhase2Readiness({ + memberCount: metrics.memberCount, + recentEvents: metrics.recentEvents, + }), + }; } } diff --git a/src/features/member-work-sync/core/domain/MemberWorkSyncPhase2Readiness.ts b/src/features/member-work-sync/core/domain/MemberWorkSyncPhase2Readiness.ts new file mode 100644 index 00000000..b989f160 --- /dev/null +++ b/src/features/member-work-sync/core/domain/MemberWorkSyncPhase2Readiness.ts @@ -0,0 +1,131 @@ +import type { + MemberWorkSyncMetricEvent, + MemberWorkSyncPhase2ReadinessAssessment, + MemberWorkSyncPhase2ReadinessReason, + MemberWorkSyncPhase2ReadinessThresholds, +} from '../../contracts'; + +export const DEFAULT_MEMBER_WORK_SYNC_PHASE2_READINESS_THRESHOLDS: MemberWorkSyncPhase2ReadinessThresholds = + { + minObservedMembers: 1, + minStatusEvents: 20, + minObservationHours: 1, + maxWouldNudgesPerMemberHour: 2, + maxFingerprintChangesPerMemberHour: 1, + maxReportRejectionRate: 0.2, + }; + +interface AssessMemberWorkSyncPhase2ReadinessInput { + memberCount: number; + recentEvents: MemberWorkSyncMetricEvent[]; + thresholds?: Partial; +} + +function parseTime(value: string): number | null { + const time = new Date(value).getTime(); + return Number.isFinite(time) ? time : null; +} + +function getObservationHours(events: MemberWorkSyncMetricEvent[]): number { + const times = events.flatMap((event) => { + const time = parseTime(event.recordedAt); + return time == null ? [] : [time]; + }); + if (times.length < 2) { + return 0; + } + const min = Math.min(...times); + const max = Math.max(...times); + return Math.max(0, (max - min) / 3_600_000); +} + +function roundRate(value: number): number { + return Math.round(value * 1000) / 1000; +} + +function pushIf( + reasons: MemberWorkSyncPhase2ReadinessReason[], + condition: boolean, + reason: MemberWorkSyncPhase2ReadinessReason +): void { + if (condition) { + reasons.push(reason); + } +} + +export function assessMemberWorkSyncPhase2Readiness({ + memberCount, + recentEvents, + thresholds: thresholdOverrides, +}: AssessMemberWorkSyncPhase2ReadinessInput): MemberWorkSyncPhase2ReadinessAssessment { + const thresholds = { + ...DEFAULT_MEMBER_WORK_SYNC_PHASE2_READINESS_THRESHOLDS, + ...thresholdOverrides, + }; + const statusEvents = recentEvents.filter((event) => event.kind === 'status_evaluated'); + const wouldNudgeEvents = recentEvents.filter((event) => event.kind === 'would_nudge'); + const fingerprintChangeEvents = recentEvents.filter( + (event) => event.kind === 'fingerprint_changed' + ); + const reportAcceptedEvents = recentEvents.filter((event) => event.kind === 'report_accepted'); + const reportRejectedEvents = recentEvents.filter((event) => event.kind === 'report_rejected'); + const observationHours = getObservationHours(recentEvents); + const memberHourDenominator = Math.max(memberCount, 1) * Math.max(observationHours, 1 / 60); + const wouldNudgesPerMemberHour = wouldNudgeEvents.length / memberHourDenominator; + const fingerprintChangesPerMemberHour = fingerprintChangeEvents.length / memberHourDenominator; + const reportEventCount = reportAcceptedEvents.length + reportRejectedEvents.length; + const reportRejectionRate = + reportEventCount > 0 ? reportRejectedEvents.length / reportEventCount : 0; + + const collectingReasons: MemberWorkSyncPhase2ReadinessReason[] = []; + pushIf(collectingReasons, memberCount < thresholds.minObservedMembers, 'insufficient_members'); + pushIf( + collectingReasons, + statusEvents.length < thresholds.minStatusEvents, + 'insufficient_status_events' + ); + pushIf( + collectingReasons, + observationHours < thresholds.minObservationHours, + 'insufficient_observation_window' + ); + + const blockingReasons: MemberWorkSyncPhase2ReadinessReason[] = []; + pushIf( + blockingReasons, + wouldNudgesPerMemberHour > thresholds.maxWouldNudgesPerMemberHour, + 'would_nudge_rate_high' + ); + pushIf( + blockingReasons, + fingerprintChangesPerMemberHour > thresholds.maxFingerprintChangesPerMemberHour, + 'fingerprint_churn_high' + ); + pushIf( + blockingReasons, + reportRejectionRate > thresholds.maxReportRejectionRate, + 'report_rejection_rate_high' + ); + + const state = + collectingReasons.length > 0 + ? 'collecting_shadow_data' + : blockingReasons.length > 0 + ? 'blocked' + : 'shadow_ready'; + const reasons = [...collectingReasons, ...blockingReasons]; + + return { + state, + reasons, + thresholds, + rates: { + observationHours: roundRate(observationHours), + statusEventCount: statusEvents.length, + wouldNudgesPerMemberHour: roundRate(wouldNudgesPerMemberHour), + fingerprintChangesPerMemberHour: roundRate(fingerprintChangesPerMemberHour), + reportRejectionRate: roundRate(reportRejectionRate), + }, + diagnostics: reasons.map((reason) => `phase2_readiness:${reason}`), + }; +} diff --git a/src/features/member-work-sync/core/domain/index.ts b/src/features/member-work-sync/core/domain/index.ts index 8e9ccf16..11ba63d6 100644 --- a/src/features/member-work-sync/core/domain/index.ts +++ b/src/features/member-work-sync/core/domain/index.ts @@ -2,5 +2,6 @@ export * from './ActionableWorkAgenda'; export * from './AgendaFingerprint'; export * from './currentReviewCycle'; export * from './memberName'; +export * from './MemberWorkSyncPhase2Readiness'; export * from './MemberWorkSyncReportValidator'; export * from './SyncDecisionPolicy'; diff --git a/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts b/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts index a71cf6ff..d7b61e8a 100644 --- a/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts +++ b/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts @@ -11,6 +11,7 @@ import type { MemberWorkSyncStatusState, MemberWorkSyncTeamMetrics, } from '../../contracts'; +import { assessMemberWorkSyncPhase2Readiness } from '../../core/domain'; import type { MemberWorkSyncReportStorePort, MemberWorkSyncStatusStorePort, @@ -234,7 +235,7 @@ export class JsonMemberWorkSyncStore const recentEvents = [...(file.metrics?.recentEvents ?? [])].sort((left, right) => left.recordedAt.localeCompare(right.recordedAt) ); - return { + const metrics = { teamName, generatedAt: new Date().toISOString(), memberCount: members.length, @@ -250,6 +251,13 @@ export class JsonMemberWorkSyncStore .length, recentEvents, }; + return { + ...metrics, + phase2Readiness: assessMemberWorkSyncPhase2Readiness({ + memberCount: metrics.memberCount, + recentEvents: metrics.recentEvents, + }), + }; } async appendPendingReport(request: MemberWorkSyncReportRequest, reason: string): Promise { diff --git a/test/features/member-work-sync/core/MemberWorkSyncPhase2Readiness.test.ts b/test/features/member-work-sync/core/MemberWorkSyncPhase2Readiness.test.ts new file mode 100644 index 00000000..f75f0330 --- /dev/null +++ b/test/features/member-work-sync/core/MemberWorkSyncPhase2Readiness.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; + +import { assessMemberWorkSyncPhase2Readiness } from '@features/member-work-sync/core/domain'; +import type { MemberWorkSyncMetricEvent } from '@features/member-work-sync/contracts'; + +function event( + index: number, + kind: MemberWorkSyncMetricEvent['kind'], + recordedAt: string +): MemberWorkSyncMetricEvent { + return { + id: `event-${index}-${kind}`, + teamName: 'team-a', + memberName: index % 2 === 0 ? 'bob' : 'alice', + kind, + state: 'needs_sync', + agendaFingerprint: `agenda:v1:${index}`, + recordedAt, + actionableCount: 1, + }; +} + +function statusEvents(count: number, start = Date.parse('2026-04-29T00:00:00.000Z')) { + return Array.from({ length: count }, (_, index) => + event(index, 'status_evaluated', new Date(start + index * 6 * 60_000).toISOString()) + ); +} + +describe('assessMemberWorkSyncPhase2Readiness', () => { + it('keeps Phase 2 collecting until enough shadow data exists', () => { + const assessment = assessMemberWorkSyncPhase2Readiness({ + memberCount: 0, + recentEvents: [], + }); + + expect(assessment.state).toBe('collecting_shadow_data'); + expect(assessment.reasons).toEqual([ + 'insufficient_members', + 'insufficient_status_events', + 'insufficient_observation_window', + ]); + }); + + it('reports shadow-ready only when sample size and rates are acceptable', () => { + const assessment = assessMemberWorkSyncPhase2Readiness({ + memberCount: 2, + recentEvents: statusEvents(24), + }); + + expect(assessment.state).toBe('shadow_ready'); + expect(assessment.reasons).toEqual([]); + expect(assessment.rates.statusEventCount).toBe(24); + expect(assessment.rates.observationHours).toBeGreaterThan(1); + }); + + it('blocks Phase 2 when would-nudge or fingerprint churn rates are too high', () => { + const base = statusEvents(24); + const noisyEvents = [ + ...base, + ...base + .slice(0, 8) + .map((source, index) => event(100 + index, 'would_nudge', source.recordedAt)), + ...base + .slice(0, 5) + .map((source, index) => event(200 + index, 'fingerprint_changed', source.recordedAt)), + ]; + + const assessment = assessMemberWorkSyncPhase2Readiness({ + memberCount: 1, + recentEvents: noisyEvents, + }); + + expect(assessment.state).toBe('blocked'); + expect(assessment.reasons).toContain('would_nudge_rate_high'); + expect(assessment.reasons).toContain('fingerprint_churn_high'); + }); +}); diff --git a/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts b/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts index a1af8cf3..f5acfff2 100644 --- a/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts +++ b/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts @@ -144,5 +144,12 @@ describe('JsonMemberWorkSyncStore', () => { 'status_evaluated', 'fingerprint_changed', ]); + expect(metrics.phase2Readiness).toMatchObject({ + state: 'collecting_shadow_data', + reasons: expect.arrayContaining([ + 'insufficient_status_events', + 'insufficient_observation_window', + ]), + }); }); });