@haoyiyin/workflow 0.2.2 → 0.2.4

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.
Files changed (61) hide show
  1. package/package.json +9 -8
  2. package/src/agents/contracts.ts +559 -0
  3. package/src/agents/dispatcher-enhanced.ts +350 -0
  4. package/src/agents/dispatcher.ts +680 -0
  5. package/src/agents/index.ts +48 -0
  6. package/src/agents/resilience.ts +255 -0
  7. package/src/agents/token-budget.ts +83 -0
  8. package/src/agents/types.ts +73 -0
  9. package/src/guard/main-agent.ts +245 -0
  10. package/src/hooks/builtin/index.ts +8 -0
  11. package/src/hooks/builtin/on-error.ts +23 -0
  12. package/src/hooks/builtin/post-execute.ts +40 -0
  13. package/src/hooks/builtin/post-plan.ts +23 -0
  14. package/src/hooks/builtin/pre-execute.ts +30 -0
  15. package/src/hooks/builtin/pre-plan.ts +26 -0
  16. package/src/hooks/index.ts +7 -0
  17. package/src/hooks/loader.ts +98 -0
  18. package/src/hooks/manager.ts +99 -0
  19. package/src/hooks/types-enhanced.ts +38 -0
  20. package/src/hooks/types.ts +35 -0
  21. package/src/index.ts +127 -0
  22. package/src/persistence/index.ts +17 -0
  23. package/src/persistence/plan-md.ts +141 -0
  24. package/src/persistence/state-md.ts +167 -0
  25. package/src/persistence/types.ts +89 -0
  26. package/src/router/classifier.ts +610 -0
  27. package/src/router/guard.ts +483 -0
  28. package/src/router/index.ts +22 -0
  29. package/src/router/router.ts +108 -0
  30. package/src/router/types.ts +127 -0
  31. package/src/skills/agents-md/SKILL.md +45 -0
  32. package/src/skills/agents-md/index.ts +33 -0
  33. package/src/skills/execute-plan/SKILL.md +60 -0
  34. package/src/skills/execute-plan/index.ts +970 -0
  35. package/src/skills/index.ts +13 -0
  36. package/src/skills/quick-task/SKILL.md +54 -0
  37. package/src/skills/quick-task/index.ts +346 -0
  38. package/src/skills/registry.ts +59 -0
  39. package/src/skills/review-diff/SKILL.md +53 -0
  40. package/src/skills/review-diff/index.ts +394 -0
  41. package/src/skills/skill.ts +59 -0
  42. package/src/skills/systematic-debugging/SKILL.md +56 -0
  43. package/src/skills/systematic-debugging/index.ts +404 -0
  44. package/src/skills/tdd/SKILL.md +52 -0
  45. package/src/skills/tdd/index.ts +409 -0
  46. package/src/skills/to-plan/SKILL.md +56 -0
  47. package/src/skills/to-plan/index-enhanced.ts +551 -0
  48. package/src/skills/to-plan/index.ts +586 -0
  49. package/src/skills/types.ts +47 -0
  50. package/src/state/cleanup.ts +118 -0
  51. package/src/state/index.ts +8 -0
  52. package/src/state/manager.ts +96 -0
  53. package/src/state/persistence.ts +77 -0
  54. package/src/state/types.ts +30 -0
  55. package/src/state/validator.ts +78 -0
  56. package/src/types.ts +102 -0
  57. package/src/utils/compress.ts +347 -0
  58. package/src/utils/git.ts +82 -0
  59. package/src/utils/index.ts +6 -0
  60. package/src/utils/logger.ts +23 -0
  61. package/src/utils/paths.ts +55 -0
