@llm4ts/flow 2.31.0 → 2.32.0

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 (92) hide show
  1. package/dist/Approval.d.ts +1 -1
  2. package/dist/Approval.d.ts.map +1 -1
  3. package/dist/Artifacts.d.ts +2 -2
  4. package/dist/Artifacts.d.ts.map +1 -1
  5. package/dist/BenchReport.d.ts +2 -2
  6. package/dist/BenchReport.d.ts.map +1 -1
  7. package/dist/Chat.d.ts +5 -2
  8. package/dist/Chat.d.ts.map +1 -1
  9. package/dist/Chat.js +7 -4
  10. package/dist/Chat.js.map +1 -1
  11. package/dist/CostLedger.d.ts +2 -2
  12. package/dist/CostLedger.d.ts.map +1 -1
  13. package/dist/Equiv.d.ts +3 -3
  14. package/dist/Equiv.d.ts.map +1 -1
  15. package/dist/Flow.d.ts +16 -1
  16. package/dist/Flow.d.ts.map +1 -1
  17. package/dist/Flow.js +29 -3
  18. package/dist/Flow.js.map +1 -1
  19. package/dist/FlowError.d.ts +15 -2
  20. package/dist/FlowError.d.ts.map +1 -1
  21. package/dist/FlowError.js +15 -0
  22. package/dist/FlowError.js.map +1 -1
  23. package/dist/FlowEvents.d.ts +18 -5
  24. package/dist/FlowEvents.d.ts.map +1 -1
  25. package/dist/FlowEvents.js +30 -9
  26. package/dist/FlowEvents.js.map +1 -1
  27. package/dist/Gates.d.ts +1 -1
  28. package/dist/Gates.d.ts.map +1 -1
  29. package/dist/Judgment.d.ts +2 -2
  30. package/dist/Judgment.d.ts.map +1 -1
  31. package/dist/Landing.d.ts +1 -1
  32. package/dist/Landing.d.ts.map +1 -1
  33. package/dist/NodePreflight.d.ts +1 -1
  34. package/dist/NodePreflight.d.ts.map +1 -1
  35. package/dist/Pack.d.ts +9 -1
  36. package/dist/Pack.d.ts.map +1 -1
  37. package/dist/Pack.js +26 -2
  38. package/dist/Pack.js.map +1 -1
  39. package/dist/Persistence.d.ts +2 -2
  40. package/dist/Persistence.d.ts.map +1 -1
  41. package/dist/PlanExecution.d.ts +1 -1
  42. package/dist/PlanExecution.d.ts.map +1 -1
  43. package/dist/ProgramJudge.d.ts +1 -1
  44. package/dist/ProgramJudge.d.ts.map +1 -1
  45. package/dist/ProgramJudge.js +6 -3
  46. package/dist/ProgramJudge.js.map +1 -1
  47. package/dist/Replay.d.ts +2 -2
  48. package/dist/Replay.d.ts.map +1 -1
  49. package/dist/Retro.d.ts +51 -4
  50. package/dist/Retro.d.ts.map +1 -1
  51. package/dist/Retro.js +196 -11
  52. package/dist/Retro.js.map +1 -1
  53. package/dist/Review.d.ts +38 -6
  54. package/dist/Review.d.ts.map +1 -1
  55. package/dist/Review.js +147 -8
  56. package/dist/Review.js.map +1 -1
  57. package/dist/ReviewCache.d.ts +1 -1
  58. package/dist/ReviewCache.d.ts.map +1 -1
  59. package/dist/Reviewer.d.ts +2 -0
  60. package/dist/Reviewer.d.ts.map +1 -1
  61. package/dist/Reviewer.js +3 -1
  62. package/dist/Reviewer.js.map +1 -1
  63. package/dist/Roster.d.ts +19 -10
  64. package/dist/Roster.d.ts.map +1 -1
  65. package/dist/Roster.js +51 -17
  66. package/dist/Roster.js.map +1 -1
  67. package/dist/RosterSeats.d.ts +5 -4
  68. package/dist/RosterSeats.d.ts.map +1 -1
  69. package/dist/RosterSeats.js +7 -4
  70. package/dist/RosterSeats.js.map +1 -1
  71. package/dist/Stall.d.ts +29 -0
  72. package/dist/Stall.d.ts.map +1 -0
  73. package/dist/Stall.js +49 -0
  74. package/dist/Stall.js.map +1 -0
  75. package/dist/Stories.d.ts +8 -1
  76. package/dist/Stories.d.ts.map +1 -1
  77. package/dist/Stories.js +7 -1
  78. package/dist/Stories.js.map +1 -1
  79. package/package.json +3 -2
  80. package/src/Chat.ts +15 -7
  81. package/src/Flow.ts +48 -3
  82. package/src/FlowError.ts +16 -0
  83. package/src/FlowEvents.ts +34 -11
  84. package/src/Pack.ts +34 -1
  85. package/src/ProgramJudge.ts +3 -0
  86. package/src/Retro.ts +229 -4
  87. package/src/Review.ts +202 -8
  88. package/src/Reviewer.ts +3 -1
  89. package/src/Roster.ts +86 -39
  90. package/src/RosterSeats.ts +11 -6
  91. package/src/Stall.ts +85 -0
  92. package/src/Stories.ts +14 -0
