pi-roundtable 0.19.2 → 0.21.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 (31) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +1 -1
  3. package/README.zh-TW.md +1 -1
  4. package/docs/plugins.md +51 -5
  5. package/package.json +2 -1
  6. package/src/core/discord/card-audience.ts +47 -0
  7. package/src/core/discord/owner-card-answers.ts +180 -0
  8. package/src/core/discord/owner-card-asks.ts +149 -0
  9. package/src/core/discord/owner-card-questionnaire.ts +281 -0
  10. package/src/core/discord/owner-card-rows.ts +40 -18
  11. package/src/core/discord/owner-card-types.ts +54 -5
  12. package/src/core/discord/owner-cards-fixture.ts +37 -3
  13. package/src/core/discord/owner-cards.ts +62 -133
  14. package/src/core/discord/utf16.ts +11 -0
  15. package/src/core/holds.ts +15 -2
  16. package/src/core/i18n/cards.ts +32 -2
  17. package/src/core/interactions/prompts.ts +43 -22
  18. package/src/core/interactions/questionnaire.ts +213 -0
  19. package/src/core/runtime/ask-questions.ts +93 -0
  20. package/src/core/runtime/extensions/ask-user-arguments.ts +52 -0
  21. package/src/core/runtime/extensions/ask-user-text.ts +146 -0
  22. package/src/core/runtime/extensions/ask-user.ts +87 -81
  23. package/src/core/runtime/extensions/card-ask.ts +3 -0
  24. package/src/core/runtime/extensions/confirmation-gate.ts +2 -0
  25. package/src/core/runtime/extensions/gate-refusals.ts +2 -0
  26. package/src/core/runtime/extensions/hold-lookups.ts +16 -4
  27. package/src/core/runtime/prompt-slot.ts +21 -1
  28. package/src/core/testing/surface-contract-questionnaire.ts +192 -0
  29. package/src/core/testing/surface-contract-support.ts +29 -1
  30. package/src/core/testing/surface-contract.ts +46 -20
  31. package/src/index.ts +11 -0
@@ -1,40 +1,16 @@
1
1
  import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
- import { Type } from "typebox";
3
- import { messages } from "../../i18n/index.ts";
4
- import { type OwnerIdentity, ownerWords, PRONOUNS } from "../../identity.ts";
5
- import type { OwnerAnswer, OwnerQuestion } from "../../interactions/prompts.ts";
2
+ import { type Static, Type } from "typebox";
3
+ import { type OwnerIdentity, ownerWords } from "../../identity.ts";
4
+ import type { OwnerQuestion } from "../../interactions/prompts.ts";
5
+ import { QUESTIONNAIRE_LIMITS as L } from "../../interactions/questionnaire.ts";
6
6
  import { toolText } from "../../shared/tool-result.ts";
7
+ import { askQuestions } from "../ask-questions.ts";
7
8
  import type { PromptSlot } from "../prompt-slot.ts";
9
+ import { foldAskArguments } from "./ask-user-arguments.ts";
8
10
 
9
- export const ASK_USER_TOOL = "ask_user";
10
-
11
- /** The tool's result: what the owner chose and wrote. */
12
- export function answerText(
13
- answer: OwnerAnswer | "pending" | undefined,
14
- owner: OwnerIdentity,
15
- ): string {
16
- const o = ownerWords(owner);
17
- if (answer === "pending")
18
- return `${o.name} has not answered yet. The card stays open, so do not ask again and do not wait for it. End your turn now: say briefly that you are waiting for ${o.his} answer on the card. When ${o.he} answers, a new turn brings you the answer and you continue from there.`;
19
- if (!answer)
20
- return `No answer: ${o.name} did not answer. Go on without it if you safely can, otherwise ask in your reply.`;
21
- const lines = [
22
- ...(answer.choices.length > 0
23
- ? [`${o.name} chose: ${answer.choices.join("; ")}`]
24
- : []),
25
- ...(answer.text !== undefined ? [`${o.name} wrote: ${answer.text}`] : []),
26
- ];
27
- return lines.length > 0 ? lines.join("\n") : `${o.name} chose nothing.`;
28
- }
11
+ export { answerText, lateAnswerText } from "./ask-user-text.ts";
29
12
 
30
- /** The turn a late answer starts: the question it answers and what was chosen and written. */
31
- export function lateAnswerText(
32
- question: string,
33
- answer: OwnerAnswer,
34
- by: string,
35
- ): string {
36
- return `(${by} answered your earlier ask_user question on its card, after your turn had ended. Continue from where you stopped, with this answer.)\nQuestion: ${question}\n${answerText(answer, { name: by, pronouns: PRONOUNS.they })}`;
37
- }
13
+ export const ASK_USER_TOOL = "ask_user";
38
14
 