@@ -0,0 +1,610 @@
1
+ /**
2
+ * Request Classifier - determines what type of subagent should handle a request.
3
+ *
4
+ * Analyzes user request text and contextual signals to produce an
5
+ * IntentClassification, then builds a RoutingDecision with the appropriate
6
+ * SubagentConfig and SubagentContract.
7
+ *
8
+ * Classification follows a priority-ordered chain:
9
+ * 1. execute (plan file exists / mid-execution)
10
+ * 2. quick-task (trivial typo/whitespace, small scoped change)
11
+ * 3. review (code review request)
12
+ * 4. debug (root cause analysis)
13
+ * 5. tdd (test-driven development request)
14
+ * 6. plan (new feature / significant change)
15
+ * 7. general (catch-all)
16
+ */
17
+
18
+ import type { SubagentConfig } from '../agents/types.js'
19
+ import type {
20
+ IntentType,
21
+ IntentClassification,
22
+ RoutingDecision,
23
+ SubagentContract,
24
+ PermissionSet,
25
+ RouterConfig,
26
+ } from './types.js'
27
+ import { ROUTE_KEYWORDS } from './types.js'
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Classification context
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /** Context signals used alongside the raw request text. */
34
+ export interface ClassificationContext {
35
+ /** Whether a plan file already exists for this task/feature */
36
+ planFileExists: boolean
37
+ /** Whether the user is in the middle of executing a plan */
38
+ isExecutingPlan: boolean
39
+ /** Whether there are uncommitted changes in the working tree */
40
+ hasUncommittedChanges: boolean
41
+ /** The current branch name (if available) */
42
+ currentBranch?: string
43
+ /** Any files the user explicitly mentioned */
44
+ mentionedFiles: string[]
45
+ }
46
+
47
+ /** Extended classification result with diagnostic metadata. */
48
+ export interface ClassifierResult {
49
+ /** The intent classification */
50
+ classification: IntentClassification
51
+ /** The full routing decision (contract + config) */
52
+ routing: RoutingDecision
53
+ /** Confidence score 0-1 */
54
+ confidence: number
55
+ /** Which rule matched */
56
+ matchedRule: string
57
+ /** Alternative classifications that were considered */
58
+ alternatives: Array<{ type: IntentType; confidence: number }>
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Constants
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /** Token budget recommendations by intent type (in thousands). */
66
+ const TOKEN_BUDGETS: Record<IntentType, number> = {
67
+ plan: 64,
68
+ 'quick-task': 32,
69
+ execute: 64,
70
+ review: 32,
71
+ debug: 64,
72
+ tdd: 64,
73
+ general: 32,
74
+ }
75
+
76
+ /** Subagent roles mapped from intent types. */
77
+ const SUBAGENT_ROLES: Record<IntentType, SubagentConfig['role']> = {
78
+ plan: 'planner',
79
+ 'quick-task': 'implementer',
80
+ execute: 'implementer',
81
+ review: 'reviewer',
82
+ debug: 'debugger',
83
+ tdd: 'implementer',
84
+ general: 'general',
85
+ }
86
+
87
+ /** Default permissions by intent type. */
88
+ const SUBAGENT_PERMISSIONS: Record<IntentType, PermissionSet> = {
89
+ plan: {
90
+ readFiles: true,
91
+ searchCode: true,
92
+ runCommands: false,
93
+ writeFiles: true,
94
+ gitOperations: false,
95
+ },
96
+ 'quick-task': {
97
+ readFiles: true,
98
+ searchCode: true,
99
+ runCommands: true,
100
+ writeFiles: true,
101
+ gitOperations: false,
102
+ },
103
+ execute: {
104
+ readFiles: true,
105
+ searchCode: true,
106
+ runCommands: true,
107
+ writeFiles: true,
108
+ gitOperations: true,
109
+ },
110
+ review: {
111
+ readFiles: true,
112
+ searchCode: true,
113
+ runCommands: false,
114
+ writeFiles: false,
115
+ gitOperations: false,
116
+ },
117
+ debug: {
118
+ readFiles: true,
119
+ searchCode: true,
120
+ runCommands: true,
121
+ writeFiles: false,
122
+ gitOperations: false,
123
+ },
124
+ tdd: {
125
+ readFiles: true,
126
+ searchCode: true,
127
+ runCommands: true,
128
+ writeFiles: true,
129
+ gitOperations: false,
130
+ },
131
+ general: {
132
+ readFiles: true,
133
+ searchCode: true,
134
+ runCommands: false,
135
+ writeFiles: false,
136
+ gitOperations: false,
137
+ },
138
+ }
139
+
140
+ /** Whether a given intent type uses worktree isolation. */
141
+ const SUBAGENT_ISOLATION: Partial<Record<IntentType, 'worktree'>> = {
142
+ execute: 'worktree',
143
+ plan: 'worktree',
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Decision builders
148
+ // ---------------------------------------------------------------------------
149
+
150
+ /**
151
+ * Build an IntentClassification from type and metadata.
152
+ */
153
+ function buildClassification(
154
+ type: IntentType,
155
+ confidence: number,
156
+ reasoning: string
157
+ ): IntentClassification {
158
+ return {
159
+ type,
160
+ confidence,
161
+ reasoning,
162
+ suggestedAgent: SUBAGENT_ROLES[type],
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Build a RoutingDecision: subagent config + contract + async flag.
168
+ */
169
+ function buildRouting(
170
+ type: IntentType,
171
+ prompt: string,
172
+ config: RouterConfig,
173
+ owns: string[] = [],
174
+ reads: string[] = []
175
+ ): RoutingDecision {
176
+ const subagentConfig: SubagentConfig = {
177
+ role: SUBAGENT_ROLES[type],
178
+ model: config.defaultModel,
179
+ isolation: SUBAGENT_ISOLATION[type],
180
+ tokenBudget: TOKEN_BUDGETS[type] * 1000,
181
+ }
182
+
183
+ const contract: SubagentContract = {
184
+ permissions: { ...SUBAGENT_PERMISSIONS[type] },
185
+ prompt,
186
+ owns,
187
+ reads,
188
+ }
189
+
190
+ return {
191
+ subagent: subagentConfig,
192
+ contract,
193
+ async: type !== 'quick-task',
194
+ }
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // Pattern matchers
199
+ // ---------------------------------------------------------------------------
200
+
201
+ /**
202
+ * Check if request matches a trivial edit (typo, whitespace, single-line fix).
203
+ * The ONE exception to full delegation — quick-task can handle directly.
204
+ */
205
+ function matchTrivialEdit(
206
+ request: string
207
+ ): { type: IntentType; confidence: number; reasoning: string } | null {
208
+ const normalized = request.toLowerCase().trim()
209
+
210
+ const patterns = [
211
+ /^fix\s+(a\s+)?typo\b/i,
212
+ /^fix\s+(a\s+)?spell(ing)?\s+(error|mistake)\b/i,
213
+ /^fix\s+whitespace\b/i,
214
+ /^remove\s+trailing\s+whitespace\b/i,
215
+ /^fix\s+lint(ing)?\s+(error|warning)\b/i,
216
+ /^format\s+(this|a|the)\s+(file|code)\b/i,
217
+ /^(add|remove)\s+(a\s+)?missing\s+semicolon/i,
218
+ /^change\s+(a\s+)?single\s+(character|char|letter)\b/i,
219
+ /^rename\s+(a\s+)?single\s+variable\b/i,
220
+ ]
221
+
222
+ for (const p of patterns) {
223
+ if (p.test(normalized)) {
224
+ return {
225
+ type: 'quick-task',
226
+ confidence: 0.85,
227
+ reasoning: 'Trivial single-line fix — route to quick-task',
228
+ }
229
+ }
230
+ }
231
+
232
+ // Short request referencing a specific line number
233
+ if (normalized.length < 60 && /\bline\s+\d+\b/.test(normalized)) {
234
+ return {
235
+ type: 'quick-task',
236
+ confidence: 0.7,
237
+ reasoning: 'Very short request references a specific line number',
238
+ }
239
+ }
240
+
241
+ return null
242
+ }
243
+
244
+ /**
245
+ * Check if request is a code review.
246
+ */
247
+ function matchReview(
248
+ request: string
249
+ ): { type: IntentType; confidence: number; reasoning: string } | null {
250
+ const keywords = ROUTE_KEYWORDS.review
251
+ const normalized = request.toLowerCase().trim()
252
+
253
+ for (const kw of keywords) {
254
+ if (normalized.includes(kw)) {
255
+ return {
256
+ type: 'review',
257
+ confidence: 0.9,
258
+ reasoning: `Request matches review keyword: "${kw}"`,
259
+ }
260
+ }
261
+ }
262
+
263
+ return null
264
+ }
265
+
266
+ /**
267
+ * Check if request is about debugging / root cause analysis.
268
+ */
269
+ function matchDebug(
270
+ request: string
271
+ ): { type: IntentType; confidence: number; reasoning: string } | null {
272
+ const keywords = ROUTE_KEYWORDS.debug
273
+ const normalized = request.toLowerCase().trim()
274
+
275
+ for (const kw of keywords) {
276
+ if (normalized.includes(kw)) {
277
+ return {
278
+ type: 'debug',
279
+ confidence: 0.85,
280
+ reasoning: `Request matches debug keyword: "${kw}"`,
281
+ }
282
+ }
283
+ }
284
+
285
+ // Broader debug patterns
286
+ const broadPatterns = [
287
+ /\bwhy\s+(is|does|am\s*i|are)\b.*\b(error|fail|break|crash|wrong|bug)\b/i,
288
+ /\b(stack\s*trace|backtrace)\b/i,
289
+ /\binvestigate\s+(the|a|this|an)\s+(issue|error|bug|crash|problem)\b/i,
290
+ ]
291
+
292
+ for (const p of broadPatterns) {
293
+ if (p.test(normalized)) {
294
+ return {
295
+ type: 'debug',
296
+ confidence: 0.75,
297
+ reasoning: 'Request matches broad debug pattern',
298
+ }
299
+ }
300
+ }
301
+
302
+ return null
303
+ }
304
+
305
+ /**
306
+ * Check if request is about test-driven development.
307
+ */
308
+ function matchTdd(
309
+ request: string
310
+ ): { type: IntentType; confidence: number; reasoning: string } | null {
311
+ const keywords = ROUTE_KEYWORDS.tdd
312
+ const normalized = request.toLowerCase().trim()
313
+
314
+ for (const kw of keywords) {
315
+ if (normalized.includes(kw)) {
316
+ return {
317
+ type: 'tdd',
318
+ confidence: 0.9,
319
+ reasoning: `Request matches TDD keyword: "${kw}"`,
320
+ }
321
+ }
322
+ }
323
+
324
+ return null
325
+ }
326
+
327
+ /**
328
+ * Check if request needs a full plan (complex, multi-step).
329
+ */
330
+ function matchPlan(
331
+ request: string
332
+ ): { type: IntentType; confidence: number; reasoning: string } | null {
333
+ const keywords = ROUTE_KEYWORDS.plan
334
+ const normalized = request.toLowerCase().trim()
335
+
336
+ for (const kw of keywords) {
337
+ if (normalized.includes(kw)) {
338
+ return {
339
+ type: 'plan',
340
+ confidence: 0.85,
341
+ reasoning: `Request matches planning keyword: "${kw}"`,
342
+ }
343
+ }
344
+ }
345
+
346
+ // Additional complexity indicators
347
+ const planIndicators = [
348
+ /\b(complex|major|big|large|significant)\s+(change|refactor|update|feature)\b/i,
349
+ /\b(multi[-\s]?(file|step|phase)|several\s+files?)\b/i,
350
+ /\b(end[-\s]?to[-\s]?end|full[-\s]?stack)\b/i,
351
+ /\b(from\s+scratch|restructure|reorgani[sz]e|rearchitect)\b/i,
352
+ ]
353
+
354
+ for (const p of planIndicators) {
355
+ if (p.test(normalized)) {
356
+ return {
357
+ type: 'plan',
358
+ confidence: 0.75,
359
+ reasoning: 'Request contains complexity indicators — planning likely needed',
360
+ }
361
+ }
362
+ }
363
+
364
+ // Long requests (>500 chars) often need planning
365
+ if (normalized.length > 500) {
366
+ return {
367
+ type: 'plan',
368
+ confidence: 0.55,
369
+ reasoning: 'Long request suggests complex, multi-step work',
370
+ }
371
+ }
372
+
373
+ return null
374
+ }
375
+
376
+ /**
377
+ * Check if request is a read-only / informational question.
378
+ */
379
+ function matchGeneral(
380
+ request: string
381
+ ): { type: IntentType; confidence: number; reasoning: string } | null {
382
+ const keywords = ROUTE_KEYWORDS.general
383
+ const normalized = request.toLowerCase().trim()
384
+
385
+ for (const kw of keywords) {
386
+ if (normalized.includes(kw)) {
387
+ return {
388
+ type: 'general',
389
+ confidence: 0.7,
390
+ reasoning: `Request matches general keyword: "${kw}"`,
391
+ }
392
+ }
393
+ }
394
+
395
+ return null
396
+ }
397
+
398
+ // ---------------------------------------------------------------------------
399
+ // RequestClassifier
400
+ // ---------------------------------------------------------------------------
401
+
402
+ /**
403
+ * Analyzes user requests and produces routing decisions.
404
+ *
405
+ * Classification runs through a priority-ordered chain:
406
+ * 1. Context-based: plan exists → 'execute'
407
+ * 2. Pattern-based: trivial-edit → quick-task, review, debug, tdd, plan, general
408
+ * 3. Fallback: 'general'
409
+ *
410
+ * Usage:
411
+ * ```typescript
412
+ * const classifier = new RequestClassifier(config)
413
+ * const result = classifier.classify("Fix the typo in src/utils.ts", {
414
+ * planFileExists: false,
415
+ * isExecutingPlan: false,
416
+ * hasUncommittedChanges: true,
417
+ * mentionedFiles: ['src/utils.ts'],
418
+ * })
419
+ * // result.classification.type === 'quick-task'
420
+ * ```
421
+ */
422
+ export class RequestClassifier {
423
+ private readonly config: Readonly<RouterConfig>
424
+
425
+ /** Priority-ordered classification rules. */
426
+ private readonly rules: ReadonlyArray<{
427
+ name: string
428
+ match: (
429
+ request: string,
430
+ ctx: ClassificationContext
431
+ ) => { type: IntentType; confidence: number; reasoning: string } | null
432
+ }>
433
+
434
+ constructor(config: RouterConfig) {
435
+ this.config = Object.freeze({ ...config })
436
+
437
+ this.rules = Object.freeze([
438
+ { name: 'context-execute', match: (_req, ctx) => this.checkContextExecute(ctx) },
439
+ { name: 'trivial-edit', match: (req, _ctx) => matchTrivialEdit(req) },
440
+ { name: 'review', match: (req, _ctx) => matchReview(req) },
441
+ { name: 'debug', match: (req, _ctx) => matchDebug(req) },
442
+ { name: 'tdd', match: (req, _ctx) => matchTdd(req) },
443
+ { name: 'plan', match: (req, _ctx) => matchPlan(req) },
444
+ { name: 'general-keywords', match: (req, _ctx) => matchGeneral(req) },
445
+ ])
446
+ }
447
+
448
+ /**
449
+ * If a plan file exists or we are mid-execution, route to execute.
450
+ */
451
+ private checkContextExecute(
452
+ ctx: ClassificationContext
453
+ ): { type: IntentType; confidence: number; reasoning: string } | null {
454
+ if (ctx.isExecutingPlan) {
455
+ return {
456
+ type: 'execute',
457
+ confidence: 0.95,
458
+ reasoning: 'Currently executing a plan — continue with execute agent',
459
+ }
460
+ }
461
+ if (ctx.planFileExists) {
462
+ return {
463
+ type: 'execute',
464
+ confidence: 0.8,
465
+ reasoning: 'A plan file already exists for this task',
466
+ }
467
+ }
468
+ return null
469
+ }
470
+
471
+ // -----------------------------------------------------------------------
472
+ // Public API
473
+ // -----------------------------------------------------------------------
474
+
475
+ /**
476
+ * Classify a user request and produce a full routing decision.
477
+ *
478
+ * @param request - Raw user request text
479
+ * @param context - Contextual signals to improve accuracy
480
+ * @returns ClassifierResult with classification metadata and routing decision
481
+ */
482
+ classify(request: string, context: ClassificationContext): ClassifierResult {
483
+ if (!request || request.trim().length === 0) {
484
+ const classification = buildClassification('general', 0.1, 'Empty request')
485
+ const routing = buildRouting('general', request, this.config)
486
+ return {
487
+ classification,
488
+ routing,
489
+ confidence: 0.1,
490
+ matchedRule: 'empty-request',
491
+ alternatives: [],
492
+ }
493
+ }
494
+
495
+ // Run through the priority-ordered rule chain
496
+ for (const rule of this.rules) {
497
+ try {
498
+ const match = rule.match(request, context)
499
+ if (match) {
500
+ const classification = buildClassification(match.type, match.confidence, match.reasoning)
501
+ const routing = buildRouting(match.type, request, this.config)
502
+ return {
503
+ classification,
504
+ routing,
505
+ confidence: match.confidence,
506
+ matchedRule: rule.name,
507
+ alternatives: this.computeAlternatives(match.type, match.confidence),
508
+ }
509
+ }
510
+ } catch (error) {
511
+ console.error(`Classifier rule "${rule.name}" failed:`, error)
512
+ }
513
+ }
514
+
515
+ // Fallback: general
516
+ const classification = buildClassification(
517
+ 'general',
518
+ 0.3,
519
+ 'No specific pattern matched — routing to general agent'
520
+ )
521
+ const routing = buildRouting('general', request, this.config)
522
+ return {
523
+ classification,
524
+ routing,
525
+ confidence: 0.3,
526
+ matchedRule: 'fallback',
527
+ alternatives: [
528
+ { type: 'plan', confidence: 0.3 },
529
+ { type: 'quick-task', confidence: 0.2 },
530
+ { type: 'debug', confidence: 0.2 },
531
+ ],
532
+ }
533
+ }
534
+
535
+ /**
536
+ * Classify intent only (without building a full routing decision).
537
+ */
538
+ classifyIntent(request: string, context: ClassificationContext): IntentClassification {
539
+ return this.classify(request, context).classification
540
+ }
541
+
542
+ /**
543
+ * Produce just the routing decision.
544
+ */
545
+ route(request: string, context: ClassificationContext): RoutingDecision {
546
+ return this.classify(request, context).routing
547
+ }
548
+
549
+ // -----------------------------------------------------------------------
550
+ // Configuration queries
551
+ // -----------------------------------------------------------------------
552
+
553
+ /** Get recommended token budget for an intent type. */
554
+ getTokenBudget(type: IntentType): number {
555
+ return TOKEN_BUDGETS[type] * 1000
556
+ }
557
+
558
+ /** Get subagent role for an intent type. */
559
+ getSubagentRole(type: IntentType): SubagentConfig['role'] {
560
+ return SUBAGENT_ROLES[type]
561
+ }
562
+
563
+ /** Get default permissions for an intent type (returns a copy). */
564
+ getSubagentPermissions(type: IntentType): PermissionSet {
565
+ return { ...SUBAGENT_PERMISSIONS[type] }
566
+ }
567
+
568
+ // -----------------------------------------------------------------------
569
+ // Reporting
570
+ // -----------------------------------------------------------------------
571
+
572
+ /**
573
+ * Generate a human-readable classification summary for logging.
574
+ */
575
+ describeClassification(result: ClassifierResult): string {
576
+ const { classification, confidence, matchedRule, routing } = result
577
+ return [
578
+ `Intent: ${classification.type}`,
579
+ `Confidence: ${(confidence * 100).toFixed(0)}%`,
580
+ `Rule: ${matchedRule}`,
581
+ `Agent: ${classification.suggestedAgent}`,
582
+ `Reason: ${classification.reasoning}`,
583
+ `Async: ${routing.async}`,
584
+ routing.subagent.isolation ? `Isolation: ${routing.subagent.isolation}` : null,
585
+ ]
586
+ .filter(Boolean)
587
+ .join(' | ')
588
+ }
589
+
590
+ // -----------------------------------------------------------------------
591
+ // Internal
592
+ // -----------------------------------------------------------------------
593
+
594
+ /**
595
+ * Compute alternative classifications based on the winning type.
596
+ */
597
+ private computeAlternatives(
598
+ winningType: IntentType,
599
+ winningConfidence: number
600
+ ): Array<{ type: IntentType; confidence: number }> {
601
+ const remaining = 1 - winningConfidence
602
+ const allTypes: IntentType[] = ['plan', 'quick-task', 'execute', 'review', 'debug', 'tdd', 'general']
603
+ const others = allTypes.filter((t) => t !== winningType)
604
+
605
+ return others.slice(0, 3).map((type, i) => ({
606
+ type,
607
+ confidence: Math.round((remaining / (others.length - i)) * 100) / 100,
608
+ }))
609
+ }
610
+ }