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 +6 -0
- package/docs/plugins.md +20 -3
- package/package.json +1 -1
- package/src/core/discord/card-audience.ts +47 -0
- package/src/core/discord/owner-cards.ts +1 -0
- package/src/core/holds.ts +15 -2
- package/src/core/interactions/prompts.ts +10 -0
- package/src/core/runtime/extensions/card-ask.ts +3 -0
- package/src/core/runtime/extensions/confirmation-gate.ts +2 -0
- package/src/core/runtime/extensions/gate-refusals.ts +2 -0
- package/src/core/runtime/extensions/hold-lookups.ts +16 -4
- package/src/core/testing/surface-contract-support.ts +7 -1
- package/src/core/testing/surface-contract.ts +25 -1
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
|
@@ -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
|
-
*
|
|
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 =
|
|
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 {
|
|
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
|
|
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
|
|
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
|