@sparkleideas/claims 3.5.2-patch.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,753 @@
1
+ /**
2
+ * Claim Service - Application Layer
3
+ *
4
+ * Implements IClaimService interface for managing issue claims.
5
+ * Supports both human and agent claimants with handoff capabilities.
6
+ *
7
+ * Key Features:
8
+ * - Issue claiming and releasing
9
+ * - Human-to-agent and agent-to-agent handoffs
10
+ * - Status tracking and updates
11
+ * - Auto-management (expiration, auto-assignment)
12
+ * - Full event sourcing (ADR-007)
13
+ *
14
+ * @module v3/claims/application/claim-service
15
+ */
16
+
17
+ import { randomUUID } from 'crypto';
18
+ import {
19
+ ClaimId,
20
+ IssueId,
21
+ Claimant,
22
+ ClaimStatus,
23
+ Issue,
24
+ IssueClaim,
25
+ IssueWithClaim,
26
+ IssueFilters,
27
+ ClaimResult,
28
+ Duration,
29
+ HandoffRecord,
30
+ ClaimOperationError,
31
+ durationToMs,
32
+ } from '../domain/types.js';
33
+ import {
34
+ IClaimRepository,
35
+ IIssueRepository,
36
+ IClaimantRepository,
37
+ IClaimEventStore,
38
+ } from '../domain/repositories.js';
39
+ import {
40
+ createClaimCreatedEvent,
41
+ createClaimReleasedEvent,
42
+ createClaimExpiredEvent,
43
+ createClaimStatusChangedEvent,
44
+ createClaimNoteAddedEvent,
45
+ createHandoffRequestedEvent,
46
+ createHandoffAcceptedEvent,
47
+ createHandoffRejectedEvent,
48
+ createReviewRequestedEvent,
49
+ } from '../domain/events.js';
50
+
51
+ // =============================================================================
52
+ // Service Interface
53
+ // =============================================================================
54
+
55
+ /**
56
+ * IClaimService interface - main contract for claim operations
57
+ */
58
+ export interface IClaimService {
59
+ // ==========================================================================
60
+ // Claiming
61
+ // ==========================================================================
62
+
63
+ /**
64
+ * Claim an issue for a claimant
65
+ */
66
+ claim(issueId: string, claimant: Claimant): Promise<ClaimResult>;
67
+
68
+ /**
69
+ * Release a claim on an issue
70
+ */
71
+ release(issueId: string, claimant: Claimant): Promise<void>;
72
+
73
+ // ==========================================================================
74
+ // Handoffs (human<->agent and agent<->agent)
75
+ // ==========================================================================
76
+
77
+ /**
78
+ * Request a handoff from one claimant to another
79
+ */
80
+ requestHandoff(issueId: string, from: Claimant, to: Claimant, reason: string): Promise<void>;
81
+
82
+ /**
83
+ * Accept a pending handoff
84
+ */
85
+ acceptHandoff(issueId: string, claimant: Claimant): Promise<void>;
86
+
87
+ /**
88
+ * Reject a pending handoff
89
+ */
90
+ rejectHandoff(issueId: string, claimant: Claimant, reason: string): Promise<void>;
91
+
92
+ // ==========================================================================
93
+ // Status
94
+ // ==========================================================================
95
+
96
+ /**
97
+ * Update the status of a claim
98
+ */
99
+ updateStatus(issueId: string, status: ClaimStatus, note?: string): Promise<void>;
100
+
101
+ /**
102
+ * Request review for a claimed issue
103
+ */
104
+ requestReview(issueId: string, reviewers: Claimant[]): Promise<void>;
105
+
106
+ // ==========================================================================
107
+ // Queries
108
+ // ==========================================================================
109
+
110
+ /**
111
+ * Get all issues claimed by a specific claimant
112
+ */
113
+ getClaimedBy(claimant: Claimant): Promise<IssueClaim[]>;
114
+
115
+ /**
116
+ * Get available (unclaimed) issues matching filters
117
+ */
118
+ getAvailableIssues(filters?: IssueFilters): Promise<Issue[]>;
119
+
120
+ /**
121
+ * Get the current status of an issue including claim info
122
+ */
123
+ getIssueStatus(issueId: string): Promise<IssueWithClaim>;
124
+
125
+ // ==========================================================================
126
+ // Auto-management
127
+ // ==========================================================================
128
+
129
+ /**
130
+ * Expire stale claims that haven't had activity
131
+ */
132
+ expireStale(maxAge: Duration): Promise<IssueClaim[]>;
133
+
134
+ /**
135
+ * Auto-assign an issue to the best available claimant
136
+ */
137
+ autoAssign(issue: Issue): Promise<Claimant | null>;
138
+ }
139
+
140
+ // =============================================================================
141
+ // Service Implementation
142
+ // =============================================================================
143
+
144
+ /**
145
+ * Claim Service implementation with event sourcing
146
+ */
147
+ export class ClaimService implements IClaimService {
148
+ constructor(
149
+ private readonly claimRepository: IClaimRepository,
150
+ private readonly issueRepository: IIssueRepository,
151
+ private readonly claimantRepository: IClaimantRepository,
152
+ private readonly eventStore: IClaimEventStore
153
+ ) {}
154
+
155
+ // ==========================================================================
156
+ // Claiming
157
+ // ==========================================================================
158
+
159
+ async claim(issueId: string, claimant: Claimant): Promise<ClaimResult> {
160
+ // Validate issue exists
161
+ const issue = await this.issueRepository.findById(issueId);
162
+ if (!issue) {
163
+ return {
164
+ success: false,
165
+ error: {
166
+ code: 'ISSUE_NOT_FOUND',
167
+ message: `Issue ${issueId} not found`,
168
+ },
169
+ };
170
+ }
171
+
172
+ // Check if already claimed
173
+ const existingClaim = await this.claimRepository.findByIssueId(issueId);
174
+ if (existingClaim && existingClaim.status === 'active') {
175
+ return {
176
+ success: false,
177
+ error: {
178
+ code: 'ALREADY_CLAIMED',
179
+ message: `Issue ${issueId} is already claimed by ${existingClaim.claimant.name}`,
180
+ details: { currentClaimant: existingClaim.claimant },
181
+ },
182
+ };
183
+ }
184
+
185
+ // Check claimant's current workload
186
+ const currentClaimCount = await this.claimRepository.countByClaimant(claimant.id);
187
+ const maxClaims = claimant.maxConcurrentClaims ?? 5;
188
+ if (currentClaimCount >= maxClaims) {
189
+ return {
190
+ success: false,
191
+ error: {
192
+ code: 'MAX_CLAIMS_EXCEEDED',
193
+ message: `Claimant ${claimant.name} has reached maximum concurrent claims (${maxClaims})`,
194
+ details: { currentClaims: currentClaimCount, maxClaims },
195
+ },
196
+ };
197
+ }
198
+
199
+ // Validate capabilities match (if required)
200
+ if (issue.requiredCapabilities && issue.requiredCapabilities.length > 0) {
201
+ const claimantCapabilities = claimant.capabilities ?? [];
202
+ const missingCapabilities = issue.requiredCapabilities.filter(
203
+ (cap) => !claimantCapabilities.includes(cap)
204
+ );
205
+ if (missingCapabilities.length > 0) {
206
+ return {
207
+ success: false,
208
+ error: {
209
+ code: 'CAPABILITY_MISMATCH',
210
+ message: `Claimant lacks required capabilities: ${missingCapabilities.join(', ')}`,
211
+ details: { missingCapabilities, requiredCapabilities: issue.requiredCapabilities },
212
+ },
213
+ };
214
+ }
215
+ }
216
+
217
+ // Create the claim
218
+ const now = new Date();
219
+ const claimId = `claim-${randomUUID()}` as ClaimId;
220
+ const claim: IssueClaim = {
221
+ id: claimId,
222
+ issueId,
223
+ claimant,
224
+ status: 'active',
225
+ claimedAt: now,
226
+ lastActivityAt: now,
227
+ notes: [],
228
+ handoffChain: [],
229
+ reviewers: [],
230
+ };
231
+
232
+ // Save claim
233
+ await this.claimRepository.save(claim);
234
+
235
+ // Emit event
236
+ const event = createClaimCreatedEvent(claimId, issueId, claimant);
237
+ await this.eventStore.append(event);
238
+
239
+ return { success: true, claim };
240
+ }
241
+
242
+ async release(issueId: string, claimant: Claimant): Promise<void> {
243
+ const claim = await this.claimRepository.findByIssueId(issueId);
244
+
245
+ // Validate claim exists
246
+ if (!claim) {
247
+ throw new ClaimOperationError('NOT_CLAIMED', `Issue ${issueId} is not claimed`);
248
+ }
249
+
250
+ // Validate claimant owns the claim
251
+ if (claim.claimant.id !== claimant.id) {
252
+ throw new ClaimOperationError(
253
+ 'UNAUTHORIZED',
254
+ `Claimant ${claimant.name} does not own the claim on issue ${issueId}`
255
+ );
256
+ }
257
+
258
+ // Check for pending handoffs
259
+ const pendingHandoff = claim.handoffChain?.find((h) => h.status === 'pending');
260
+ if (pendingHandoff) {
261
+ throw new ClaimOperationError(
262
+ 'HANDOFF_PENDING',
263
+ `Cannot release claim with pending handoff to ${pendingHandoff.to.name}`
264
+ );
265
+ }
266
+
267
+ // Update claim status
268
+ const previousStatus = claim.status;
269
+ claim.status = 'released';
270
+ claim.lastActivityAt = new Date();
271
+
272
+ await this.claimRepository.save(claim);
273
+
274
+ // Emit events
275
+ const releaseEvent = createClaimReleasedEvent(claim.id, issueId, claimant);
276
+ await this.eventStore.append(releaseEvent);
277
+
278
+ if (previousStatus !== 'released') {
279
+ const statusEvent = createClaimStatusChangedEvent(
280
+ claim.id,
281
+ issueId,
282
+ previousStatus,
283
+ 'released'
284
+ );
285
+ await this.eventStore.append(statusEvent);
286
+ }
287
+ }
288
+
289
+ // ==========================================================================
290
+ // Handoffs
291
+ // ==========================================================================
292
+
293
+ async requestHandoff(issueId: string, from: Claimant, to: Claimant, reason: string): Promise<void> {
294
+ const claim = await this.claimRepository.findByIssueId(issueId);
295
+
296
+ // Validate claim exists
297
+ if (!claim) {
298
+ throw new ClaimOperationError('NOT_CLAIMED', `Issue ${issueId} is not claimed`);
299
+ }
300
+
301
+ // Validate 'from' claimant owns the claim
302
+ if (claim.claimant.id !== from.id) {
303
+ throw new ClaimOperationError(
304
+ 'UNAUTHORIZED',
305
+ `Claimant ${from.name} does not own the claim on issue ${issueId}`
306
+ );
307
+ }
308
+
309
+ // Check for existing pending handoffs
310
+ const existingPending = claim.handoffChain?.find((h) => h.status === 'pending');
311
+ if (existingPending) {
312
+ throw new ClaimOperationError(
313
+ 'HANDOFF_PENDING',
314
+ `A handoff to ${existingPending.to.name} is already pending`
315
+ );
316
+ }
317
+
318
+ // Validate 'to' claimant exists
319
+ const toClaimant = await this.claimantRepository.findById(to.id);
320
+ if (!toClaimant) {
321
+ throw new ClaimOperationError('CLAIMANT_NOT_FOUND', `Target claimant ${to.name} not found`);
322
+ }
323
+
324
+ // Create handoff record
325
+ const handoffId = `handoff-${randomUUID()}`;
326
+ const handoffRecord: HandoffRecord = {
327
+ id: handoffId,
328
+ from,
329
+ to,
330
+ reason,
331
+ status: 'pending',
332
+ requestedAt: new Date(),
333
+ };
334
+
335
+ // Update claim
336
+ claim.handoffChain = claim.handoffChain ?? [];
337
+ claim.handoffChain.push(handoffRecord);
338
+ claim.status = 'pending_handoff';
339
+ claim.lastActivityAt = new Date();
340
+
341
+ await this.claimRepository.save(claim);
342
+
343
+ // Emit events
344
+ const handoffEvent = createHandoffRequestedEvent(claim.id, issueId, handoffId, from, to, reason);
345
+ await this.eventStore.append(handoffEvent);
346
+
347
+ const statusEvent = createClaimStatusChangedEvent(
348
+ claim.id,
349
+ issueId,
350
+ 'active',
351
+ 'pending_handoff'
352
+ );
353
+ await this.eventStore.append(statusEvent);
354
+ }
355
+
356
+ async acceptHandoff(issueId: string, claimant: Claimant): Promise<void> {
357
+ const claim = await this.claimRepository.findByIssueId(issueId);
358
+
359
+ // Validate claim exists
360
+ if (!claim) {
361
+ throw new ClaimOperationError('NOT_CLAIMED', `Issue ${issueId} is not claimed`);
362
+ }
363
+
364
+ // Find pending handoff for this claimant
365
+ const pendingHandoff = claim.handoffChain?.find(
366
+ (h) => h.status === 'pending' && h.to.id === claimant.id
367
+ );
368
+
369
+ if (!pendingHandoff) {
370
+ throw new ClaimOperationError(
371
+ 'HANDOFF_NOT_FOUND',
372
+ `No pending handoff found for claimant ${claimant.name}`
373
+ );
374
+ }
375
+
376
+ // Check claimant's workload
377
+ const currentClaimCount = await this.claimRepository.countByClaimant(claimant.id);
378
+ const maxClaims = claimant.maxConcurrentClaims ?? 5;
379
+ if (currentClaimCount >= maxClaims) {
380
+ throw new ClaimOperationError(
381
+ 'MAX_CLAIMS_EXCEEDED',
382
+ `Cannot accept handoff: claimant ${claimant.name} at max capacity`
383
+ );
384
+ }
385
+
386
+ // Update handoff record
387
+ pendingHandoff.status = 'accepted';
388
+ pendingHandoff.resolvedAt = new Date();
389
+
390
+ // Transfer claim to new owner
391
+ const previousClaimant = claim.claimant;
392
+ claim.claimant = claimant;
393
+ claim.status = 'active';
394
+ claim.lastActivityAt = new Date();
395
+
396
+ await this.claimRepository.save(claim);
397
+
398
+ // Emit events
399
+ const acceptEvent = createHandoffAcceptedEvent(
400
+ claim.id,
401
+ issueId,
402
+ pendingHandoff.id,
403
+ previousClaimant,
404
+ claimant
405
+ );
406
+ await this.eventStore.append(acceptEvent);
407
+
408
+ const statusEvent = createClaimStatusChangedEvent(
409
+ claim.id,
410
+ issueId,
411
+ 'pending_handoff',
412
+ 'active'
413
+ );
414
+ await this.eventStore.append(statusEvent);
415
+ }
416
+
417
+ async rejectHandoff(issueId: string, claimant: Claimant, reason: string): Promise<void> {
418
+ const claim = await this.claimRepository.findByIssueId(issueId);
419
+
420
+ // Validate claim exists
421
+ if (!claim) {
422
+ throw new ClaimOperationError('NOT_CLAIMED', `Issue ${issueId} is not claimed`);
423
+ }
424
+
425
+ // Find pending handoff for this claimant
426
+ const pendingHandoff = claim.handoffChain?.find(
427
+ (h) => h.status === 'pending' && h.to.id === claimant.id
428
+ );
429
+
430
+ if (!pendingHandoff) {
431
+ throw new ClaimOperationError(
432
+ 'HANDOFF_NOT_FOUND',
433
+ `No pending handoff found for claimant ${claimant.name}`
434
+ );
435
+ }
436
+
437
+ // Update handoff record
438
+ pendingHandoff.status = 'rejected';
439
+ pendingHandoff.resolvedAt = new Date();
440
+ pendingHandoff.rejectionReason = reason;
441
+
442
+ // Revert claim status to active
443
+ claim.status = 'active';
444
+ claim.lastActivityAt = new Date();
445
+
446
+ await this.claimRepository.save(claim);
447
+
448
+ // Emit events
449
+ const rejectEvent = createHandoffRejectedEvent(
450
+ claim.id,
451
+ issueId,
452
+ pendingHandoff.id,
453
+ pendingHandoff.from,
454
+ claimant,
455
+ reason
456
+ );
457
+ await this.eventStore.append(rejectEvent);
458
+
459
+ const statusEvent = createClaimStatusChangedEvent(
460
+ claim.id,
461
+ issueId,
462
+ 'pending_handoff',
463
+ 'active'
464
+ );
465
+ await this.eventStore.append(statusEvent);
466
+ }
467
+
468
+ // ==========================================================================
469
+ // Status
470
+ // ==========================================================================
471
+
472
+ async updateStatus(issueId: string, status: ClaimStatus, note?: string): Promise<void> {
473
+ const claim = await this.claimRepository.findByIssueId(issueId);
474
+
475
+ // Validate claim exists
476
+ if (!claim) {
477
+ throw new ClaimOperationError('NOT_CLAIMED', `Issue ${issueId} is not claimed`);
478
+ }
479
+
480
+ // Validate status transition
481
+ const validTransitions = this.getValidStatusTransitions(claim.status);
482
+ if (!validTransitions.includes(status)) {
483
+ throw new ClaimOperationError(
484
+ 'INVALID_STATUS_TRANSITION',
485
+ `Cannot transition from ${claim.status} to ${status}`,
486
+ { currentStatus: claim.status, requestedStatus: status, validTransitions }
487
+ );
488
+ }
489
+
490
+ const previousStatus = claim.status;
491
+ claim.status = status;
492
+ claim.lastActivityAt = new Date();
493
+
494
+ // Add note if provided
495
+ if (note) {
496
+ claim.notes = claim.notes ?? [];
497
+ claim.notes.push(`[${new Date().toISOString()}] Status changed to ${status}: ${note}`);
498
+ }
499
+
500
+ await this.claimRepository.save(claim);
501
+
502
+ // Emit event
503
+ const statusEvent = createClaimStatusChangedEvent(
504
+ claim.id,
505
+ issueId,
506
+ previousStatus,
507
+ status,
508
+ note
509
+ );
510
+ await this.eventStore.append(statusEvent);
511
+ }
512
+
513
+ async requestReview(issueId: string, reviewers: Claimant[]): Promise<void> {
514
+ const claim = await this.claimRepository.findByIssueId(issueId);
515
+
516
+ // Validate claim exists
517
+ if (!claim) {
518
+ throw new ClaimOperationError('NOT_CLAIMED', `Issue ${issueId} is not claimed`);
519
+ }
520
+
521
+ // Validate at least one reviewer
522
+ if (!reviewers || reviewers.length === 0) {
523
+ throw new ClaimOperationError('VALIDATION_ERROR', 'At least one reviewer is required');
524
+ }
525
+
526
+ // Validate reviewers exist
527
+ for (const reviewer of reviewers) {
528
+ const exists = await this.claimantRepository.exists(reviewer.id);
529
+ if (!exists) {
530
+ throw new ClaimOperationError(
531
+ 'CLAIMANT_NOT_FOUND',
532
+ `Reviewer ${reviewer.name} not found`
533
+ );
534
+ }
535
+ }
536
+
537
+ // Update claim
538
+ const previousStatus = claim.status;
539
+ claim.reviewers = reviewers;
540
+ claim.status = 'in_review';
541
+ claim.lastActivityAt = new Date();
542
+
543
+ await this.claimRepository.save(claim);
544
+
545
+ // Emit events
546
+ const reviewEvent = createReviewRequestedEvent(
547
+ claim.id,
548
+ issueId,
549
+ reviewers,
550
+ claim.claimant
551
+ );
552
+ await this.eventStore.append(reviewEvent);
553
+
554
+ if (previousStatus !== 'in_review') {
555
+ const statusEvent = createClaimStatusChangedEvent(
556
+ claim.id,
557
+ issueId,
558
+ previousStatus,
559
+ 'in_review'
560
+ );
561
+ await this.eventStore.append(statusEvent);
562
+ }
563
+ }
564
+
565
+ // ==========================================================================
566
+ // Queries
567
+ // ==========================================================================
568
+
569
+ async getClaimedBy(claimant: Claimant): Promise<IssueClaim[]> {
570
+ return this.claimRepository.findByClaimant(claimant);
571
+ }
572
+
573
+ async getAvailableIssues(filters?: IssueFilters): Promise<Issue[]> {
574
+ return this.issueRepository.findAvailable(filters);
575
+ }
576
+
577
+ async getIssueStatus(issueId: string): Promise<IssueWithClaim> {
578
+ const issue = await this.issueRepository.findById(issueId);
579
+ if (!issue) {
580
+ throw new ClaimOperationError('ISSUE_NOT_FOUND', `Issue ${issueId} not found`);
581
+ }
582
+
583
+ const claim = await this.claimRepository.findByIssueId(issueId);
584
+ const pendingHandoffs = claim?.handoffChain?.filter((h) => h.status === 'pending') ?? [];
585
+
586
+ return {
587
+ issue,
588
+ claim,
589
+ pendingHandoffs,
590
+ };
591
+ }
592
+
593
+ // ==========================================================================
594
+ // Auto-management
595
+ // ==========================================================================
596
+
597
+ async expireStale(maxAge: Duration): Promise<IssueClaim[]> {
598
+ const maxAgeMs = durationToMs(maxAge);
599
+ const staleSince = new Date(Date.now() - maxAgeMs);
600
+
601
+ const staleClaims = await this.claimRepository.findStaleClaims(staleSince);
602
+ const expiredClaims: IssueClaim[] = [];
603
+
604
+ for (const claim of staleClaims) {
605
+ // Only expire active claims
606
+ if (claim.status !== 'active') {
607
+ continue;
608
+ }
609
+
610
+ const previousStatus = claim.status;
611
+ claim.status = 'expired';
612
+
613
+ await this.claimRepository.save(claim);
614
+
615
+ // Emit events
616
+ const expireEvent = createClaimExpiredEvent(
617
+ claim.id,
618
+ claim.issueId,
619
+ claim.claimant,
620
+ claim.lastActivityAt.getTime()
621
+ );
622
+ await this.eventStore.append(expireEvent);
623
+
624
+ const statusEvent = createClaimStatusChangedEvent(
625
+ claim.id,
626
+ claim.issueId,
627
+ previousStatus,
628
+ 'expired'
629
+ );
630
+ await this.eventStore.append(statusEvent);
631
+
632
+ expiredClaims.push(claim);
633
+ }
634
+
635
+ return expiredClaims;
636
+ }
637
+
638
+ async autoAssign(issue: Issue): Promise<Claimant | null> {
639
+ // Get available claimants
640
+ const availableClaimants = await this.claimantRepository.findAvailable();
641
+
642
+ if (availableClaimants.length === 0) {
643
+ return null;
644
+ }
645
+
646
+ // Score claimants based on capability match and workload
647
+ const scoredClaimants = availableClaimants.map((claimant) => {
648
+ let score = 0;
649
+
650
+ // Capability matching
651
+ if (issue.requiredCapabilities && issue.requiredCapabilities.length > 0) {
652
+ const claimantCapabilities = claimant.capabilities ?? [];
653
+ const matchedCapabilities = issue.requiredCapabilities.filter((cap) =>
654
+ claimantCapabilities.includes(cap)
655
+ );
656
+ score += matchedCapabilities.length * 10;
657
+
658
+ // Bonus for having all required capabilities
659
+ if (matchedCapabilities.length === issue.requiredCapabilities.length) {
660
+ score += 20;
661
+ }
662
+ } else {
663
+ // No specific capabilities required, all claimants are equal
664
+ score += 10;
665
+ }
666
+
667
+ // Specialization matching (labels to specializations)
668
+ if (claimant.specializations && issue.labels) {
669
+ const matchedSpecializations = issue.labels.filter(
670
+ (label) => claimant.specializations?.includes(label)
671
+ );
672
+ score += matchedSpecializations.length * 5;
673
+ }
674
+
675
+ // Lower workload is better
676
+ const workload = claimant.currentWorkload ?? 0;
677
+ const maxClaims = claimant.maxConcurrentClaims ?? 5;
678
+ const utilizationPenalty = (workload / maxClaims) * 15;
679
+ score -= utilizationPenalty;
680
+
681
+ // Prefer agents for agent-suitable tasks
682
+ if (claimant.type === 'agent' && issue.complexity !== 'epic') {
683
+ score += 3;
684
+ }
685
+
686
+ return { claimant, score };
687
+ });
688
+
689
+ // Sort by score (descending)
690
+ scoredClaimants.sort((a, b) => b.score - a.score);
691
+
692
+ // Return the best match
693
+ const bestMatch = scoredClaimants[0];
694
+
695
+ // Only return if the claimant has required capabilities
696
+ if (issue.requiredCapabilities && issue.requiredCapabilities.length > 0) {
697
+ const claimantCapabilities = bestMatch.claimant.capabilities ?? [];
698
+ const hasAllRequired = issue.requiredCapabilities.every((cap) =>
699
+ claimantCapabilities.includes(cap)
700
+ );
701
+ if (!hasAllRequired) {
702
+ return null;
703
+ }
704
+ }
705
+
706
+ return bestMatch.claimant;
707
+ }
708
+
709
+ // ==========================================================================
710
+ // Private Helpers
711
+ // ==========================================================================
712
+
713
+ /**
714
+ * Get valid status transitions from a given status
715
+ */
716
+ private getValidStatusTransitions(currentStatus: ClaimStatus): ClaimStatus[] {
717
+ const transitions: Record<ClaimStatus, ClaimStatus[]> = {
718
+ active: ['pending_handoff', 'in_review', 'completed', 'released', 'paused', 'blocked', 'stealable'],
719
+ pending_handoff: ['active', 'released'],
720
+ in_review: ['active', 'completed', 'released'],
721
+ completed: [], // Terminal state
722
+ released: [], // Terminal state
723
+ expired: [], // Terminal state
724
+ paused: ['active', 'blocked', 'stealable', 'completed'],
725
+ blocked: ['active', 'paused', 'stealable', 'completed'],
726
+ stealable: ['active', 'completed'],
727
+ };
728
+
729
+ return transitions[currentStatus] ?? [];
730
+ }
731
+
732
+ // ==========================================================================
733
+ // Lifecycle
734
+ // ==========================================================================
735
+
736
+ async initialize(): Promise<void> {
737
+ await Promise.all([
738
+ this.claimRepository.initialize(),
739
+ this.issueRepository.initialize(),
740
+ this.claimantRepository.initialize(),
741
+ this.eventStore.initialize(),
742
+ ]);
743
+ }
744
+
745
+ async shutdown(): Promise<void> {
746
+ await Promise.all([
747
+ this.claimRepository.shutdown(),
748
+ this.issueRepository.shutdown(),
749
+ this.claimantRepository.shutdown(),
750
+ this.eventStore.shutdown(),
751
+ ]);
752
+ }
753
+ }