@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.27.15",
3
+ "version": "0.28.1",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],
@@ -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 = 18;
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
- const { pendingOptions: _clearedOptions, pendingOptionsMode: _clearedMode, ...answered } = state;
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 ? answered : state),
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] };
@@ -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 options and a real mode. */
105
- export function validateDiscussionOptions(options: string[], mode: string): { options: string[]; mode: DiscussionOptionsMode } {
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
- return { options: [...options], mode: mode as DiscussionOptionsMode };
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
  }
@@ -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))),