pi-roundtable 0.20.0 → 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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.21.0] - 2026-10-20
9
+
10
+ ### Added
11
+
12
+ - A held call's approval card can be its speaker's alone (`pi-roundtable`). A hold answers `{ text, approval: "speaker" }` (`HoldDescription.approval`, beside `copy` and `supersede`; a tool's `hold` and a rule's `describeInTurn` both may) and the card reaches the surface as `ApprovalOptions.speakerOnly`. Only the speaker whose turn made the call answers it, on any of their identities the surface knows and at the call's tier, checked again when they press: in a shared conversation no owner does, and an owner's press or decline is refused with the card's own line while the card stays open for the speaker. A speaker below the tier, a turn with no speaker and a speaker with no Discord identity get `unavailable` at once, shown to no one, so the call stays held. With `approvers` the card is still the speaker's alone, and the runtime accepts their press only when the approvers name them or they are an owner. The option governs the card only; a message that confirms the held call is judged as before, so a tool that must act on the speaker's press alone checks `ToolTurn.approvedPrompt` and `ToolTurn.approvedBy` in `run`. Without it every card behaves as before. The Discord cards and the web chat honour it, and `SurfaceContractSubject.speakerOnly` makes `describeSurfaceContract` check, with an `owner`, that an owner's answer leaves such an approval open in a shared conversation; a subject that does not declare it skips the check.
13
+
8
14
  ## [0.20.0] - 2026-10-18
9
15
 
10
16
  ### Added