package/src/Roster.ts CHANGED
@@ -395,6 +395,8 @@ export interface Lease {
395
395
  readonly release: Effect.Effect<void>
396
396
  /** Took no slot: the context's own coder executor serves the call (see `LeaseOptions.borrow`). */
397
397
  readonly borrowed: boolean
398
+ /** Set when the executor is the context's own coder (not independent), and why; see `ExecutorLeased.because`. */
399
+ readonly because?: "nobody" | "held" | "out"
398
400
  }
399
401
 
400
402
  export interface LeaseOptions {
@@ -405,15 +407,16 @@ export interface LeaseOptions {
405
407
  /** Who is asking, for the events (a story's worktree). */
406
408
  readonly label?: string
407
409
  /**
408
- * The executor holding this context's coder, used without a slot (its
409
- * coder is idle while the context makes this call) when independence
410
- * cannot be had: no executor outside `avoid` can ever take the role, or
411
- * every one in the round that could has all its slots held by coders. A
412
- * coder's slot frees only when its story ends, and that story may itself
413
- * be waiting on a call like this one — waiting for it would saturate the
414
- * roster. An executor that is out of the round is still borrowed in the
415
- * first case (the call fails on it, as it would on anyone), never in the
416
- * second (a story in the round will free a slot).
410
+ * The executor holding this context's coder, taken when independence
411
+ * cannot be had now: no executor outside `avoid` can ever take the role,
412
+ * every one that could is held by a coder, or every one is out of the
413
+ * round. A coder's slot frees only when its story ends (which may itself
414
+ * wait on a call like this one), and an exclusion may last hours; a
415
+ * reasoning call only ever waits for another call to end. The own
416
+ * executor is taken on a free slot of its own when it has one, else
417
+ * without a slot (its coder is idle while the context makes this call).
418
+ * Out of the round itself, it is still taken when nobody can ever serve
419
+ * (the call fails on it, as it would on anyone), and waited past otherwise.
417
420
  */
418
421
  readonly borrow?: ExecutorSpec
419
422
  }
@@ -445,6 +448,15 @@ export interface RosterShape {
445
448
  * for `role` would then wait for a story to end, not for a call.
446
449
  */
447
450
  readonly coderHeld: (role: Role, avoid?: ReadonlyArray<string>) => Effect.Effect<boolean>
451
+ /**
452
+ * Why a lease for `role` outside `avoid` cannot be served by a call
453
+ * ending: `held` (every executor in the round is held by coders), `out`
454
+ * (none is in the round), or `undefined` when one has a slot a call frees.
455
+ */
456
+ readonly independenceBlocked: (
457
+ role: Role,
458
+ avoid?: ReadonlyArray<string>
459
+ ) => Effect.Effect<"held" | "out" | undefined>
448
460
  /** Free slots for `role` now, across executors in the round. */
449
461
  readonly available: (role: Role) => Effect.Effect<number>
450
462
  /** Configured slots for `role`. */
@@ -574,11 +586,13 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
574
586
  const leaseOf = (
575
587
  spec: ExecutorSpec,
576
588
  role: Role,
577
- label: string | undefined
589
+ label: string | undefined,
590
+ because?: "nobody" | "held" | "out"
578
591
  ): Effect.Effect<Lease, never, Scope.Scope> =>
579
592
  Effect.gen(function* () {
580
593
  const labelled = label === undefined ? {} : { label }
581
- yield* events.publish(ExecutorLeased.make({ executor: spec.id, role, ...labelled }))
594
+ const own = because === undefined ? {} : { because }
595
+ yield* events.publish(ExecutorLeased.make({ executor: spec.id, role, ...labelled, ...own }))
582
596
  const done = yield* Ref.make(false)
583
597
  const free = Effect.flatMap(Ref.getAndSet(done, true), (was) =>
584
598
  was
@@ -589,7 +603,7 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
589
603
  )
590
604
  )
591
605
  yield* Effect.addFinalizer(() => free)
592
- return { executor: spec, role, release: free, borrowed: false }
606
+ return { executor: spec, role, release: free, borrowed: false, ...own }
593
607
  })
594
608
 
595
609
  /** A lease over the context's own coder executor: no slot taken, none freed. */
@@ -597,7 +611,7 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
597
611
  spec: ExecutorSpec,
598
612
  role: Role,
599
613
  label: string | undefined,
600
- because: "nobody" | "held"
614
+ because: "nobody" | "held" | "out"
601
615
  ): Effect.Effect<Lease, never, Scope.Scope> =>