39
15
  /** Registers ask_user, which asks the owner on a card in the turn's channel and waits. */
40
16
  export function askUserExtension(
@@ -45,36 +21,68 @@ export function askUserExtension(
45
21
  ): ExtensionFactory {
46
22
  const o = ownerWords(owner);
47
23
  return (pi) => {
24
+ const parameters = Type.Object(
25
+ {
26
+ questions: Type.Array(
27
+ Type.Object(
28
+ {
29
+ question: Type.String({
30
+ minLength: 1,
31
+ maxLength: L.questionChars,
32
+ }),
33
+ header: Type.Optional(
34
+ Type.String({
35
+ maxLength: L.headerChars,
36
+ description: "A short name for the question in a set.",
37
+ }),
38
+ ),
39
+ options: Type.Optional(
40
+ Type.Array(
41
+ Type.Object(
42
+ {
43
+ label: Type.String({
44
+ minLength: 1,
45
+ maxLength: L.labelChars,
46
+ }),
47
+ description: Type.Optional(
48
+ Type.String({ maxLength: L.descriptionChars }),
49
+ ),
50
+ preview: Type.Optional(
51
+ Type.String({
52
+ maxLength: L.previewChars,
53
+ description:
54
+ "A markdown mock-up or snippet shown with the option.",
55
+ }),
56
+ ),
57
+ },
58
+ { additionalProperties: false },
59
+ ),
60
+ { maxItems: L.options },
61
+ ),
62
+ ),
63
+ multi: Type.Optional(
64
+ Type.Boolean({ description: "Several options may be chosen." }),
65
+ ),
66
+ allow_other: Type.Optional(
67
+ Type.Boolean({
68
+ description: `${o.He} may write an answer besides the options (default true; false for a closed choice).`,
69
+ }),
70
+ ),
71
+ },
72
+ { additionalProperties: false },
73
+ ),
74
+ { minItems: 1, maxItems: L.questions },
75
+ ),
76
+ },
77
+ { additionalProperties: false },
78
+ );
48
79
  pi.registerTool({
49
80
  name: ASK_USER_TOOL,
50
81
  label: `Ask ${o.name}`,
51
- description: `Ask ${o.name} a question on a card with buttons in this channel and wait for ${o.his} answer, within this turn. Use it when you need ${o.his} decision to continue, instead of ending your turn with a question. The card should read on its own, so put the context ${o.he} needs in the question too. Offer options when the choices are known; set multi when several may apply and allow_other to let ${o.him} write ${o.his} own answer. If ${o.he} does not answer soon, the result says so and the card stays open: then end your turn saying you are waiting, and ${o.his} answer comes in a new turn.`,
52
- parameters: Type.Object(
53
- {
54
- question: Type.String({ minLength: 1, maxLength: 1500 }),
55
- options: Type.Optional(
56
- Type.Array(
57
- Type.Object(
58
- {
59
- label: Type.String({ minLength: 1, maxLength: 100 }),
60
- description: Type.Optional(Type.String({ maxLength: 100 })),
61
- },
62
- { additionalProperties: false },
63
- ),
64
- { maxItems: 25 },
65
- ),
66
- ),
67
- multi: Type.Optional(
68
- Type.Boolean({ description: "Several options may be chosen." }),
69
- ),
70
- allow_other: Type.Optional(
71
- Type.Boolean({
72
- description: `${o.He} may write an answer besides the options.`,
73
- }),
74
- ),
75
- },
76
- { additionalProperties: false },
77
- ),
82
+ description: `Ask ${o.name} a question, or up to four related ones, on a card with buttons in this channel and wait for ${o.his} answer, within this turn. Use it when you need ${o.his} decision to continue, instead of ending your turn with a question. The card should read on its own, so put the context ${o.he} needs in the question too. Put every question you have into one call as questions. Offer options when the choices are known, each with a short description, and a preview (markdown) when seeing the option helps; set multi when several may apply, and allow_other to false for a closed choice (otherwise ${o.he} may write ${o.his} own answer). Give each question a short header when you ask several. If ${o.he} does not answer soon, the result says so and the card stays open: then end your turn saying you are waiting, and ${o.his} answer comes in a new turn.`,
83
+ parameters,
84
+ prepareArguments: (args) =>
85
+ foldAskArguments(args) as Static<typeof parameters>,
78
86
  execute: async (_toolCallId, params, signal) => {
79
87
  const prompts = slot.prompts;
80
88
  if (!prompts)
@@ -82,31 +90,29 @@ export function askUserExtension(
82
90
  `${o.name} cannot answer buttons in this turn, such as a schedule's. Ask ${o.him} in your reply instead.`,
83
91
  );
84
92
  const p = params as {
85
- question: string;
86
- options?: { label: string; description?: string }[];
87
- multi?: boolean;
88
- allow_other?: boolean;
89
- };
90
- const question: OwnerQuestion = {
91
- question: p.question,
92
- options: p.options ?? [],
93
- multi: p.multi === true,
94
- allowOther: p.allow_other === true,
93
+ questions: {
94
+ question: string;
95
+ header?: string;
96
+ options?: OwnerQuestion["options"];
97
+ multi?: boolean;
98
+ allow_other?: boolean;
99
+ }[];
95
100
  };
96
101
  return toolText(
97
- answerText(
98
- await prompts.ask(
99
- messages().askTitle(slot.asker),
100
- question,
101
- signal,
102
- {
103
- late: (late) =>
104
- late.kind === "question"
105
- ? lateAnswerText(p.question, late.answer, late.by)
106
- : undefined,
107
- },
108
- ),
102
+ await askQuestions(
103
+ prompts,
104
+ slot.asker,
105
+ {
106
+ questions: p.questions.map((q) => ({
107
+ question: q.question,
108
+ ...(q.header !== undefined ? { header: q.header } : {}),
109
+ options: q.options ?? [],
110
+ multi: q.multi === true,
111
+ allowOther: q.allow_other !== false,
112
+ })),
113
+ },
109
114
  addressee?.() ?? owner,
115
+ signal,
110
116
  ),
111
117
  );
112
118
  },
@@ -18,6 +18,8 @@ export interface CardAsk {
18
18
  wait: PromptWait;
19
19
  details: ApprovalDetails;
20
20
  approvers: readonly string[] | undefined;
21
+ /** Only the speaker may answer the card; see `HoldDescription`. */
22
+ speakerOnly: boolean;
21
23
  /** The call's watch, when its plugin may withdraw it. */
22
24
  watch: Watch | undefined;
23
25
  }
@@ -41,6 +43,7 @@ export async function askOnCard(
41
43
  const found: Omit<CardAnswer, "answer"> = {};
42
44
  const options: ApprovalOptions = {
43
45
  ...(ask.approvers ? { approvers: ask.approvers } : {}),
46
+ ...(ask.speakerOnly ? { speakerOnly: true } : {}),
44
47
  approvedBy: (by) => {
45
48
  found.approvedBy = by;
46
49
  },
@@ -334,6 +334,7 @@ export class ConfirmationGate {
334
334
  wait: { late: (late) => this.#late(call, late, watch) },
335
335
  details: approvalDetails(call, this.#fileRoot, needing.copy),
336
336
  approvers: call.approverIds,
337
+ speakerOnly: needing.speakerOnly === true,
337
338
  watch,
338
339
  },
339
340
  );
@@ -446,6 +447,7 @@ export class ConfirmationGate {
446
447
  held: call,
447
448
  ...(judged.copy ? { copy: judged.copy } : {}),
448
449
  ...(judged.supersede ? { supersede: judged.supersede } : {}),
450
+ ...(judged.speakerOnly ? { speakerOnly: judged.speakerOnly } : {}),
449
451
  };
450
452
  }
451
453
 
@@ -34,6 +34,8 @@ export type Needing =
34
34
  copy?: NonNullable<ApprovalDetails["copy"]>;
35
35
  /** Aborts when the plugin withdraws the call; see `HoldDescription`. */
36
36
  supersede?: AbortSignal;
37
+ /** The card is the speaker's alone; see `HoldDescription`. */
38
+ speakerOnly?: true;
37
39
  }
38
40
  | { released: Release };
39
41
 
@@ -32,6 +32,8 @@ export interface Judgment {
32
32
  /** Aborts when the plugin withdraws the call; see `HoldDescription`. */
33
33
  supersede?: AbortSignal;
34
34
  approvers?: readonly string[];
35
+ /** The card is the speaker's alone; see `HoldDescription`. */
36
+ speakerOnly?: true;
35
37
  }
36
38
 
37
39
  export type Judged = Judgment | undefined | typeof STOPPED;
@@ -55,13 +57,16 @@ function isSignal(value: unknown): value is AbortSignal {
55
57
  export function described(
56
58
  answer: HoldDescription,
57
59
  tool: string,
58
- ): Pick<Judgment, "action" | "copy" | "supersede"> {
60
+ ): Pick<Judgment, "action" | "copy" | "supersede" | "speakerOnly"> {
59
61
  if (typeof answer === "string") return { action: answer };
60
62
  if (typeof answer !== "object" || answer === null)
61
63
  return { action: messages().holdGeneric(tool) };
62
- const { text, copy, supersede } = answer;
64
+ const { text, copy, supersede, approval } = answer;
63
65
  if (typeof text !== "string") return { action: messages().holdGeneric(tool) };
64
- const withdrawal = isSignal(supersede) ? { supersede } : {};
66
+ const withdrawal = {
67
+ ...(isSignal(supersede) ? { supersede } : {}),
68
+ ...(approval === "speaker" ? { speakerOnly: true as const } : {}),
69
+ };
65
70
  if (
66
71
  !copy ||
67
72
  typeof copy !== "object" ||
@@ -125,7 +130,12 @@ export function joinApprovers(
125
130
  if (!lookup) return withoutApprovers(answer, tool);
126
131
  const join = (action: Described): Judged | Promise<Judged> => {
127
132
  if (action === STOPPED || action === undefined) return action;
128
- const { action: text, copy, supersede } = described(action, tool);
133
+ const {
134
+ action: text,
135
+ copy,
136
+ supersede,
137
+ speakerOnly,
138
+ } = described(action, tool);
129
139
  const controller = new AbortController();
130
140
  if (turn.signal?.aborted) controller.abort();
131
141
  let named:
@@ -149,6 +159,7 @@ export function joinApprovers(
149
159
  action: text,
150
160
  ...(copy ? { copy } : {}),
151
161
  ...(supersede ? { supersede } : {}),
162
+ ...(speakerOnly ? { speakerOnly } : {}),
152
163
  ...(named ? { approvers: named } : {}),
153
164
  };
154
165
  }
@@ -159,6 +170,7 @@ export function joinApprovers(
159
170
  action: text,
160
171
  ...(copy ? { copy } : {}),
161
172
  ...(supersede ? { supersede } : {}),
173
+ ...(speakerOnly ? { speakerOnly } : {}),
162
174
  ...(approvers ? { approvers } : {}),
163
175
  };
164
176
  },
@@ -1,6 +1,11 @@
1
1
  import type { HoldRefusal } from "../domain/progress.ts";
2
2
  import { assistantName } from "../i18n/index.ts";
3
- import type { OwnerQuestion, Prompts } from "../interactions/prompts.ts";
3
+ import type {
4
+ OwnerQuestion,
5
+ Prompts,
6
+ PromptWait,
7
+ Questionnaire,
8
+ } from "../interactions/prompts.ts";
4
9
  import { turnBudget } from "./turn-budget.ts";
5
10
 
6
11
  /**
@@ -61,7 +66,22 @@ export class PromptSlot {
61
66
  get prompts(): Prompts | undefined {
62
67
  const prompts = this.#prompts;
63
68
  if (!prompts) return undefined;
69
+ const { questionnaire } = prompts;
64
70
  return {
71
+ ...(prompts.asks && questionnaire
72
+ ? {
73
+ asks: prompts.asks,
74
+ questionnaire: (
75
+ title: string,
76
+ asked: Questionnaire,
77
+ signal?: AbortSignal,
78
+ wait?: PromptWait,
79
+ ) =>
80
+ this.#waiting(() =>
81
+ questionnaire.call(prompts, title, asked, signal, wait),
82
+ ),
83
+ }
84
+ : {}),
65
85
  confirm: (title, message, signal, minTier, wait, details, options) =>
66
86
  this.#waiting(() =>
67
87
  prompts.confirm(
@@ -0,0 +1,192 @@
1
+ import { describe, test } from "bun:test";
2
+ import {
3
+ type AskSupport,
4
+ type Questionnaire,
5
+ type QuestionnaireAnswer,
6
+ shapeFor,
7
+ } from "../interactions/questionnaire.ts";
8
+ import type { SurfaceContractSubject } from "./surface-contract.ts";
9
+ import {
10
+ ContractBroken,
11
+ expectThat,
12
+ nextPrompt,
13
+ type Running,
14
+ runCheck,
15
+ scopeOf,
16
+ seen,
17
+ until,
18
+ } from "./surface-contract-support.ts";
19
+
20
+ /** One check of the questionnaire part of the surface contract. */
21
+ export interface QuestionnaireCheck {
22
+ name: string;
23
+ applies(subject: SurfaceContractSubject): boolean;
24
+ run(running: Running): Promise<void>;
25
+ }
26
+
27
+ /** The set the checks ask: two questions, the first with previews where the surface shows them. */
28
+ function contractSet(asks: AskSupport): Questionnaire {
29
+ const preview = asks.previews ? { preview: "```\nlayout\n```" } : {};
30
+ const day = {
31
+ question: "Which day?",
32
+ header: "Day",
33
+ options: [
34
+ { label: "Saturday", ...preview },
35
+ { label: "Sunday", ...preview },
36
+ ],
37
+ multi: false,
38
+ allowOther: true,
39
+ };
40
+ const food = {
41
+ question: "Which food?",
42
+ options: [{ label: "Rice" }, { label: "Noodles" }],
43
+ multi: true,
44
+ allowOther: false,
45
+ };
46
+ return shapeFor(
47
+ { questions: asks.questions >= 2 ? [day, food] : [day] },
48
+ asks,
49
+ );
50
+ }
51
+
52
+ /** The person's prompts, once the surface has started; undefined where it declares no `asks`. */
53
+ function questionnairePrompts(running: Running) {
54
+ const { surface, channel } = running.subject;
55
+ const prompts = surface.prompts?.(channel, scopeOf(running.subject));
56
+ if (!prompts?.asks || !prompts.questionnaire)
57
+ throw new ContractBroken(
58
+ "the subject answers questionnaires, but its prompts declare no asks",
59
+ );
60
+ return { prompts, asks: prompts.asks, ask: prompts.questionnaire };
61
+ }
62
+
63
+ const answerOf = (
64
+ set: Questionnaire,
65
+ asks: AskSupport,
66
+ ): QuestionnaireAnswer => ({
67
+ answers: set.questions.map((_, i) =>
68
+ i === 0 ? { choices: ["Sunday"] } : undefined,
69
+ ),
70
+ ...(asks.note ? { note: "thanks" } : {}),
71
+ });
72
+
73
+ const applies = (subject: SurfaceContractSubject) =>
74
+ subject.surface.prompts !== undefined &&
75
+ subject.answerQuestionnaire !== undefined;
76
+
77
+ /**
78
+ * The questionnaire checks. They apply to a subject that gives `answerQuestionnaire`, which must declare `asks`;
79
+ * a surface that takes one question at a time gives none and is skipped.
80
+ */
81
+ export const QUESTIONNAIRE_CHECKS: readonly QuestionnaireCheck[] = [
82
+ {
83
+ name: "a questionnaire resolves as the person answers it, keeps a blank question blank, and closes",
84
+ applies,
85
+ run: async (running) => {
86
+ const found = questionnairePrompts(running);
87
+ const answer = running.subject.answerQuestionnaire;
88
+ if (!answer) return;
89
+ const set = contractSet(found.asks);
90
+ const before = seen(running, "prompt").length;
91
+ const result = found.ask("Questions", set);
92
+ const id = await nextPrompt(running, before);
93
+ const given = answerOf(set, found.asks);
94
+ await answer(id, given, set);
95
+ const got = await result;
96
+ if (got === "pending" || got === undefined)
97
+ throw new ContractBroken(`the person's answers came back ${got}`);
98
+ expectThat(
99
+ JSON.stringify(got.answers.map((a) => a ?? null)) ===
100
+ JSON.stringify(given.answers.map((a) => a ?? null)),
101
+ `the answers came back as ${JSON.stringify(got.answers)}`,
102
+ );
103
+ expectThat(
104
+ got.note === given.note,
105
+ `the note came back as ${JSON.stringify(got.note)}`,
106
+ );
107
+ await until(
108
+ () =>
109
+ seen(running, "prompt_closed").some(
110
+ (o) => o.kind === "prompt_closed" && o.id === id,
111
+ ),
112
+ "the answered questionnaire closes",
113
+ );
114
+ },
115
+ },
116
+ {
117
+ name: "a stopped turn's questionnaire resolves with no answer and closes",
118
+ applies,
119
+ run: async (running) => {
120
+ const found = questionnairePrompts(running);
121
+ const stop = new AbortController();
122
+ const before = seen(running, "prompt").length;
123
+ const result = found.ask(
124
+ "Questions",
125
+ contractSet(found.asks),
126
+ stop.signal,
127
+ );
128
+ const id = await nextPrompt(running, before);
129
+ stop.abort();
130
+ const got = await result;
131
+ expectThat(
132
+ got === undefined,
133
+ `a stopped turn's questionnaire got ${got}`,
134
+ );
135
+ await until(
136
+ () =>
137
+ seen(running, "prompt_closed").some(
138
+ (o) => o.kind === "prompt_closed" && o.id === id,
139
+ ),
140
+ "the stopped questionnaire closes",
141
+ );
142
+ },
143
+ },
144
+ {
145
+ name: "another person cannot answer the person's questionnaire",
146
+ applies: (subject) =>
147
+ applies(subject) && subject.stranger?.answerQuestionnaire !== undefined,
148
+ run: async (running) => {
149
+ const found = questionnairePrompts(running);
150
+ const stranger = running.subject.stranger;
151
+ const answer = running.subject.answerQuestionnaire;
152
+ if (!answer || !stranger?.answerQuestionnaire) return;
153
+ await stranger.join?.();
154
+ const set = contractSet(found.asks);
155
+ const before = seen(running, "prompt").length;
156
+ let settled: unknown;
157
+ const result = found.ask("Questions", set).then((value) => {
158
+ settled = value;
159
+ return value;
160
+ });
161
+ const id = await nextPrompt(running, before);
162
+ await stranger.answerQuestionnaire(id, answerOf(set, found.asks), set);
163
+ await Bun.sleep(50);
164
+ expectThat(
165
+ settled === undefined,
166
+ "another person's answer settled the questionnaire",
167
+ );
168
+ await answer(id, answerOf(set, found.asks), set);
169
+ const own = await result;
170
+ expectThat(
171
+ own !== "pending" && own !== undefined,
172
+ `the person's answer came back ${own}`,
173
+ );
174
+ },
175
+ },
176
+ ];
177
+
178
+ /**
179
+ * Registers only the questionnaire part of the contract, for a card layer without a chat around
180
+ * it. Internal: the published contract is `describeSurfaceContract`.
181
+ */
182
+ export function describeQuestionnaireContract(
183
+ name: string,
184
+ make: () => Promise<SurfaceContractSubject>,
185
+ ): void {
186
+ describe(`${name} keeps the questionnaire contract`, () => {
187
+ for (const check of QUESTIONNAIRE_CHECKS)
188
+ test(check.name, async () => {
189
+ await runCheck(check, make);
190
+ });
191
+ });
192
+ }
@@ -95,6 +95,7 @@ export async function answeredOnlyByThem(
95
95
  other: SurfaceContractAnswerer | undefined,
96
96
  visibility: "private" | "shared",
97
97
  approvers?: readonly string[],
98
+ speakerOnly = false,
98
99
  ): Promise<void> {
99
100
  const { surface, channel, answer } = running.subject;
100
101
  const prompts = surface.prompts?.(
@@ -114,7 +115,12 @@ export async function answeredOnlyByThem(
114
115
  speakerOf(running.subject).tier,
115
116
  undefined,
116
117
  undefined,
117
- approvers ? { approvers } : undefined,
118
+ approvers || speakerOnly
119
+ ? {
120
+ ...(approvers ? { approvers } : {}),
121
+ ...(speakerOnly ? { speakerOnly } : {}),
122
+ }
123
+ : undefined,
118
124
  )
119
125
  .then((value) => {
120
126
  settled = value;
@@ -192,3 +198,25 @@ export async function namedApproverAnswers(running: Running): Promise<void> {
192
198
  `the approval reported ${reported?.principalId} as its approver, not ${approver.principalId}`,
193
199
  );
194
200
  }
201
+
202
+ /** Runs one check on a fresh subject from `make`, starting and stopping its surface around it. */
203
+ export async function runCheck(
204
+ check: {
205
+ applies?(subject: SurfaceContractSubject): boolean;
206
+ run(running: Running): Promise<void>;
207
+ },
208
+ make: () => Promise<SurfaceContractSubject>,
209
+ ): Promise<"passed" | "skipped"> {
210
+ const subject = await make();
211
+ const running: Running = { subject, delivered: [] };
212
+ try {
213
+ if (check.applies && !check.applies(subject)) return "skipped";
214
+ await subject.surface.start((message) => running.delivered.push(message));
215
+ await subject.join?.();
216
+ await check.run(running);
217
+ return "passed";
218
+ } finally {
219
+ await subject.close?.();
220
+ await subject.surface.stop?.();
221
+ }
222
+ }