package/docs/plugins.md CHANGED
@@ -876,6 +876,21 @@ A rule whose verdict depends on the input, such as one that holds only a `delete
876
876
  It is asked when the input is not known yet, as for a [precheck script](#precheck-scripts-prechecks-the-agent-writes)'s call whose arguments are computed when it runs; a rule without it is judged by `describe` with an empty input.
877
877
  A rule whose held call stands for others can answer `approvalTier(tool, input, context)`: the lowest tier that may approve it when higher than the tool's own. The held call keeps it as `minTier`, and both its card and a confirming message require it. A card is answered, and a confirming message accepted, from the speaker whose turn held the call, when their tier is at least `minTier`, and from the owners where the conversation is shared ([who answers a prompt](#who-answers-a-prompt-promptscope)); nobody else in the channel may approve it. The held call records that speaker as `PendingConfirmation.speakerId` and their principal as `principalId`, so a confirming message from another identity of theirs counts after a restart too; a call held without them is the owners' to approve, and one held before 0.9, without a principal, is matched by `speakerId`.
878
878
 
879
+ #### A hold that makes its card the speaker's alone
880
+
881
+ By default the speaker whose turn made a held call may answer its card, and in a shared conversation so may every owner. A hold that wants only the speaker to press, such as one whose tool books something under the speaker's name, answers `{ text, approval: "speaker" }` (`HoldDescription.approval`, beside `copy` and `supersede`; a tool's `hold` and a rule's `describeInTurn` both may):
882
+
883
+ ```ts
884
+ hold: async ({ draft }, turn) => ({ text: await summaryOf(draft, turn), approval: "speaker" }),
885
+ ```
886
+
887
+ - Only the speaker answers the card: their principal, on any of their identities the surface knows, at the call's tier, checked again when they press. An owner's press is refused with the card's own line, the card stays open for the speaker, and the owner's decline is refused too. An owner who is the speaker answers as the speaker.
888
+ - It holds for a late press as well: a card answered after the turn stopped waiting is still the speaker's alone, so the turn that follows is started by the speaker.
889
+ - A speaker below the call's tier, a turn with no speaker, and a surface that cannot tell who the speaker is get `unavailable` at once, shown to no one, and the call stays held, as for any card nobody may answer. It never falls back to the owners.
890
+ - Combined with [approvers](#a-hold-that-names-its-approvers) the card is still the speaker's alone, and the runtime accepts the press only when the approvers name the speaker (or they are an owner); otherwise the call stays held.
891
+ - The option governs the card only. A message that confirms the held call is judged as for any call, so an owner's message can still release it. A tool that must act on the speaker's press alone checks in `run` that `turn.approvedPrompt` is set (a message sets none) and that `turn.approvedBy?.speakerId` is the speaker's.
892
+ - Anything but `"speaker"` leaves the card as every card is. A surface implements this through `ApprovalOptions.speakerOnly`; the Discord cards and the web chat (whose prompts are its person's alone) honour it, and the contract checks it where a subject declares `speakerOnly`.
893
+
879
894
  #### A hold that names its approvers
880
895
 
881
896
  A held call is approved by the speaker whose turn made it, or by an owner where the conversation is shared. A tool whose call stands for someone else, such as a message sent under another person's name, can name who approves it instead:
@@ -2645,6 +2660,8 @@ A surface's prompts keep these rules:
2645
2660
  - Without a scope, as for a turn with no speaker, the prompts are the owners'.
2646
2661
  - An approval that names [approvers](#a-hold-that-names-its-approvers) (`ApprovalOptions.approvers`, which `confirm` receives beside the scope) is theirs **instead of the speaker's**, where the scope escalates to the owners: each named principal answers on any of their identities the surface knows, at the tier it needs, checked again when they answer; the speaker does not answer it unless named, and an owner still does. When none of the named has an identity on the surface, or none is named, it is the owners' alone. Where the scope escalates to no one, the person alone answers, and only when they are named or an owner: for anyone else the approval resolves `unavailable` at once, shown to no one. The runtime also refuses such a call before it asks, whatever the surface does.
2647
2662
 
2663
+ - An approval that is the [speaker's alone](#a-hold-that-makes-its-card-the-speakers-alone) (`ApprovalOptions.speakerOnly`) is theirs without the owner escalation, whatever the scope says: the speaker's principal answers on any of their identities the surface knows, at the tier it needs, checked again when they answer, and no owner does. Without a scope, or when the speaker is below the tier or has no identity on the surface, it resolves `unavailable` at once, shown to no one.
2664
+
2648
2665
  On Discord a card for named approvers mentions every Discord identity of every named principal that has one, in a thread, and refuses anyone else with a line that says it is for the people it names and the owners.
2649
2666
  The web chat shows prompts to the conversation's person only. The runtime never asks it about a call that names approvers the person is neither among nor an owner of, and the person is the approver of one that names them.
2650
2667
 
@@ -2656,7 +2673,7 @@ The web chat shows prompts to the conversation's person only: its owners are not
2656
2673
 
2657
2674
  `confirm(title, message, signal?, minTier?, wait?, details?, options?)` takes the approval twice: `message` is the card as markdown text, and the optional `details` (`ApprovalDetails`: `{ action, tool, input, files? }`) is the same held call as data, with its whole input (the web chat cuts it only past 256 KiB and says so with `truncated`), and each file it sends by path as `{ path, bytes? }`.
2658
2675
  The confirmation gate and a coding worker's cards pass both.
2659
- `options` (`ApprovalOptions`) says who else may approve the call and hears who did: `approvers`, the principal ids that approve it instead of the speaker (see [who answers a prompt](#who-answers-a-prompt-promptscope)), and `approvedBy(by)`, which a surface that knows who answered calls with `{ principalId, speakerId, name, owner? }` (`ApprovedBy`; `owner` when they approved as an owner) before `confirm` resolves `approved`. `approvedPrompt(prompt)` is called, likewise before `confirm` resolves `approved`, with the id of the card that approved the call, for `ToolTurn.approvedPrompt`; a surface whose cards have no stable id leaves it uncalled. A surface may ignore all three for a call that names no approvers. For one that does, the runtime accepts the approval only from a surface that calls `approvedBy` with a named principal or an `owner`, so a surface that cannot say who answered must not offer such a call to a shared conversation.
2676
+ `options` (`ApprovalOptions`) says who else may approve the call and hears who did: `approvers`, the principal ids that approve it instead of the speaker (see [who answers a prompt](#who-answers-a-prompt-promptscope)), `speakerOnly`, true when the card is the speaker's alone and no owner may answer it (see [a hold that makes its card the speaker's alone](#a-hold-that-makes-its-card-the-speakers-alone); a surface that cannot honour it resolves `unavailable` instead of showing the card to the owners), and `approvedBy(by)`, which a surface that knows who answered calls with `{ principalId, speakerId, name, owner? }` (`ApprovedBy`; `owner` when they approved as an owner) before `confirm` resolves `approved`. `approvedPrompt(prompt)` is called, likewise before `confirm` resolves `approved`, with the id of the card that approved the call, for `ToolTurn.approvedPrompt`; a surface whose cards have no stable id leaves it uncalled. A surface may ignore all three for a call that names no approvers. For one that does, the runtime accepts the approval only from a surface that calls `approvedBy` with a named principal or an `owner`, so a surface that cannot say who answered must not offer such a call to a shared conversation.
2660
2677
  A surface that shows text, such as Discord, uses `message` and reads only `details.copy` (below); the web chat sends `details` to the client as the prompt frame's `approval`, beside the unchanged `title` and `message`.
2661
2678
  A surface or a `Prompts` of your own may ignore it: nothing requires reading it.
2662
2679
 
@@ -3419,9 +3436,9 @@ test("the production configuration registers every plugin", async () => {
3419
3436
  ### `describeSurfaceContract`: what every chat surface keeps
3420
3437
 
3421
3438
  A plugin that contributes a `ChatSurface` can test it against the contract every surface keeps.
3422
- `describeSurfaceContract(name, make)` registers one test per check, each on a fresh `SurfaceContractSubject` from `make`: the `surface`, a `channel` of it, and one person on its network, who can `write(text)`, whose `observations()` list what they have seen (`SurfaceObservation`: a reply's text and file names, typing and stop controls going on and off, progress, and prompts opening and closing), and who may `answer(prompt, approved)` an approval; `speaker` is who they are to `surface.prompts`, given as `promptScope(speaker, visibility)`, an owner by default, and every approval the contract asks for needs exactly their tier. `stranger`, when given, is someone else on the network, another principal and not an owner, who knows the id of the person's open approval and may `join()` and `answer(prompt, approved)` it; `owner`, when given, is an owner on the network, another principal than the person, who does the same in the person's private conversation. Both are `SurfaceContractAnswerer`s. `approver`, when given, is a third person on the network with a `principalId`, not an owner and at the person's tier, whom the contract names in `approvers`; `privateOnly` marks a surface that serves only conversations private to one person, such as the web chat.
3439
+ `describeSurfaceContract(name, make)` registers one test per check, each on a fresh `SurfaceContractSubject` from `make`: the `surface`, a `channel` of it, and one person on its network, who can `write(text)`, whose `observations()` list what they have seen (`SurfaceObservation`: a reply's text and file names, typing and stop controls going on and off, progress, and prompts opening and closing), and who may `answer(prompt, approved)` an approval; `speaker` is who they are to `surface.prompts`, given as `promptScope(speaker, visibility)`, an owner by default, and every approval the contract asks for needs exactly their tier. `stranger`, when given, is someone else on the network, another principal and not an owner, who knows the id of the person's open approval and may `join()` and `answer(prompt, approved)` it; `owner`, when given, is an owner on the network, another principal than the person, who does the same in the person's private conversation. Both are `SurfaceContractAnswerer`s. `approver`, when given, is a third person on the network with a `principalId`, not an owner and at the person's tier, whom the contract names in `approvers`; `speakerOnly` marks a surface that honours `ApprovalOptions.speakerOnly` in a shared conversation, which the contract then checks with the `owner`; `privateOnly` marks a surface that serves only conversations private to one person, such as the web chat.
3423
3440
  The contract starts the surface, calls the subject's `join()`, and stops the surface after the subject's `close()`.
3424
- It checks that the surface names a prefix and owns its channel, delivers what the person writes, and shows every chunk of a reply; and, for the parts the surface offers, that it delivers files when `supportsFiles` is set, ends typing and stop controls idempotently, shows progress, and resolves an approval as the person approves or declines it, or `cancelled` when the turn stops or its plugin withdraws it (`ApprovalOptions.supersede`; one answered first stays answered), with a `stranger`, that the stranger's answer leaves the person's approval open, and with an `owner`, that an owner's answer leaves it open in a private conversation; with an `approver` and a `stranger`, that an approval naming the approver is answered by the approver and not by the person or the stranger, unless the surface is `privateOnly`, and, with an `approver`, that a private conversation ignores the names; and, for a speaker below the owner tier, that a private conversation's approval above their tier resolves `unavailable` (or `expired`, as older surfaces answer) at once and is not shown.
3441
+ It checks that the surface names a prefix and owns its channel, delivers what the person writes, and shows every chunk of a reply; and, for the parts the surface offers, that it delivers files when `supportsFiles` is set, ends typing and stop controls idempotently, shows progress, and resolves an approval as the person approves or declines it, or `cancelled` when the turn stops or its plugin withdraws it (`ApprovalOptions.supersede`; one answered first stays answered), with a `stranger`, that the stranger's answer leaves the person's approval open, and with an `owner`, that an owner's answer leaves it open in a private conversation; with an `approver` and a `stranger`, that an approval naming the approver is answered by the approver and not by the person or the stranger, unless the surface is `privateOnly`, and, with an `approver`, that a private conversation ignores the names; with a `speakerOnly` subject and an `owner`, that an owner's answer leaves an approval that is the speaker's alone open in a shared conversation, unless the surface is `privateOnly`; and, for a speaker below the owner tier, that a private conversation's approval above their tier resolves `unavailable` (or `expired`, as older surfaces answer) at once and is not shown.
3425
3442
  A check for a part the surface does not offer passes.
3426
3443
  The contract has no check for `ApprovalDetails.copy`: a surface that shows it keeps it informational, so using it never resolves, approves or declines the prompt, whoever uses it.
3427
3444
  A subject that gives `answerQuestionnaire` (and a `stranger` that gives its own) must declare `asks` and also keeps the questionnaire checks.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "A plugin-driven Pi agent server for one owner, reached through Discord or the web",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -58,7 +58,9 @@ export class CardAudiences {
58
58
  scope: PromptScope | undefined,
59
59
  minTier: Tier,
60
60
  named?: readonly string[],
61
+ speakerOnly = false,
61
62
  ): Promise<Audience | undefined> {
63
+ if (speakerOnly) return this.#speakers(scope, minTier);
62
64
  let approvers = named;
63
65
  // A private conversation's person is the only one who answers, so a call naming others is theirs only if named or an owner.
64
66
  if (approvers && scope?.escalate === "none") {
@@ -78,6 +80,51 @@ export class CardAudiences {
78
80
  );
79
81
  }
80
82
 
83
+ /**
84
+ * The card of a call that is its speaker's alone (`ApprovalOptions.speakerOnly`): the scope's
85
+ * principal on any of their Discord identities still theirs when they press, at the tier the
86
+ * call needs, checked again then, and no owner as such, whatever the scope escalates to. The
87
+ * primary owner answers their own card, and where there is no identity service the primary owner
88
+ * is the only speaker who has one. Undefined, so the call is unavailable at once, when there is
89
+ * no scope, or the speaker is below the tier or has no Discord identity.
90
+ */
91
+ async #speakers(
92
+ scope: PromptScope | undefined,
93
+ minTier: Tier,
94
+ ): Promise<Audience | undefined> {
95
+ if (!scope) return undefined;
96
+ const { ownerId, identity } = this.#options;
97
+ if (!identity) {
98
+ // Without an identity service the primary owner is the only one who answers, and the only speaker who can.
99
+ if (scope.speakerId !== ownerId) return undefined;
100
+ return {
101
+ allows: async (user) => user.id === ownerId,
102
+ mentions: [ownerId],
103
+ note: "",
104
+ refusal: messages().cardOwnerOnly,
105
+ approver: (user) => this.#approver(user),
106
+ };
107
+ }
108
+ const own = await this.#ownIdentities(scope, minTier);
109
+ if (!own) return undefined;
110
+ const mentions = own.includes(scope.speakerId) ? [scope.speakerId] : own;
111
+ const primary = own.includes(ownerId);
112
+ return {
113
+ allows: async (user) =>
114
+ own.includes(user.id) &&
115
+ // Read the links again: one moved or unlinked since the card was posted is no longer theirs.
116
+ (await this.#stillOwn(scope.principalId, user.id)) &&
117
+ ((primary && user.id === ownerId) ||
118
+ (await this.#owners.holds(user, minTier, scope.principalId))),
119
+ mentions,
120
+ note: primary ? "" : messages().cardApproversNote(...mentions),
121
+ refusal: primary
122
+ ? messages().cardOwnerOnly
123
+ : messages().cardApproversRefusal,
124
+ approver: (user) => this.#approver(user),
125
+ };
126
+ }
127
+
81
128
  /** Who may answer a question; undefined when no one may. */
82
129
  question(scope: PromptScope | undefined): Promise<Audience | undefined> {
83
130
  return this.#audience(scope, undefined, {
@@ -141,6 +141,7 @@ export class OwnerCards implements InteractionModule {
141
141
  scope,
142
142
  minTier,
143
143
  options?.approvers,
144
+ options?.speakerOnly === true,
144
145
  );
145
146
  // A private conversation's call above its person's tier is no one's to approve.
146
147
  if (!audience) return "unavailable";
package/src/core/holds.ts CHANGED
@@ -19,8 +19,20 @@ export interface HoldContext {
19
19
 
20
20
  /**
21
21
  * What a hold that runs in a turn answers with: the description of the call, or the description
22
- * as `text` with a `copy` the owner's approval card offers beside it (see `ApprovalDetails.copy`)
23
- * and a `supersede` signal to withdraw the call by.
22
+ * as `text` with a `copy` the owner's approval card offers beside it (see `ApprovalDetails.copy`),
23
+ * a `supersede` signal to withdraw the call by, and `approval: "speaker"` to make the card the
24
+ * speaker's alone.
25
+ *
26
+ * With `approval: "speaker"` only the speaker whose turn made the call may answer its card, on
27
+ * any of their identities the surface knows and at the call's tier; no owner may answer it as an
28
+ * owner (an owner who is the speaker answers as the speaker). A speaker below the call's tier, a
29
+ * turn with no speaker, or a surface that cannot tell who the speaker is gets `unavailable` at
30
+ * once and the call stays held. Combined with approvers the card is still the speaker's alone,
31
+ * and the runtime accepts their press only when the approvers name them or they are an owner;
32
+ * otherwise the call stays held. It governs the card only: a message that confirms the held call
33
+ * is judged as for any call, so a tool that must act on the speaker's press alone checks
34
+ * `ToolTurn.approvedPrompt` and `ToolTurn.approvedBy` in `run`. Anything but `"speaker"` leaves
35
+ * the card as every card is.
24
36
  *
25
37
  * Aborting `supersede` withdraws the call the hold held, when the plugin no longer wants it
26
38
  * (a newer request replaced it, its subject is gone): its approval card is closed, also after
@@ -39,6 +51,7 @@ export type HoldDescription =
39
51
  text: string;
40
52
  copy?: NonNullable<ApprovalDetails["copy"]>;
41
53
  supersede?: AbortSignal;
54
+ approval?: "speaker";
42
55
  };
43
56
 
44
57
  /**
@@ -95,6 +95,16 @@ export interface ApprovalOptions {
95
95
  * conversation an approval it does not name is not accepted for a call that names approvers.
96
96
  */
97
97
  approvers?: readonly string[];
98
+ /**
99
+ * When true the card is the scope's speaker's alone: they answer it on any of their identities
100
+ * the surface knows, at the call's `minTier` checked again when they answer, and the owners do
101
+ * not, whatever the scope escalates to. Without a speaker, below the tier, or on a surface that
102
+ * cannot tell, it resolves `unavailable` at once. With `approvers`, the speaker's press is
103
+ * accepted only if they are named (the runtime refuses the rest). A surface that cannot honour
104
+ * it must resolve `unavailable` rather than show the card to the owners. Absent or false leaves
105
+ * the card as every card is.
106
+ */
107
+ speakerOnly?: boolean;
98
108
  /**
99
109
  * Called with who approved the call, before the approval resolves `approved`; not called for a
100
110
  * refusal, or by a surface that does not know who answered.
@@ -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
  },
@@ -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;
@@ -83,6 +83,12 @@ export interface SurfaceContractSubject {
83
83
  * named-approver checks are skipped.
84
84
  */
85
85
  approver?: SurfaceContractAnswerer & { principalId: string };
86
+ /**
87
+ * Set by a surface that honours `ApprovalOptions.speakerOnly` in a shared conversation, where
88
+ * `owner` is on the network and could otherwise answer: the owner's answer to such an approval
89
+ * settles nothing, the person's does. Without it, or without `owner`, the check is skipped.
90
+ */
91
+ speakerOnly?: boolean;
86
92
  /**
87
93
  * Set by a surface that serves only conversations private to one person, such as the web chat:
88
94
  * it has no shared conversation in which an approval is the named approvers', so it skips that
@@ -369,6 +375,23 @@ const CHECKS: readonly Check[] = [
369
375
  run: (running) =>
370
376
  answeredOnlyByThem(running, running.subject.owner, "private"),
371
377
  },
378
+ {
379
+ name: "an approval that is the speaker's alone is not answered by an owner in a shared conversation",
380
+ applies: (subject) =>
381
+ subject.surface.prompts !== undefined &&
382
+ subject.answer !== undefined &&
383
+ subject.owner !== undefined &&
384
+ subject.speakerOnly === true &&
385
+ subject.privateOnly !== true,
386
+ run: (running) =>
387
+ answeredOnlyByThem(
388
+ running,
389
+ running.subject.owner,
390
+ "shared",
391
+ undefined,
392
+ true,
393
+ ),
394
+ },
372
395
  {
373
396
  name: "a named approver answers an approval instead of the person; the person and another person cannot",
374
397
  applies: (subject) =>
@@ -448,7 +471,8 @@ export async function checkSurfaceContract(
448
471
  * one its plugin withdraws (`ApprovalOptions.supersede`) as cancelled, ignores
449
472
  * another person's answer to the person's approval, and, in a private conversation, an owner's
450
473
  * too, and lets an approval above the person's tier there come back at once, shown to no one.
451
- * Where it can name approvers, an approval that does is answered by them, reported as the
474
+ * Where `speakerOnly` is declared, an approval that is the speaker's alone is not settled by an
475
+ * owner's answer in a shared conversation. Where it can name approvers, an approval that does is answered by them, reported as the
452
476
  * approver, and not by the person or another person, and a private conversation's is answered by
453
477
  * its person alone, whoever the call names besides them. A subject that gives
454
478
  * `answerQuestionnaire` must declare `asks`, and then questionnaires keep the same rules. Each