602
616
  Effect.gen(function* () {
603
617
  const labelled = label === undefined ? {} : { label }
@@ -611,7 +625,7 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
611
625
  : events.publish(ExecutorReleased.make({ executor: spec.id, role, ...labelled }))
612
626
  )
613
627
  yield* Effect.addFinalizer(() => free)
614
- return { executor: spec, role, release: free, borrowed: true }
628
+ return { executor: spec, role, release: free, borrowed: true, because }
615
629
  })
616
630
 
617
631
  /** One attempt: expire, pick, take the slot — all under the lock. */
@@ -660,18 +674,26 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
660
674
  )
661
675
  )
662
676
 
663
- const coderHeld = (role: Role, avoid: ReadonlyArray<string> = []): Effect.Effect<boolean> =>
664
- lock.withPermit(
665
- Effect.sync(() => {
666
- const inRound = eligible(role, avoid)
667
- return (
668
- inRound.length > 0 &&
669
- inRound.every(
670
- (spec) => (usage.get(spec.id) ?? { busy: 0, busyCoders: 0 }).busyCoders >= slotsOf(spec)
671
- )
672
- )
673
- })
677
+ const blockedOutside = (role: Role, avoid: ReadonlyArray<string>): "held" | "out" | undefined => {
678
+ const inRound = eligible(role, avoid)
679
+ if (inRound.length === 0) {
680
+ return "out"
681
+ }
682
+ return inRound.every(
683
+ (spec) => (usage.get(spec.id) ?? { busy: 0, busyCoders: 0 }).busyCoders >= slotsOf(spec)
674
684
  )
685
+ ? "held"
686
+ : undefined
687
+ }
688
+
689
+ const independenceBlocked = (
690
+ role: Role,
691
+ avoid: ReadonlyArray<string> = []
692
+ ): Effect.Effect<"held" | "out" | undefined> =>
693
+ lock.withPermit(Effect.sync(() => blockedOutside(role, avoid)))
694
+
695
+ const coderHeld = (role: Role, avoid: ReadonlyArray<string> = []): Effect.Effect<boolean> =>
696
+ Effect.map(independenceBlocked(role, avoid), (blocked) => blocked === "held")
675
697
 
676
698
  /** Probes every `health` exclusion whose next probe is due; a live one comes back. */
677
699
  const probeDue: Effect.Effect<void> = Effect.gen(function* () {
@@ -771,13 +793,37 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
771
793
  leaseOptions.borrow !== undefined && hasRole(leaseOptions.borrow, role)
772
794
  ? leaseOptions.borrow
773
795
  : undefined
774
- /** Borrowing when the round is held by coders needs an executor in the round. */
775
- const borrowInRound = (): Effect.Effect<ExecutorSpec | undefined> =>
776
- lock.withPermit(
777
- Effect.sync(() =>
778
- borrow !== undefined && !exclusions.has(borrow.id) ? borrow : undefined
779
- )
780
- )
796
+ /**
797
+ * Independence cannot be had now: the context's own executor, when in
798
+ * the round, takes the call — on a free slot of its own, else without
799
+ * one (its coder is idle while the context makes this call).
800
+ */
801
+ const takeOwn = (
802
+ because: "nobody" | "held" | "out"
803
+ ): Effect.Effect<Lease | undefined, never, Scope.Scope> =>
804
+ Effect.gen(function* () {
805
+ if (borrow === undefined) {
806
+ return undefined
807
+ }
808
+ // Out of the round itself, the own executor is still taken when
809
+ // nobody else can ever serve (the call fails on it, as on anyone).
810
+ const inRound = yield* lock.withPermit(Effect.sync(() => !exclusions.has(borrow.id)))
811
+ if (!inRound && because !== "nobody") {
812
+ return undefined
813
+ }
814
+ const own = yield* attempt(role, {
815
+ ...leaseOptions,
816
+ avoid: avoid.filter((id) => id !== borrow.id),
817
+ prefer: borrow.id
818
+ })
819
+ yield* waited
820
+ return own !== undefined && own.id === borrow.id
821
+ ? yield* leaseOf(own, role, leaseOptions.label, because)
822
+ : own !== undefined
823
+ ? // Someone came back between the check and the pick: independent after all.
824
+ yield* leaseOf(own, role, leaseOptions.label)
825
+ : yield* borrowedLease(borrow, role, leaseOptions.label, because)
826
+ })
781
827
  let announced = false
782
828
  const asked = yield* Clock.currentTimeMillis
783
829
  // A lease that had to wait says how long, for the story it is for.
@@ -800,9 +846,9 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
800
846
  return yield* leaseOf(spec, role, leaseOptions.label)
801
847
  }
802
848
  if (!(yield* canEverServe(role, avoid))) {
803
- if (borrow !== undefined) {
804
- yield* waited
805
- return yield* borrowedLease(borrow, role, leaseOptions.label, "nobody")
849
+ const own = yield* takeOwn("nobody")
850
+ if (own !== undefined) {
851
+ return own
806
852
  }
807
853
  const reasons = yield* lock.withPermit(
808
854
  Effect.sync(() =>
@@ -819,11 +865,11 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
819
865
  reasons: reasons.length === 0 ? ["no executor in the roster takes this role"] : reasons
820
866
  })
821
867
  }
822
- if (yield* coderHeld(role, avoid)) {
823
- const own = yield* borrowInRound()
868
+ const blocked = yield* independenceBlocked(role, avoid)
869
+ if (blocked !== undefined) {
870
+ const own = yield* takeOwn(blocked)
824
871
  if (own !== undefined) {
825
- yield* waited
826
- return yield* borrowedLease(own, role, leaseOptions.label, "held")
872
+ return own
827
873
  }
828
874
  }
829
875
  if (!announced) {
@@ -956,6 +1002,7 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
956
1002
  tryLease,
957
1003
  canEverServe,
958
1004
  coderHeld,
1005
+ independenceBlocked,
959
1006
  available,
960
1007
  slots,
961
1008
  report,
@@ -15,6 +15,7 @@ import { describeFlowError, type FlowError } from "./FlowError.ts"
15
15
  import { ExecutorHandedOver, type FlowEventsShape } from "./FlowEvents.ts"
16
16
  import {
17
17
  describeExclusion,
18
+ hasRole,
18
19
  type ExecutorSpec,
19
20
  type Lease,
20
21
  type Role,
@@ -104,9 +105,10 @@ export interface RosterSeatOptions {
104
105
  /** Executors this seat must not use: the context's coder, for independence. */
105
106
  readonly avoid?: Effect.Effect<ReadonlyArray<string>>
106
107
  /**
107
- * The context's coder executor, used without a slot when independence
108
- * cannot be had: nobody outside `avoid` can ever serve the role, or every
109
- * executor that could is held by a coder (`LeaseOptions.borrow`).
108
+ * The context's coder executor, taken when independence cannot be had —
109
+ * nobody outside `avoid` can ever serve the role, every executor that
110
+ * could is held by a coder, or out of the round — on a free slot of its
111
+ * own, or without one (`LeaseOptions.borrow`).
110
112
  */
111
113
  readonly borrow?: Effect.Effect<ExecutorSpec | undefined>
112
114
  /** Who is asking, for the events. */
@@ -150,8 +152,9 @@ export const rosterSeat = (
150
152
 
151
153
  /**
152
154
  * After a failure that took `executor` out of the round, whether another
153
- * executor can still take the call: the call moves once, never twice — a
154
- * second failure is the call's own, not the executor's.
155
+ * executor can still take the call — an independent one, or the context's
156
+ * own: the call moves once, never twice — a second failure is the call's
157
+ * own, not the executor's.
155
158
  */
156
159
  const retryElsewhere = (
157
160
  executor: ExecutorSpec,
@@ -164,7 +167,9 @@ export const rosterSeat = (
164
167
  return false
165
168
  }
166
169
  const avoid = options.avoid === undefined ? [] : yield* options.avoid
167
- const other = yield* roster.canEverServe(role, [...avoid, executor.id])
170
+ const borrow = options.borrow === undefined ? undefined : yield* options.borrow
171
+ const own = borrow !== undefined && borrow.id !== executor.id && hasRole(borrow, role)
172
+ const other = own || (yield* roster.canEverServe(role, [...avoid, executor.id]))
168
173
  if (other) {
169
174
  yield* options.events.publish(
170
175
  ExecutorHandedOver.make({
package/src/Stall.ts ADDED
@@ -0,0 +1,85 @@
1
+ // Stopping conditions the model does not control (ADR 0027 decision 11).
2
+ // Pure helpers over a chat's chunk stream; `Chat` applies them when asked.
3
+ import * as Duration from "effect/Duration"
4
+ import * as Effect from "effect/Effect"
5
+ import * as Ref from "effect/Ref"
6
+ import * as Stream from "effect/Stream"
7
+ import type { LlmError } from "@llm4ts/core/Errors"
8
+ import type { LlmChunk } from "@llm4ts/core/Models"
9
+ import { Stalled } from "./FlowError.ts"
10
+
11
+ export interface StallOptions {
12
+ /** The same tool call with the same arguments this many times in a row ends the turn. Default 5. */
13
+ readonly repeats?: number
14
+ /** No chunk for this long ends the turn; absent means never (a slow seat looks like silence). */
15
+ readonly silence?: Duration.Duration
16
+ }
17
+
18
+ export const defaultStallRepeats = 5
19
+
20
+ /** The identity of a tool call chunk: name and arguments; `undefined` for anything else. */
21
+ export const toolCallKey = (chunk: LlmChunk): string | undefined =>
22
+ chunk.metadata.event === "tool_use"
23
+ ? `${chunk.metadata.tool_name ?? chunk.metadata.toolName ?? ""}|${chunk.metadata.tool_input ?? chunk.metadata.toolInput ?? ""}`
24
+ : undefined
25
+
26
+ export interface RepeatState {
27
+ readonly key: string | undefined
28
+ readonly count: number
29
+ }
30
+
31
+ export const noRepeats: RepeatState = { key: undefined, count: 0 }
32
+
33
+ /** The repeat state after one chunk: a different call resets, the same one counts. */
34
+ export const nextRepeat = (state: RepeatState, chunk: LlmChunk): RepeatState => {
35
+ const key = toolCallKey(chunk)
36
+ if (key === undefined) {
37
+ return state
38
+ }
39
+ return key === state.key ? { key, count: state.count + 1 } : { key, count: 1 }
40
+ }
41
+
42
+ /**
43
+ * The stream, ended with `Stalled` when one tool call repeats `repeats`
44
+ * times in a row (the state is shared across a chat's turns through `state`)
45
+ * or when no chunk arrives within `silence`.
46
+ */
47
+ export const stallGuard = <R>(
48
+ stream: Stream.Stream<LlmChunk, LlmError, R>,
49
+ options: StallOptions,
50
+ state: Ref.Ref<RepeatState>
51
+ ): Stream.Stream<LlmChunk, LlmError | Stalled, R> => {
52
+ const limit = Math.max(2, options.repeats ?? defaultStallRepeats)
53
+ const counted = Stream.mapEffect(stream, (chunk) =>
54
+ Effect.flatMap(
55
+ Ref.modify(state, (current) => {
56
+ const next = nextRepeat(current, chunk)
57
+ return [next, next] as const
58
+ }),
59
+ (next) =>
60
+ next.count >= limit
61
+ ? Effect.fail(
62
+ Stalled.make({
63
+ signal: "repeated-tool-call",
64
+ detail: `the same tool call ${next.count} times in a row: ${(next.key ?? "").slice(0, 200)}`
65
+ })
66
+ )
67
+ : Effect.succeed(chunk)
68
+ )
69
+ )
70
+ const silence = options.silence
71
+ return silence === undefined
72
+ ? counted
73
+ : counted.pipe(
74
+ Stream.timeoutOrElse({
75
+ duration: silence,
76
+ orElse: () =>
77
+ Stream.fail(
78
+ Stalled.make({
79
+ signal: "silence",
80
+ detail: `no output for ${Duration.format(silence)} (LLM4TS_STALL_MINUTES)`
81
+ })
82
+ )
83
+ })
84
+ )
85
+ }
package/src/Stories.ts CHANGED
@@ -72,6 +72,7 @@ import {
72
72
  ReviewIssue,
73
73
  ReviewResult,
74
74
  applyTriage,
75
+ loadRepoReviewRules,
75
76
  type FixPromptOptions,
76
77
  type GateTriageOptions
77
78
  } from "./Review.ts"
@@ -85,6 +86,7 @@ import { cachedValue, fingerprintOf } from "./ReviewCache.ts"
85
86
  import { withContract, type ContractProfile } from "./AutonomyContract.ts"
86
87
  import { fabricatedStatusIssues, unverifiedClaims } from "./Evidence.ts"
87
88
  import type { Trailer } from "./CarriedNotes.ts"
89
+ import type { StallOptions } from "./Stall.ts"
88
90
  import type { Reviewer } from "./Reviewer.ts"
89
91
  import {
90
92
  dependentsOf,
@@ -659,6 +661,12 @@ export interface StoriesOptions {
659
661
  readonly fix?: FixPromptOptions
660
662
  /** Test-file and marker patterns for the oracle guard; default rules when absent (ADR 0027). */
661
663
  readonly oracleRules?: OracleRules
664
+ /** Independent votes of the adversarial lens per review round (ADR 0027 decision 7). Default 1. */
665
+ readonly votes?: number
666
+ /** Who applies review findings: the implementer's chat (default) or a separate fixer (ADR 0027 decision 8). */
667
+ readonly fixer?: "coder" | "separate"
668
+ /** End a coder turn that repeats one tool call or goes silent (ADR 0027 decision 11). */
669
+ readonly stall?: StallOptions
662
670
  /**
663
671
  * The autonomy contract profile for the coder's system prompt (ADR 0027
664
672
  * decision 5). Default: the roster executor's `contract`, else `full`.
@@ -1447,6 +1455,12 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1447
1455
  result
1448
1456
  ),
1449
1457
  onTaskReply: (task, _reply, trailer, startedAt) => checkEvidence(task, trailer, startedAt),
1458
+ // The repository's own review rules ride along as one extra lens
1459
+ // (ADR 0027 decision 9), read from the epic checkout.
1460
+ repoRules: loadRepoReviewRules(files, context.workDir),
1461
+ ...(options.votes === undefined ? {} : { votes: options.votes }),
1462
+ ...(options.fixer === undefined ? {} : { fixer: options.fixer }),
1463
+ ...(options.stall === undefined ? {} : { stall: options.stall }),
1450
1464
  ...(options.reviewers === undefined ? {} : { reviewers: options.reviewers }),
1451
1465
  ...(options.maxRounds === undefined ? {} : { maxRounds: options.maxRounds })
1452
1466
  })