@danypops/papyrus 0.27.15 → 0.28.1
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/extension/src/discuss.ts +1 -1
- package/extension/src/domain-tools.ts +33 -3
- package/package.json +1 -1
- package/src/adapters/sqlite-discussion-round-store.ts +8 -4
- package/src/cli.ts +6 -4
- package/src/constants.ts +7 -1
- package/src/db.ts +12 -0
- package/src/discussion-service.ts +18 -10
- package/src/domain/discussion.ts +34 -3
- package/src/modules/discuss.ts +2 -0
package/extension/src/discuss.ts
CHANGED
|
@@ -89,7 +89,7 @@ export async function showDiscussions(ctx: ExtensionCommandContext): Promise<voi
|
|
|
89
89
|
const question = transcript.rounds.at(-1)?.content?.trim() || `Reply to "${discussion.title}":`;
|
|
90
90
|
const subtitle = discussion.title;
|
|
91
91
|
const answer = pending?.pendingOptions && pending.pendingOptions.length > 0 && pending.pendingOptionsMode
|
|
92
|
-
? await askQuestion(commandCtx, { question, subtitle, options: pending.pendingOptions.map((title) => ({ title })), allowMultiple: pending.pendingOptionsMode === "multi" })
|
|
92
|
+
? await askQuestion(commandCtx, { question, subtitle, options: pending.pendingOptions.map((title, index) => ({ title, description: pending.pendingOptionDescriptions?.[index] || undefined })), allowMultiple: pending.pendingOptionsMode === "multi" })
|
|
93
93
|
: await askQuestion(commandCtx, { question, subtitle });
|
|
94
94
|
if (!answer) return; // canceled
|
|
95
95
|
await callService("discuss.reply", { id: discussion.id, actor: ACTOR, content: answer.content, ...(answer.selected ? { selected: answer.selected } : {}), source: SOURCE });
|
|
@@ -39,6 +39,35 @@ function text(message: string, details: unknown = {}) {
|
|
|
39
39
|
* is available, never throws -- an unanswered live prompt still leaves the round it already
|
|
40
40
|
* recorded intact.
|
|
41
41
|
*/
|
|
42
|
+
/**
|
|
43
|
+
* The discuss tool accepts each option as either a bare string (self-evident choices) or
|
|
44
|
+
* {title, description} (a real tradeoff worth spelling out) -- normalizes to the two parallel
|
|
45
|
+
* arrays discuss.open/discuss.reply actually expect (options: string[], option_descriptions:
|
|
46
|
+
* string[], index-aligned, empty string meaning "none for this one"). Mutates params in place.
|
|
47
|
+
*/
|
|
48
|
+
function normalizeDiscussOptions(params: Record<string, unknown>): void {
|
|
49
|
+
const raw = params.options;
|
|
50
|
+
if (!Array.isArray(raw)) return;
|
|
51
|
+
let anyDescription = false;
|
|
52
|
+
const titles: string[] = [];
|
|
53
|
+
const descriptions: string[] = [];
|
|
54
|
+
for (const entry of raw) {
|
|
55
|
+
if (typeof entry === "string") { titles.push(entry); descriptions.push(""); continue; }
|
|
56
|
+
if (entry && typeof entry === "object" && typeof (entry as Record<string, unknown>).title === "string") {
|
|
57
|
+
const record = entry as Record<string, unknown>;
|
|
58
|
+
titles.push(record.title as string);
|
|
59
|
+
const description = typeof record.description === "string" ? record.description : "";
|
|
60
|
+
if (description) anyDescription = true;
|
|
61
|
+
descriptions.push(description);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
titles.push(String(entry));
|
|
65
|
+
descriptions.push("");
|
|
66
|
+
}
|
|
67
|
+
params.options = titles;
|
|
68
|
+
if (anyDescription) params.option_descriptions = descriptions;
|
|
69
|
+
}
|
|
70
|
+
|
|
42
71
|
async function liveAnswer(ctx: ExtensionContext, discussion: Artifact, latestContent: string | undefined, onUpdate: AgentToolUpdateCallback | undefined, signal: AbortSignal | undefined): Promise<{ content: string; selected?: string[] } | undefined> {
|
|
43
72
|
if (!ctx.hasUI) return undefined;
|
|
44
73
|
const pending = (() => { try { return readDiscussionExtra(discussion.extra); } catch { return undefined; } })();
|
|
@@ -52,7 +81,7 @@ async function liveAnswer(ctx: ExtensionContext, discussion: Artifact, latestCon
|
|
|
52
81
|
return askQuestion(ctx, {
|
|
53
82
|
question,
|
|
54
83
|
subtitle,
|
|
55
|
-
options: pending.pendingOptions.map((title) => ({ title })),
|
|
84
|
+
options: pending.pendingOptions.map((title, index) => ({ title, description: pending.pendingOptionDescriptions?.[index] || undefined })),
|
|
56
85
|
allowMultiple: pending.pendingOptionsMode === "multi",
|
|
57
86
|
onUpdate,
|
|
58
87
|
signal,
|
|
@@ -681,7 +710,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
681
710
|
pi.registerTool({
|
|
682
711
|
name: "discuss",
|
|
683
712
|
label: "Discuss",
|
|
684
|
-
description: "Native Papyrus deliberation with a real lifecycle -- distinct from a one-shot ask: a Discussion persists, takes multiple rounds, and can genuinely block a Task's completion until settled or deferred. ACTIONS: open, reply, defer, resume, settle, block, unblock, show, rounds, list. open starts round 1 and optionally blocks_task_ids immediately. reply is refused once deferred or settled -- resume first. defer is explicitly non-blocking (paused, resumable); settle is terminal and archives the discussion. block/unblock manage the blocking relationship to a task independently of open. A task's completion is refused while any active Discussion blocks it. open/reply can pose a structured choice via options (2-10 entries) + options_mode ('single' mutually exclusive, 'multi' allows several); reply answers a currently pending choice via selected, validated against it. Pass live:true on open or reply to get the human's answer synchronously in this same call, via an interactive prompt (the pending choice's picker if one was posed, otherwise a freeform question) -- covers a completely open question with no artifact (open with no prior discussion) and a question tied to a specific existing artifact (reply, addressed by name) alike. Only takes effect with an interactive UI available; otherwise degrades silently to the normal async round. PREFER `name` (the discussion's exact title) over `id`, `task_name`/`blocks_task_names` over `task_id`/`blocks_task_ids` -- all are backend implementation details, resolved from name automatically.",
|
|
713
|
+
description: "Native Papyrus deliberation with a real lifecycle -- distinct from a one-shot ask: a Discussion persists, takes multiple rounds, and can genuinely block a Task's completion until settled or deferred. ACTIONS: open, reply, defer, resume, settle, block, unblock, show, rounds, list. open starts round 1 and optionally blocks_task_ids immediately. reply is refused once deferred or settled -- resume first. defer is explicitly non-blocking (paused, resumable); settle is terminal and archives the discussion. block/unblock manage the blocking relationship to a task independently of open. A task's completion is refused while any active Discussion blocks it. open/reply can pose a structured choice via options (2-10 entries) + options_mode ('single' mutually exclusive, 'multi' allows several); reply answers a currently pending choice via selected, validated against it. Each option is either a bare string or {title, description}; description is optional for exactly 2 options (a self-evident yes/no) but REQUIRED and non-empty for every option once there are 3 or more -- rejected otherwise. One line: the real pro/con/risk/consequence, never padding that just restates the title. Pass live:true on open or reply to get the human's answer synchronously in this same call, via an interactive prompt (the pending choice's picker if one was posed, otherwise a freeform question) -- covers a completely open question with no artifact (open with no prior discussion) and a question tied to a specific existing artifact (reply, addressed by name) alike. Only takes effect with an interactive UI available; otherwise degrades silently to the normal async round. PREFER `name` (the discussion's exact title) over `id`, `task_name`/`blocks_task_names` over `task_id`/`blocks_task_ids` -- all are backend implementation details, resolved from name automatically.",
|
|
685
714
|
parameters: Type.Object({
|
|
686
715
|
action: Type.String(),
|
|
687
716
|
id: Type.Optional(Type.String()),
|
|
@@ -700,7 +729,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
700
729
|
state: Type.Optional(Type.String()),
|
|
701
730
|
after_round: Type.Optional(Type.Number()),
|
|
702
731
|
limit: Type.Optional(Type.Number()),
|
|
703
|
-
options: Type.Optional(Type.Array(Type.String())),
|
|
732
|
+
options: Type.Optional(Type.Array(Type.Union([Type.String(), Type.Object({ title: Type.String(), description: Type.Optional(Type.String()) })]))),
|
|
704
733
|
options_mode: Type.Optional(Type.String()),
|
|
705
734
|
selected: Type.Optional(Type.Array(Type.String())),
|
|
706
735
|
live: Type.Optional(Type.Boolean()),
|
|
@@ -722,6 +751,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
722
751
|
]);
|
|
723
752
|
await resolveNameArrayField(params, "blocks_task_names", "blocks_task_ids", "tasks.list", taskScope);
|
|
724
753
|
if (action === "open" || action === "reply") {
|
|
754
|
+
normalizeDiscussOptions(params);
|
|
725
755
|
const operation = action === "open" ? "discuss.open" : "discuss.reply";
|
|
726
756
|
const result = await callService<Record<string, unknown>, DiscussionAndRounds>(operation, params);
|
|
727
757
|
const fallback = action === "open"
|
package/package.json
CHANGED
|
@@ -21,6 +21,7 @@ interface DiscussionRoundRow {
|
|
|
21
21
|
options: string | null;
|
|
22
22
|
options_mode: string | null;
|
|
23
23
|
selected: string | null;
|
|
24
|
+
option_descriptions: string | null;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
function mapRow(row: DiscussionRoundRow): DiscussionRound {
|
|
@@ -34,6 +35,7 @@ function mapRow(row: DiscussionRoundRow): DiscussionRound {
|
|
|
34
35
|
...(row.options !== null ? { options: JSON.parse(row.options) as string[] } : {}),
|
|
35
36
|
...(row.options_mode !== null ? { optionsMode: row.options_mode as DiscussionOptionsMode } : {}),
|
|
36
37
|
...(row.selected !== null ? { selected: JSON.parse(row.selected) as string[] } : {}),
|
|
38
|
+
...(row.option_descriptions !== null ? { optionDescriptions: JSON.parse(row.option_descriptions) as string[] } : {}),
|
|
37
39
|
};
|
|
38
40
|
}
|
|
39
41
|
|
|
@@ -47,16 +49,17 @@ export class SQLiteDiscussionRoundStore implements DiscussionRoundStore {
|
|
|
47
49
|
// currently pending options (extra.discussion), which this store, deliberately scoped to
|
|
48
50
|
// the rounds table alone, has no access to. discussion-service.ts validates it beforehand.
|
|
49
51
|
const posed = round.options !== undefined || round.optionsMode !== undefined
|
|
50
|
-
? validateDiscussionOptions(round.options ?? [], round.optionsMode ?? "")
|
|
52
|
+
? validateDiscussionOptions(round.options ?? [], round.optionsMode ?? "", round.optionDescriptions)
|
|
51
53
|
: undefined;
|
|
52
54
|
const result = this.db.prepare(`
|
|
53
|
-
INSERT INTO discussion_rounds (discussion_id, round_number, actor, content, occurred_at, event_schema_version, options, options_mode, selected)
|
|
54
|
-
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)
|
|
55
|
+
INSERT INTO discussion_rounds (discussion_id, round_number, actor, content, occurred_at, event_schema_version, options, options_mode, selected, option_descriptions)
|
|
56
|
+
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?)
|
|
55
57
|
`).run(
|
|
56
58
|
round.discussionId, round.roundNumber, actor, content, occurredAt,
|
|
57
59
|
posed ? JSON.stringify(posed.options) : null,
|
|
58
60
|
posed ? posed.mode : null,
|
|
59
61
|
round.selected !== undefined ? JSON.stringify(round.selected) : null,
|
|
62
|
+
posed?.optionDescriptions ? JSON.stringify(posed.optionDescriptions) : null,
|
|
60
63
|
);
|
|
61
64
|
return {
|
|
62
65
|
id: Number(result.lastInsertRowid),
|
|
@@ -66,6 +69,7 @@ export class SQLiteDiscussionRoundStore implements DiscussionRoundStore {
|
|
|
66
69
|
content,
|
|
67
70
|
occurredAt,
|
|
68
71
|
...(posed ? { options: posed.options, optionsMode: posed.mode } : {}),
|
|
72
|
+
...(posed?.optionDescriptions ? { optionDescriptions: posed.optionDescriptions } : {}),
|
|
69
73
|
...(round.selected !== undefined ? { selected: [...round.selected] } : {}),
|
|
70
74
|
};
|
|
71
75
|
}
|
|
@@ -73,7 +77,7 @@ export class SQLiteDiscussionRoundStore implements DiscussionRoundStore {
|
|
|
73
77
|
list(query: DiscussionRoundQuery): DiscussionRound[] {
|
|
74
78
|
const limit = Math.min(DISCUSSION_ROUNDS_MAX_LIMIT, Math.max(1, Math.floor(query.limit ?? DISCUSSION_ROUNDS_DEFAULT_LIMIT)));
|
|
75
79
|
const rows = this.db.prepare(`
|
|
76
|
-
SELECT id, discussion_id, round_number, actor, content, occurred_at, options, options_mode, selected
|
|
80
|
+
SELECT id, discussion_id, round_number, actor, content, occurred_at, options, options_mode, selected, option_descriptions
|
|
77
81
|
FROM discussion_rounds
|
|
78
82
|
WHERE discussion_id = ? AND round_number > ?
|
|
79
83
|
ORDER BY round_number ASC
|
package/src/cli.ts
CHANGED
|
@@ -128,8 +128,8 @@ const USAGE = `Usage:
|
|
|
128
128
|
papyrus log append --source <id> --level <debug|info|warning|error> --message <text> --operation-id <id> [--source-label <text>] [--fields-json <json>] [--session-id <id>] [--occurred-at <iso>] [--global] [--json]
|
|
129
129
|
papyrus session register --session-id <id> [--json]
|
|
130
130
|
papyrus session release --session-id <id> [--session-secret <secret>] [--json]
|
|
131
|
-
papyrus discuss open --title <t> --actor <a> --content <c> [--body <b>] [--labels-json <json>] [--blocks-json <json>] [--options-json <json>] [--options-mode single|multi] [--json]
|
|
132
|
-
papyrus discuss reply <id> --actor <a> --content <c> [--selected-json <json>] [--options-json <json>] [--options-mode single|multi] [--json]
|
|
131
|
+
papyrus discuss open --title <t> --actor <a> --content <c> [--body <b>] [--labels-json <json>] [--blocks-json <json>] [--options-json <json>] [--options-mode single|multi] [--option-descriptions-json <json>] [--json]
|
|
132
|
+
papyrus discuss reply <id> --actor <a> --content <c> [--selected-json <json>] [--options-json <json>] [--options-mode single|multi] [--option-descriptions-json <json>] [--json]
|
|
133
133
|
papyrus discuss defer <id> [--reason <text>] [--json]
|
|
134
134
|
papyrus discuss resume <id> [--json]
|
|
135
135
|
papyrus discuss settle <id> --settlement <text> [--json]
|
|
@@ -1134,6 +1134,7 @@ export async function runDiscussCli(args: string[], client: TaskCliClient): Prom
|
|
|
1134
1134
|
let limit: number | undefined;
|
|
1135
1135
|
let options: string[] | undefined;
|
|
1136
1136
|
let optionsMode: string | undefined;
|
|
1137
|
+
let optionDescriptions: string[] | undefined;
|
|
1137
1138
|
let selected: string[] | undefined;
|
|
1138
1139
|
for (let index = 0; index < args.length; index++) {
|
|
1139
1140
|
const argument = args[index]!;
|
|
@@ -1147,6 +1148,7 @@ export async function runDiscussCli(args: string[], client: TaskCliClient): Prom
|
|
|
1147
1148
|
if (argument === "--task-id") { taskId = args[++index]; if (!taskId) throw new Error("--task-id requires a value"); continue; }
|
|
1148
1149
|
if (argument === "--options-json") { options = parseJsonStringArrayFlag(args[++index], "--options-json"); continue; }
|
|
1149
1150
|
if (argument === "--options-mode") { optionsMode = args[++index]; if (!optionsMode) throw new Error("--options-mode requires a value"); continue; }
|
|
1151
|
+
if (argument === "--option-descriptions-json") { optionDescriptions = parseJsonStringArrayFlag(args[++index], "--option-descriptions-json"); continue; }
|
|
1150
1152
|
if (argument === "--selected-json") { selected = parseJsonStringArrayFlag(args[++index], "--selected-json"); continue; }
|
|
1151
1153
|
if (argument === "--reason") { reason = args[++index]; if (reason === undefined) throw new Error("--reason requires a value"); continue; }
|
|
1152
1154
|
if (argument === "--settlement") { settlement = args[++index]; if (!settlement) throw new Error("--settlement requires a value"); continue; }
|
|
@@ -1170,12 +1172,12 @@ export async function runDiscussCli(args: string[], client: TaskCliClient): Prom
|
|
|
1170
1172
|
switch (action) {
|
|
1171
1173
|
case "open": {
|
|
1172
1174
|
if (id) throw new Error("discuss open accepts no positional arguments");
|
|
1173
|
-
const result = await client.call<Record<string, unknown>, unknown>("discuss.open", { title, actor, content, body, labels, blocks_task_ids: blocksTaskIds, options, options_mode: optionsMode });
|
|
1175
|
+
const result = await client.call<Record<string, unknown>, unknown>("discuss.open", { title, actor, content, body, labels, blocks_task_ids: blocksTaskIds, options, options_mode: optionsMode, option_descriptions: optionDescriptions });
|
|
1174
1176
|
return json ? JSON.stringify(result) : JSON.stringify(result, null, 2);
|
|
1175
1177
|
}
|
|
1176
1178
|
case "reply": {
|
|
1177
1179
|
if (!id) throw new Error("discuss reply requires exactly one discussion id");
|
|
1178
|
-
const result = await client.call<Record<string, unknown>, unknown>("discuss.reply", { id, actor, content, selected, options, options_mode: optionsMode });
|
|
1180
|
+
const result = await client.call<Record<string, unknown>, unknown>("discuss.reply", { id, actor, content, selected, options, options_mode: optionsMode, option_descriptions: optionDescriptions });
|
|
1179
1181
|
return json ? JSON.stringify(result) : JSON.stringify(result, null, 2);
|
|
1180
1182
|
}
|
|
1181
1183
|
case "defer": {
|
package/src/constants.ts
CHANGED
|
@@ -7,7 +7,7 @@ export const DAEMON_PROBE_TIMEOUT_MS = 800;
|
|
|
7
7
|
export const DAEMON_UNIT_NAME = "papyrus.service";
|
|
8
8
|
export const DAEMON_DIR_ENV = "PAPYRUS_DAEMON_DIR";
|
|
9
9
|
export const SQLITE_BUSY_TIMEOUT_MS = 5_000;
|
|
10
|
-
export const SQLITE_SCHEMA_VERSION =
|
|
10
|
+
export const SQLITE_SCHEMA_VERSION = 19;
|
|
11
11
|
export const SERVICE_MAX_BODY_BYTES = 1_048_576;
|
|
12
12
|
|
|
13
13
|
export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
|
|
@@ -175,6 +175,12 @@ export const DISCUSSION_ACTOR_MAX_LENGTH = 128;
|
|
|
175
175
|
export const DISCUSSION_OPTIONS_MIN_COUNT = 2;
|
|
176
176
|
export const DISCUSSION_OPTIONS_MAX_COUNT = 10;
|
|
177
177
|
export const DISCUSSION_OPTION_MAX_LENGTH = 200;
|
|
178
|
+
// Deliberately much shorter than DISCUSSION_ROUND_CONTENT_MAX_CHARACTERS -- a per-option
|
|
179
|
+
// description is meant to be a one-line tradeoff/consequence, not a restatement of the whole
|
|
180
|
+
// question. Long enough for a real pro/con, short enough to force conciseness.
|
|
181
|
+
export const DISCUSSION_OPTION_DESCRIPTION_MAX_LENGTH = 240;
|
|
182
|
+
// Below this, a binary yes/no choice is often self-evident and a description would just pad it.
|
|
183
|
+
export const DISCUSSION_OPTION_DESCRIPTION_REQUIRED_FROM_COUNT = 3;
|
|
178
184
|
/** Bounds for the generic graph projection protocol (external bounded contexts). */
|
|
179
185
|
export const GRAPH_PROJECTION_MAX_ARTIFACTS_PER_BATCH = 500;
|
|
180
186
|
export const GRAPH_PROJECTION_MAX_EDGES_PER_BATCH = 1_000;
|
package/src/db.ts
CHANGED
|
@@ -238,6 +238,7 @@ CREATE TABLE IF NOT EXISTS discussion_rounds (
|
|
|
238
238
|
options TEXT,
|
|
239
239
|
options_mode TEXT,
|
|
240
240
|
selected TEXT,
|
|
241
|
+
option_descriptions TEXT,
|
|
241
242
|
UNIQUE (discussion_id, round_number)
|
|
242
243
|
);
|
|
243
244
|
CREATE INDEX IF NOT EXISTS discussion_rounds_discussion_idx ON discussion_rounds(discussion_id, round_number, id);
|
|
@@ -524,6 +525,17 @@ const FUTURE_MIGRATIONS: ReadonlyArray<PapyrusMigration> = [
|
|
|
524
525
|
`);
|
|
525
526
|
},
|
|
526
527
|
},
|
|
528
|
+
{
|
|
529
|
+
version: 19,
|
|
530
|
+
name: "discuss-option-descriptions",
|
|
531
|
+
// See domain/discussion.ts's pendingOptionDescriptions. Nullable, purely additive column, same
|
|
532
|
+
// pattern as version 16's discuss-options -- a round with no per-option description (every
|
|
533
|
+
// round before this feature existed, and any that simply doesn't need one) stores NULL.
|
|
534
|
+
up: (db) => {
|
|
535
|
+
const existing = new Set((db.prepare("PRAGMA table_info(discussion_rounds)").all() as Array<{ name: string }>).map((row) => row.name));
|
|
536
|
+
if (!existing.has("option_descriptions")) db.exec("ALTER TABLE discussion_rounds ADD COLUMN option_descriptions TEXT");
|
|
537
|
+
},
|
|
538
|
+
},
|
|
527
539
|
];
|
|
528
540
|
|
|
529
541
|
/**
|
|
@@ -35,6 +35,8 @@ export interface OpenDiscussionInput {
|
|
|
35
35
|
/** Poses a choice on round 1 -- both or neither; see domain/discussion.ts's DiscussionOptionsMode. */
|
|
36
36
|
options?: string[];
|
|
37
37
|
optionsMode?: DiscussionOptionsMode;
|
|
38
|
+
/** Index-aligned with options -- see domain/discussion.ts's pendingOptionDescriptions. */
|
|
39
|
+
optionDescriptions?: string[];
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
export interface ReplyInput {
|
|
@@ -45,6 +47,7 @@ export interface ReplyInput {
|
|
|
45
47
|
/** Poses a new choice on this same round, replacing whatever was previously pending. */
|
|
46
48
|
options?: string[];
|
|
47
49
|
optionsMode?: DiscussionOptionsMode;
|
|
50
|
+
optionDescriptions?: string[];
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
export interface DiscussionAndRounds {
|
|
@@ -69,15 +72,15 @@ export class Discussions {
|
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
/** Validates a freshly-posed choice; undefined when neither field is given (nothing posed), since both/neither is the only valid shape. */
|
|
72
|
-
private validatePosedOptions(options: string[] | undefined, optionsMode: DiscussionOptionsMode | undefined): { options: string[]; mode: DiscussionOptionsMode } | undefined {
|
|
75
|
+
private validatePosedOptions(options: string[] | undefined, optionsMode: DiscussionOptionsMode | undefined, optionDescriptions: string[] | undefined): { options: string[]; mode: DiscussionOptionsMode; optionDescriptions?: string[] } | undefined {
|
|
73
76
|
if (options === undefined && optionsMode === undefined) return undefined;
|
|
74
|
-
return validateDiscussionOptions(options ?? [], optionsMode ?? "");
|
|
77
|
+
return validateDiscussionOptions(options ?? [], optionsMode ?? "", optionDescriptions);
|
|
75
78
|
}
|
|
76
79
|
|
|
77
80
|
open(input: OpenDiscussionInput, context?: ArtifactEventContext): DiscussionAndRounds {
|
|
78
81
|
const actor = validateDiscussionActor(input.actor);
|
|
79
82
|
const content = validateDiscussionContent(input.content);
|
|
80
|
-
const posed = this.validatePosedOptions(input.options, input.optionsMode);
|
|
83
|
+
const posed = this.validatePosedOptions(input.options, input.optionsMode, input.optionDescriptions);
|
|
81
84
|
return this.artifacts.atomic(() => {
|
|
82
85
|
const discussion = this.artifacts.create({
|
|
83
86
|
kind: "task",
|
|
@@ -90,13 +93,13 @@ export class Discussions {
|
|
|
90
93
|
discussion: {
|
|
91
94
|
state: "active",
|
|
92
95
|
roundCount: 1,
|
|
93
|
-
...(posed ? { pendingOptions: posed.options, pendingOptionsMode: posed.mode } : {}),
|
|
96
|
+
...(posed ? { pendingOptions: posed.options, pendingOptionsMode: posed.mode, ...(posed.optionDescriptions ? { pendingOptionDescriptions: posed.optionDescriptions } : {}) } : {}),
|
|
94
97
|
},
|
|
95
98
|
},
|
|
96
99
|
}, context);
|
|
97
100
|
const round = this.rounds.append({
|
|
98
101
|
discussionId: discussion.id, roundNumber: 1, actor, content,
|
|
99
|
-
...(posed ? { options: posed.options, optionsMode: posed.mode } : {}),
|
|
102
|
+
...(posed ? { options: posed.options, optionsMode: posed.mode, ...(posed.optionDescriptions ? { optionDescriptions: posed.optionDescriptions } : {}) } : {}),
|
|
100
103
|
}, new Date().toISOString());
|
|
101
104
|
for (const taskId of input.blocksTaskIds ?? []) this.block(discussion.id, taskId, context);
|
|
102
105
|
return { discussion: this.artifacts.get(discussion.id)!, rounds: [round] };
|
|
@@ -106,7 +109,7 @@ export class Discussions {
|
|
|
106
109
|
reply(discussionId: string, input: ReplyInput, context?: ArtifactEventContext): DiscussionAndRounds {
|
|
107
110
|
const validActor = validateDiscussionActor(input.actor);
|
|
108
111
|
const validContent = validateDiscussionContent(input.content);
|
|
109
|
-
const posed = this.validatePosedOptions(input.options, input.optionsMode);
|
|
112
|
+
const posed = this.validatePosedOptions(input.options, input.optionsMode, input.optionDescriptions);
|
|
110
113
|
return this.artifacts.atomic(() => {
|
|
111
114
|
const discussion = requireDiscussion(this.artifacts.get(discussionId), discussionId);
|
|
112
115
|
const state = this.extra(discussion);
|
|
@@ -116,14 +119,19 @@ export class Discussions {
|
|
|
116
119
|
const nextRound = state.roundCount + 1;
|
|
117
120
|
const round = this.rounds.append({
|
|
118
121
|
discussionId, roundNumber: nextRound, actor: validActor, content: validContent,
|
|
119
|
-
...(posed ? { options: posed.options, optionsMode: posed.mode } : {}),
|
|
122
|
+
...(posed ? { options: posed.options, optionsMode: posed.mode, ...(posed.optionDescriptions ? { optionDescriptions: posed.optionDescriptions } : {}) } : {}),
|
|
120
123
|
...(selected ? { selected } : {}),
|
|
121
124
|
}, new Date().toISOString());
|
|
122
|
-
|
|
125
|
+
// Whenever this round answers the pending choice OR poses a new one, the base must drop ALL
|
|
126
|
+
// three pending* fields first -- otherwise a re-pose that omits descriptions this time would
|
|
127
|
+
// leave a stale pendingOptionDescriptions array (sized for the OLD options) spread through
|
|
128
|
+
// unchanged, no longer aligned 1:1 with the new pendingOptions. Only a plain reply that
|
|
129
|
+
// neither answers nor re-poses leaves the existing pending state untouched.
|
|
130
|
+
const { pendingOptions: _clearedOptions, pendingOptionsMode: _clearedMode, pendingOptionDescriptions: _clearedDescriptions, ...withoutPending } = state;
|
|
123
131
|
const nextState = {
|
|
124
|
-
...(selected ?
|
|
132
|
+
...(selected || posed ? withoutPending : state),
|
|
125
133
|
roundCount: nextRound,
|
|
126
|
-
...(posed ? { pendingOptions: posed.options, pendingOptionsMode: posed.mode } : {}),
|
|
134
|
+
...(posed ? { pendingOptions: posed.options, pendingOptionsMode: posed.mode, ...(posed.optionDescriptions ? { pendingOptionDescriptions: posed.optionDescriptions } : {}) } : {}),
|
|
127
135
|
};
|
|
128
136
|
const updated = this.artifacts.setExtra(discussionId, { ...discussion.extra, discussion: nextState }, context)!;
|
|
129
137
|
return { discussion: updated, rounds: [round] };
|
package/src/domain/discussion.ts
CHANGED
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
import {
|
|
21
21
|
DISCUSSION_ACTOR_MAX_LENGTH,
|
|
22
22
|
DISCUSSION_DEFER_REASON_MAX_CHARACTERS,
|
|
23
|
+
DISCUSSION_OPTION_DESCRIPTION_MAX_LENGTH,
|
|
24
|
+
DISCUSSION_OPTION_DESCRIPTION_REQUIRED_FROM_COUNT,
|
|
23
25
|
DISCUSSION_OPTION_MAX_LENGTH,
|
|
24
26
|
DISCUSSION_OPTIONS_MAX_COUNT,
|
|
25
27
|
DISCUSSION_OPTIONS_MIN_COUNT,
|
|
@@ -49,6 +51,10 @@ export interface DiscussionExtra {
|
|
|
49
51
|
settledAt?: string;
|
|
50
52
|
pendingOptions?: string[];
|
|
51
53
|
pendingOptionsMode?: DiscussionOptionsMode;
|
|
54
|
+
/** Index-aligned with pendingOptions when present -- one entry per option, empty string meaning
|
|
55
|
+
* "no description for this one". Purely descriptive metadata: selection/validation only ever
|
|
56
|
+
* matches against pendingOptions itself, never against this array. */
|
|
57
|
+
pendingOptionDescriptions?: string[];
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
/** One append-only round of a Discussion -- opening statement is round 1. options/optionsMode/selected are the historical record of what was posed/picked in this specific round (extra.discussion.pendingOptions is the separate, mutable "what's unanswered right now" cache). */
|
|
@@ -61,6 +67,8 @@ export interface DiscussionRound {
|
|
|
61
67
|
occurredAt: string;
|
|
62
68
|
options?: string[];
|
|
63
69
|
optionsMode?: DiscussionOptionsMode;
|
|
70
|
+
/** Index-aligned with options -- see DiscussionExtra.pendingOptionDescriptions. */
|
|
71
|
+
optionDescriptions?: string[];
|
|
64
72
|
selected?: string[];
|
|
65
73
|
}
|
|
66
74
|
|
|
@@ -71,6 +79,7 @@ export interface AppendDiscussionRound {
|
|
|
71
79
|
content: string;
|
|
72
80
|
options?: string[];
|
|
73
81
|
optionsMode?: DiscussionOptionsMode;
|
|
82
|
+
optionDescriptions?: string[];
|
|
74
83
|
selected?: string[];
|
|
75
84
|
}
|
|
76
85
|
|
|
@@ -101,8 +110,10 @@ export function validateSettlement(settlement: string): string {
|
|
|
101
110
|
return boundedString(settlement, "settlement", DISCUSSION_SETTLEMENT_MAX_CHARACTERS);
|
|
102
111
|
}
|
|
103
112
|
|
|
104
|
-
/** Validates a freshly-posed choice: 2..DISCUSSION_OPTIONS_MAX_COUNT unique, bounded-length
|
|
105
|
-
|
|
113
|
+
/** Validates a freshly-posed choice: 2..DISCUSSION_OPTIONS_MAX_COUNT unique, bounded-length
|
|
114
|
+
* options, a real mode, and a non-empty description for every option once
|
|
115
|
+
* DISCUSSION_OPTION_DESCRIPTION_REQUIRED_FROM_COUNT or more are posed. */
|
|
116
|
+
export function validateDiscussionOptions(options: string[], mode: string, optionDescriptions?: string[]): { options: string[]; mode: DiscussionOptionsMode; optionDescriptions?: string[] } {
|
|
106
117
|
if (!(DISCUSSION_OPTIONS_MODES as readonly string[]).includes(mode)) {
|
|
107
118
|
throw new Error(`options_mode must be one of ${DISCUSSION_OPTIONS_MODES.join(", ")}`);
|
|
108
119
|
}
|
|
@@ -111,7 +122,17 @@ export function validateDiscussionOptions(options: string[], mode: string): { op
|
|
|
111
122
|
}
|
|
112
123
|
for (const option of options) boundedString(option, "option", DISCUSSION_OPTION_MAX_LENGTH);
|
|
113
124
|
if (new Set(options).size !== options.length) throw new Error("options must not repeat an entry");
|
|
114
|
-
|
|
125
|
+
const descriptionsRequired = options.length >= DISCUSSION_OPTION_DESCRIPTION_REQUIRED_FROM_COUNT;
|
|
126
|
+
if (descriptionsRequired && (optionDescriptions === undefined || optionDescriptions.some((description) => description.trim().length === 0))) {
|
|
127
|
+
throw new Error(`option_descriptions is required, with a non-empty entry for every option, once ${DISCUSSION_OPTION_DESCRIPTION_REQUIRED_FROM_COUNT} or more options are posed`);
|
|
128
|
+
}
|
|
129
|
+
if (optionDescriptions !== undefined) {
|
|
130
|
+
if (optionDescriptions.length !== options.length) throw new Error("option_descriptions must have exactly one entry per option (use an empty string for none)");
|
|
131
|
+
for (const description of optionDescriptions) {
|
|
132
|
+
if (description.length > DISCUSSION_OPTION_DESCRIPTION_MAX_LENGTH) throw new Error(`option description must be at most ${DISCUSSION_OPTION_DESCRIPTION_MAX_LENGTH} characters`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { options: [...options], mode: mode as DiscussionOptionsMode, ...(optionDescriptions !== undefined ? { optionDescriptions: [...optionDescriptions] } : {}) };
|
|
115
136
|
}
|
|
116
137
|
|
|
117
138
|
/** Validates an answer against the Discussion's currently pending posed choice, if any. */
|
|
@@ -153,6 +174,15 @@ export function readDiscussionExtra(extra: Record<string, unknown>): DiscussionE
|
|
|
153
174
|
if (pendingOptionsMode !== undefined && !(DISCUSSION_OPTIONS_MODES as readonly unknown[]).includes(pendingOptionsMode)) {
|
|
154
175
|
throw new Error("invalid Discussion pendingOptionsMode");
|
|
155
176
|
}
|
|
177
|
+
const pendingOptionDescriptions = record["pendingOptionDescriptions"];
|
|
178
|
+
if (pendingOptionDescriptions !== undefined) {
|
|
179
|
+
if (!Array.isArray(pendingOptionDescriptions) || pendingOptionDescriptions.some((entry) => typeof entry !== "string")) {
|
|
180
|
+
throw new Error("invalid Discussion pendingOptionDescriptions");
|
|
181
|
+
}
|
|
182
|
+
if (!Array.isArray(pendingOptions) || pendingOptionDescriptions.length !== pendingOptions.length) {
|
|
183
|
+
throw new Error("invalid Discussion pendingOptionDescriptions: must align 1:1 with pendingOptions");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
156
186
|
return {
|
|
157
187
|
state: state as DiscussionState,
|
|
158
188
|
roundCount,
|
|
@@ -161,5 +191,6 @@ export function readDiscussionExtra(extra: Record<string, unknown>): DiscussionE
|
|
|
161
191
|
...(typeof record["settledAt"] === "string" ? { settledAt: record["settledAt"] } : {}),
|
|
162
192
|
...(pendingOptions !== undefined ? { pendingOptions: pendingOptions as string[] } : {}),
|
|
163
193
|
...(pendingOptionsMode !== undefined ? { pendingOptionsMode: pendingOptionsMode as DiscussionOptionsMode } : {}),
|
|
194
|
+
...(pendingOptionDescriptions !== undefined ? { pendingOptionDescriptions: pendingOptionDescriptions as string[] } : {}),
|
|
164
195
|
};
|
|
165
196
|
}
|
package/src/modules/discuss.ts
CHANGED
|
@@ -77,6 +77,7 @@ export function discussOperations(discussions: Discussions): OperationDefinition
|
|
|
77
77
|
blocksTaskIds: optionalStringArray(input, "blocks_task_ids") ?? optionalStringArray(input, "blocksTaskIds"),
|
|
78
78
|
options: optionalStringArray(input, "options"),
|
|
79
79
|
optionsMode: optionsMode(input),
|
|
80
|
+
optionDescriptions: optionalStringArray(input, "option_descriptions") ?? optionalStringArray(input, "optionDescriptions"),
|
|
80
81
|
}, eventContext(input))),
|
|
81
82
|
define("discuss.reply", (input: OperationInput) => discussions.reply(string(input, "id"), {
|
|
82
83
|
actor: string(input, "actor"),
|
|
@@ -84,6 +85,7 @@ export function discussOperations(discussions: Discussions): OperationDefinition
|
|
|
84
85
|
selected: optionalStringArray(input, "selected"),
|
|
85
86
|
options: optionalStringArray(input, "options"),
|
|
86
87
|
optionsMode: optionsMode(input),
|
|
88
|
+
optionDescriptions: optionalStringArray(input, "option_descriptions") ?? optionalStringArray(input, "optionDescriptions"),
|
|
87
89
|
}, eventContext(input))),
|
|
88
90
|
define("discuss.defer", (input: OperationInput) => discussions.defer(string(input, "id"), optionalString(input, "reason"), eventContext(input))),
|
|
89
91
|
define("discuss.resume", (input: OperationInput) => discussions.resume(string(input, "id"), eventContext(input))),
|