@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.
- package/README.md +261 -0
- package/package.json +84 -0
- package/src/api/cli-commands.ts +1459 -0
- package/src/api/cli-types.ts +154 -0
- package/src/api/index.ts +24 -0
- package/src/api/mcp-tools.ts +1977 -0
- package/src/application/claim-service.ts +753 -0
- package/src/application/index.ts +46 -0
- package/src/application/load-balancer.ts +840 -0
- package/src/application/work-stealing-service.ts +807 -0
- package/src/domain/events.ts +779 -0
- package/src/domain/index.ts +214 -0
- package/src/domain/repositories.ts +239 -0
- package/src/domain/rules.ts +526 -0
- package/src/domain/types.ts +826 -0
- package/src/index.ts +79 -0
- package/src/infrastructure/claim-repository.ts +358 -0
- package/src/infrastructure/event-store.ts +297 -0
- package/src/infrastructure/index.ts +21 -0
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sparkleideas/claims - Business Rules (ADR-016)
|
|
3
|
+
* Domain rules for claiming, stealing eligibility, and load balancing
|
|
4
|
+
*
|
|
5
|
+
* Pure functions that encode the business logic for the claiming system
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
IssueClaim,
|
|
10
|
+
Claimant,
|
|
11
|
+
ClaimStatus,
|
|
12
|
+
IssuePriority,
|
|
13
|
+
WorkStealingConfig,
|
|
14
|
+
IssueClaimWithStealing,
|
|
15
|
+
StealableInfo,
|
|
16
|
+
StealableReason,
|
|
17
|
+
ExtendedClaimStatus,
|
|
18
|
+
ExtendedIssueClaim,
|
|
19
|
+
AgentLoadInfo,
|
|
20
|
+
LoadBalancingConfig,
|
|
21
|
+
StealReason,
|
|
22
|
+
DEFAULT_LOAD_BALANCING_CONFIG,
|
|
23
|
+
} from './types.js';
|
|
24
|
+
|
|
25
|
+
// =============================================================================
|
|
26
|
+
// Result Types
|
|
27
|
+
// =============================================================================
|
|
28
|
+
|
|
29
|
+
export interface RuleResult<T = boolean> {
|
|
30
|
+
success: boolean;
|
|
31
|
+
data?: T;
|
|
32
|
+
error?: {
|
|
33
|
+
code: string;
|
|
34
|
+
message: string;
|
|
35
|
+
details?: Record<string, unknown>;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function ruleSuccess<T>(data: T): RuleResult<T> {
|
|
40
|
+
return { success: true, data };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function ruleFailure(code: string, message: string, details?: Record<string, unknown>): RuleResult<never> {
|
|
44
|
+
return { success: false, error: { code, message, details } };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// =============================================================================
|
|
48
|
+
// Claim Eligibility Rules
|
|
49
|
+
// =============================================================================
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Check if a claimant can claim a new issue
|
|
53
|
+
*/
|
|
54
|
+
export function canClaimIssue(
|
|
55
|
+
claimant: Claimant,
|
|
56
|
+
existingClaims: readonly IssueClaim[],
|
|
57
|
+
): RuleResult<boolean> {
|
|
58
|
+
// Check capacity
|
|
59
|
+
const maxClaims = claimant.maxConcurrentClaims ?? 5;
|
|
60
|
+
const activeClaims = existingClaims.filter(
|
|
61
|
+
(c) => c.claimant.id === claimant.id && isActiveClaim(c.status),
|
|
62
|
+
).length;
|
|
63
|
+
|
|
64
|
+
if (activeClaims >= maxClaims) {
|
|
65
|
+
return ruleFailure(
|
|
66
|
+
'CLAIMANT_AT_CAPACITY',
|
|
67
|
+
`Claimant has reached maximum concurrent claims (${maxClaims})`,
|
|
68
|
+
{ currentClaims: activeClaims, maxClaims },
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Check workload if available
|
|
73
|
+
const workload = claimant.currentWorkload ?? 0;
|
|
74
|
+
if (workload >= 100) {
|
|
75
|
+
return ruleFailure(
|
|
76
|
+
'CLAIMANT_AT_CAPACITY',
|
|
77
|
+
'Claimant is at 100% capacity',
|
|
78
|
+
{ workload },
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return ruleSuccess(true);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Check if an issue is already claimed
|
|
87
|
+
*/
|
|
88
|
+
export function isIssueClaimed(
|
|
89
|
+
issueId: string,
|
|
90
|
+
claims: readonly IssueClaim[],
|
|
91
|
+
): IssueClaim | null {
|
|
92
|
+
return claims.find(
|
|
93
|
+
(c) => c.issueId === issueId && isActiveClaim(c.status),
|
|
94
|
+
) ?? null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Determine if a claim status is considered "active"
|
|
99
|
+
*/
|
|
100
|
+
export function isActiveClaim(status: ClaimStatus | ExtendedClaimStatus): boolean {
|
|
101
|
+
return [
|
|
102
|
+
'active',
|
|
103
|
+
'paused',
|
|
104
|
+
'blocked',
|
|
105
|
+
'pending_handoff',
|
|
106
|
+
'handoff-pending',
|
|
107
|
+
'in_review',
|
|
108
|
+
'review-requested',
|
|
109
|
+
].includes(status);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Get valid status transitions for original ClaimStatus
|
|
114
|
+
*/
|
|
115
|
+
export function getOriginalStatusTransitions(currentStatus: ClaimStatus): readonly ClaimStatus[] {
|
|
116
|
+
const transitions: Record<ClaimStatus, readonly ClaimStatus[]> = {
|
|
117
|
+
'active': ['pending_handoff', 'in_review', 'completed', 'released', 'paused', 'blocked', 'stealable'],
|
|
118
|
+
'pending_handoff': ['active', 'completed'],
|
|
119
|
+
'in_review': ['active', 'completed'],
|
|
120
|
+
'completed': [],
|
|
121
|
+
'released': [],
|
|
122
|
+
'expired': [],
|
|
123
|
+
'paused': ['active', 'blocked', 'stealable', 'completed'],
|
|
124
|
+
'blocked': ['active', 'paused', 'stealable', 'completed'],
|
|
125
|
+
'stealable': ['active', 'completed'],
|
|
126
|
+
};
|
|
127
|
+
return transitions[currentStatus] ?? [];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Get valid status transitions for ExtendedClaimStatus (ADR-016)
|
|
132
|
+
*/
|
|
133
|
+
export function getExtendedStatusTransitions(currentStatus: ExtendedClaimStatus): readonly ExtendedClaimStatus[] {
|
|
134
|
+
const transitions: Record<ExtendedClaimStatus, readonly ExtendedClaimStatus[]> = {
|
|
135
|
+
'active': ['paused', 'blocked', 'handoff-pending', 'review-requested', 'stealable', 'completed'],
|
|
136
|
+
'paused': ['active', 'blocked', 'handoff-pending', 'stealable', 'completed'],
|
|
137
|
+
'blocked': ['active', 'paused', 'stealable', 'completed'],
|
|
138
|
+
'handoff-pending': ['active', 'completed'],
|
|
139
|
+
'review-requested': ['active', 'completed', 'blocked'],
|
|
140
|
+
'stealable': ['active', 'completed'],
|
|
141
|
+
'completed': [],
|
|
142
|
+
};
|
|
143
|
+
return transitions[currentStatus];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Check if a status transition is valid
|
|
148
|
+
*/
|
|
149
|
+
export function canTransitionStatus(
|
|
150
|
+
currentStatus: ClaimStatus | ExtendedClaimStatus,
|
|
151
|
+
newStatus: ClaimStatus | ExtendedClaimStatus,
|
|
152
|
+
): RuleResult<boolean> {
|
|
153
|
+
if (currentStatus === newStatus) {
|
|
154
|
+
return ruleSuccess(true); // No-op is always valid
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Try original transitions first
|
|
158
|
+
const originalTransitions = getOriginalStatusTransitions(currentStatus as ClaimStatus);
|
|
159
|
+
if (originalTransitions.includes(newStatus as ClaimStatus)) {
|
|
160
|
+
return ruleSuccess(true);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Try extended transitions
|
|
164
|
+
const extendedTransitions = getExtendedStatusTransitions(currentStatus as ExtendedClaimStatus);
|
|
165
|
+
if (extendedTransitions.includes(newStatus as ExtendedClaimStatus)) {
|
|
166
|
+
return ruleSuccess(true);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return ruleFailure(
|
|
170
|
+
'INVALID_STATUS_TRANSITION',
|
|
171
|
+
`Cannot transition from '${currentStatus}' to '${newStatus}'`,
|
|
172
|
+
{ currentStatus, newStatus },
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// =============================================================================
|
|
177
|
+
// Work Stealing Rules
|
|
178
|
+
// =============================================================================
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Check if a claim is eligible to be marked as stealable
|
|
182
|
+
*/
|
|
183
|
+
export function canMarkAsStealable(
|
|
184
|
+
claim: IssueClaimWithStealing,
|
|
185
|
+
config: WorkStealingConfig,
|
|
186
|
+
now: Date = new Date(),
|
|
187
|
+
): RuleResult<StealableReason | null> {
|
|
188
|
+
// Already stealable or terminal status
|
|
189
|
+
if (claim.status === 'stealable' || claim.status === 'completed' || claim.status === 'released') {
|
|
190
|
+
return ruleSuccess(null);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Check for stale (no activity)
|
|
194
|
+
const lastActivity = claim.lastActivityAt.getTime();
|
|
195
|
+
const staleThreshold = config.staleThresholdMinutes * 60 * 1000;
|
|
196
|
+
if (now.getTime() - lastActivity >= staleThreshold) {
|
|
197
|
+
return ruleSuccess('stale');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Check for blocked too long
|
|
201
|
+
if (claim.blockedAt && claim.blockedReason) {
|
|
202
|
+
const blockedThreshold = config.blockedThresholdMinutes * 60 * 1000;
|
|
203
|
+
if (now.getTime() - claim.blockedAt.getTime() >= blockedThreshold) {
|
|
204
|
+
return ruleSuccess('blocked');
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return ruleSuccess(null);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Check if a claim can be stolen by a specific agent
|
|
213
|
+
*/
|
|
214
|
+
export function canStealClaim(
|
|
215
|
+
claim: IssueClaimWithStealing,
|
|
216
|
+
challenger: Claimant,
|
|
217
|
+
config: WorkStealingConfig,
|
|
218
|
+
now: Date = new Date(),
|
|
219
|
+
): RuleResult<boolean> {
|
|
220
|
+
// Check if claim is stealable
|
|
221
|
+
if (claim.status !== 'stealable' && !claim.stealInfo) {
|
|
222
|
+
return ruleFailure('NOT_STEALABLE', `Claim status is '${claim.status}', not stealable`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Check grace period
|
|
226
|
+
if (claim.stealableAt && now < claim.stealableAt) {
|
|
227
|
+
return ruleFailure(
|
|
228
|
+
'IN_GRACE_PERIOD',
|
|
229
|
+
`Grace period has not ended. Ends at ${claim.stealableAt.toISOString()}`,
|
|
230
|
+
{ stealableAt: claim.stealableAt.getTime() },
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Check progress protection
|
|
235
|
+
if (claim.progress >= config.minProgressToProtect) {
|
|
236
|
+
return ruleFailure(
|
|
237
|
+
'PROTECTED_BY_PROGRESS',
|
|
238
|
+
`Claim is protected due to high progress (${claim.progress}%)`,
|
|
239
|
+
{ progress: claim.progress, threshold: config.minProgressToProtect },
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Check cross-type stealing rules if applicable
|
|
244
|
+
if (config.allowCrossTypeSteal && claim.stealInfo?.allowedStealerTypes) {
|
|
245
|
+
const challengerType = (challenger as any).agentType;
|
|
246
|
+
if (challengerType && !claim.stealInfo.allowedStealerTypes.includes(challengerType)) {
|
|
247
|
+
return ruleFailure(
|
|
248
|
+
'CROSS_TYPE_NOT_ALLOWED',
|
|
249
|
+
`Agent type '${challengerType}' cannot steal from this claim`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Cannot steal own claim
|
|
255
|
+
if (claim.claimant.id === challenger.id) {
|
|
256
|
+
return ruleFailure('UNAUTHORIZED', 'Cannot steal your own claim');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Check if there's a pending contest
|
|
260
|
+
if (claim.contestInfo && !claim.contestInfo.resolution) {
|
|
261
|
+
return ruleFailure('CONTEST_PENDING', 'A steal contest is already in progress');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return ruleSuccess(true);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Determine if a contest is required for stealing
|
|
269
|
+
*/
|
|
270
|
+
export function requiresStealContest(
|
|
271
|
+
claim: IssueClaimWithStealing,
|
|
272
|
+
config: WorkStealingConfig,
|
|
273
|
+
): boolean {
|
|
274
|
+
// No contest for timeout/stale claims
|
|
275
|
+
if (claim.stealInfo?.reason === 'stale' || claim.stealInfo?.reason === 'timeout') {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// No contest for manual releases
|
|
280
|
+
if (claim.stealInfo?.reason === 'manual') {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Contest required based on progress
|
|
285
|
+
return claim.progress > 0;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// =============================================================================
|
|
289
|
+
// Handoff Rules
|
|
290
|
+
// =============================================================================
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Check if a handoff can be initiated
|
|
294
|
+
*/
|
|
295
|
+
export function canInitiateHandoff(
|
|
296
|
+
claim: IssueClaim,
|
|
297
|
+
targetClaimant: Claimant,
|
|
298
|
+
currentClaimant: Claimant,
|
|
299
|
+
): RuleResult<boolean> {
|
|
300
|
+
// Cannot handoff completed claims
|
|
301
|
+
if (claim.status === 'completed' || claim.status === 'released') {
|
|
302
|
+
return ruleFailure('INVALID_STATUS', 'Cannot hand off a completed or released claim');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Cannot handoff if already pending
|
|
306
|
+
if (claim.status === 'pending_handoff') {
|
|
307
|
+
return ruleFailure('HANDOFF_PENDING', 'A handoff is already pending for this claim');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Must be the current claimant
|
|
311
|
+
if (claim.claimant.id !== currentClaimant.id) {
|
|
312
|
+
return ruleFailure('UNAUTHORIZED', 'Only the current claimant can initiate a handoff');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Cannot handoff to self
|
|
316
|
+
if (claim.claimant.id === targetClaimant.id) {
|
|
317
|
+
return ruleFailure('VALIDATION_ERROR', 'Cannot hand off to yourself');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Check target capacity
|
|
321
|
+
const targetMaxClaims = targetClaimant.maxConcurrentClaims ?? 5;
|
|
322
|
+
const targetWorkload = targetClaimant.currentWorkload ?? 0;
|
|
323
|
+
if (targetWorkload >= 100) {
|
|
324
|
+
return ruleFailure(
|
|
325
|
+
'TARGET_AT_CAPACITY',
|
|
326
|
+
'Target claimant is at full capacity',
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return ruleSuccess(true);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Check if a handoff can be accepted
|
|
335
|
+
*/
|
|
336
|
+
export function canAcceptHandoff(
|
|
337
|
+
claim: IssueClaim,
|
|
338
|
+
acceptingClaimant: Claimant,
|
|
339
|
+
): RuleResult<boolean> {
|
|
340
|
+
if (claim.status !== 'pending_handoff') {
|
|
341
|
+
return ruleFailure(
|
|
342
|
+
'INVALID_STATUS',
|
|
343
|
+
'Claim is not in pending handoff status',
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Find the pending handoff record
|
|
348
|
+
const pendingHandoff = claim.handoffChain?.find(h => h.status === 'pending');
|
|
349
|
+
if (!pendingHandoff) {
|
|
350
|
+
return ruleFailure('HANDOFF_NOT_FOUND', 'No pending handoff found');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (pendingHandoff.to.id !== acceptingClaimant.id) {
|
|
354
|
+
return ruleFailure(
|
|
355
|
+
'UNAUTHORIZED',
|
|
356
|
+
'Only the target claimant can accept this handoff',
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return ruleSuccess(true);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Check if a handoff can be rejected
|
|
365
|
+
*/
|
|
366
|
+
export function canRejectHandoff(
|
|
367
|
+
claim: IssueClaim,
|
|
368
|
+
rejectingClaimant: Claimant,
|
|
369
|
+
): RuleResult<boolean> {
|
|
370
|
+
if (claim.status !== 'pending_handoff') {
|
|
371
|
+
return ruleFailure(
|
|
372
|
+
'INVALID_STATUS',
|
|
373
|
+
'Claim is not in pending handoff status',
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const pendingHandoff = claim.handoffChain?.find(h => h.status === 'pending');
|
|
378
|
+
if (!pendingHandoff) {
|
|
379
|
+
return ruleFailure('HANDOFF_NOT_FOUND', 'No pending handoff found');
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Either the target or the initiator can reject
|
|
383
|
+
const canReject =
|
|
384
|
+
pendingHandoff.to.id === rejectingClaimant.id ||
|
|
385
|
+
pendingHandoff.from.id === rejectingClaimant.id;
|
|
386
|
+
|
|
387
|
+
if (!canReject) {
|
|
388
|
+
return ruleFailure(
|
|
389
|
+
'UNAUTHORIZED',
|
|
390
|
+
'Only the target or initiating claimant can reject this handoff',
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return ruleSuccess(true);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// =============================================================================
|
|
398
|
+
// Load Balancing Rules
|
|
399
|
+
// =============================================================================
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Determine if an agent is overloaded
|
|
403
|
+
*/
|
|
404
|
+
export function isAgentOverloaded(
|
|
405
|
+
load: number,
|
|
406
|
+
threshold: number = 90,
|
|
407
|
+
): boolean {
|
|
408
|
+
return load >= threshold;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Determine if an agent is underloaded
|
|
413
|
+
*/
|
|
414
|
+
export function isAgentUnderloaded(
|
|
415
|
+
load: number,
|
|
416
|
+
threshold: number = 30,
|
|
417
|
+
): boolean {
|
|
418
|
+
return load <= threshold;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Check if rebalancing is needed for a set of agents
|
|
423
|
+
*/
|
|
424
|
+
export function needsRebalancing(
|
|
425
|
+
agentLoads: readonly { load: number }[],
|
|
426
|
+
config: { overloadThreshold: number; underloadThreshold: number; rebalanceThreshold: number },
|
|
427
|
+
): boolean {
|
|
428
|
+
if (agentLoads.length < 2) {
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Check for overloaded agents
|
|
433
|
+
const hasOverloaded = agentLoads.some((a) => isAgentOverloaded(a.load, config.overloadThreshold));
|
|
434
|
+
const hasUnderloaded = agentLoads.some((a) => isAgentUnderloaded(a.load, config.underloadThreshold));
|
|
435
|
+
|
|
436
|
+
if (hasOverloaded && hasUnderloaded) {
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Check for large load differential
|
|
441
|
+
const loads = agentLoads.map((a) => a.load);
|
|
442
|
+
const maxLoad = Math.max(...loads);
|
|
443
|
+
const minLoad = Math.min(...loads);
|
|
444
|
+
const loadDifferential = maxLoad - minLoad;
|
|
445
|
+
|
|
446
|
+
return loadDifferential >= config.rebalanceThreshold;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Check if a claim can be moved during rebalancing
|
|
451
|
+
*/
|
|
452
|
+
export function canMoveClaim(claim: IssueClaimWithStealing): boolean {
|
|
453
|
+
// Cannot move completed claims
|
|
454
|
+
if (claim.status === 'completed' || claim.status === 'released') {
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Cannot move claims with pending handoffs
|
|
459
|
+
if (claim.status === 'pending_handoff') {
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Cannot move high-progress claims (>75%)
|
|
464
|
+
if (claim.progress > 75) {
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Cannot move claims with active reviews
|
|
469
|
+
if (claim.status === 'in_review') {
|
|
470
|
+
return false;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Cannot move contested claims
|
|
474
|
+
if (claim.contestInfo && !claim.contestInfo.resolution) {
|
|
475
|
+
return false;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// =============================================================================
|
|
482
|
+
// Validation Rules
|
|
483
|
+
// =============================================================================
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Validate claim priority
|
|
487
|
+
*/
|
|
488
|
+
export function isValidPriority(priority: string): priority is IssuePriority {
|
|
489
|
+
return ['critical', 'high', 'medium', 'low'].includes(priority);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Validate claim status
|
|
494
|
+
*/
|
|
495
|
+
export function isValidStatus(status: string): status is ClaimStatus {
|
|
496
|
+
return [
|
|
497
|
+
'active',
|
|
498
|
+
'pending_handoff',
|
|
499
|
+
'in_review',
|
|
500
|
+
'completed',
|
|
501
|
+
'released',
|
|
502
|
+
'expired',
|
|
503
|
+
].includes(status);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Validate extended claim status (ADR-016)
|
|
508
|
+
*/
|
|
509
|
+
export function isValidExtendedStatus(status: string): status is ExtendedClaimStatus {
|
|
510
|
+
return [
|
|
511
|
+
'active',
|
|
512
|
+
'paused',
|
|
513
|
+
'handoff-pending',
|
|
514
|
+
'review-requested',
|
|
515
|
+
'blocked',
|
|
516
|
+
'stealable',
|
|
517
|
+
'completed',
|
|
518
|
+
].includes(status);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Validate repository format (owner/repo)
|
|
523
|
+
*/
|
|
524
|
+
export function isValidRepository(repository: string): boolean {
|
|
525
|
+
return /^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_.-]+$/.test(repository);
|
|
526
|
+
}
|