@danypops/papyrus 0.27.14 → 0.28.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.
@@ -56,6 +56,9 @@ export type AskDisplayMode = "overlay" | "inline";
56
56
  export interface AskQuestionParams {
57
57
  question: string;
58
58
  context?: string;
59
+ /** Plain orientation line ("which discussion is this"), shown dim above the question -- not a
60
+ * labeled section like context. Typically the Discussion's own title. */
61
+ subtitle?: string;
59
62
  options?: AskOption[];
60
63
  allowMultiple?: boolean;
61
64
  allowFreeform?: boolean;
@@ -621,6 +624,7 @@ class AskComponent extends Container {
621
624
  constructor(
622
625
  private question: string,
623
626
  private context: string | undefined,
627
+ private subtitle: string | undefined,
624
628
  private options: AskOption[],
625
629
  private allowMultiple: boolean,
626
630
  private allowFreeform: boolean,
@@ -781,6 +785,9 @@ class AskComponent extends Container {
781
785
  "",
782
786
  ];
783
787
  }
788
+ // Only meaningful when reached by escaping OUT of a real select list -- see showFreeformMode's
789
+ // identical guard for the non-overlay layout.
790
+ if (this.options.length === 0) return [];
784
791
  return [...new Text(this.theme.fg("accent", this.theme.bold("Custom answer")), 1, 0).render(width), ""];
785
792
  }
786
793
 
@@ -864,7 +871,11 @@ class AskComponent extends Container {
864
871
 
865
872
  private updateStaticText(): void {
866
873
  const theme = this.theme;
867
- this.titleText.setText(theme.fg("accent", theme.bold(this.mode === "comment" ? "Optional comment" : "Question")));
874
+ // Reuses the same slot for two different purposes: a plain "which discussion is this" subtitle
875
+ // normally, or "Optional comment" while in comment mode. A generic "Question" header above the
876
+ // real question text added nothing beyond what the question itself already says, and read
877
+ // confusingly like the question text WAS the header.
878
+ this.titleText.setText(this.mode === "comment" ? theme.fg("accent", theme.bold("Optional comment")) : this.subtitle ? theme.fg("dim", this.subtitle) : "");
868
879
  this.questionText.setText(theme.fg("text", theme.bold(this.question)));
869
880
  if (this.contextComponent && this.context) {
870
881
  if (this.contextComponent instanceof Markdown) (this.contextComponent as Markdown).setText(`**Context:**\n${this.context}`);
@@ -985,8 +996,13 @@ class AskComponent extends Container {
985
996
  const editor = this.ensureEditor();
986
997
  this.setEditorText(this.freeformDraft);
987
998
  (editor as any).focused = this._focused;
988
- this.modeContainer.addChild(new Text(this.theme.fg("accent", this.theme.bold("Custom answer")), 1, 0));
989
- this.modeContainer.addChild(new Spacer(1));
999
+ // Only meaningful when reached by escaping OUT of a real select list ("instead of these
1000
+ // options, here's a custom one") -- with no options at all there's nothing to contrast
1001
+ // against, so the label is pure noise.
1002
+ if (this.options.length > 0) {
1003
+ this.modeContainer.addChild(new Text(this.theme.fg("accent", this.theme.bold("Custom answer")), 1, 0));
1004
+ this.modeContainer.addChild(new Spacer(1));
1005
+ }
990
1006
  this.modeContainer.addChild(editor);
991
1007
  this.updateHelpText();
992
1008
  this.invalidate();
@@ -1165,7 +1181,7 @@ async function askQuestionBlocking(
1165
1181
  const factory = (tui: TUI, theme: Theme, keybindings: KeybindingsManager, done: (result: AskResponse | null) => void) => {
1166
1182
  if (params.signal) params.signal.addEventListener("abort", () => done(null), { once: true });
1167
1183
  if (params.timeout && params.timeout > 0) setTimeout(() => done(null), params.timeout);
1168
- return new AskComponent(params.question, normalizedContext, options, allowMultiple, allowFreeform, allowComment, displayMode, tui, theme, keybindings, shortcuts, done);
1184
+ return new AskComponent(params.question, normalizedContext, params.subtitle, options, allowMultiple, allowFreeform, allowComment, displayMode, tui, theme, keybindings, shortcuts, done);
1169
1185
  };
1170
1186
 
1171
1187
  const overlayToggle = shortcuts.overlayToggle;
@@ -82,14 +82,15 @@ export async function showDiscussions(ctx: ExtensionCommandContext): Promise<voi
82
82
  }
83
83
  if (choice === "Reply") {
84
84
  const pending = (() => { try { return readDiscussionExtra(discussion.extra); } catch { return undefined; } })();
85
- const question = `Reply to "${discussion.title}":`;
86
- // Same fix as the live discuss tool: the title alone isn't the actual question -- show
87
- // the most recent round's real content as context, not a bare title prompt.
85
+ // Same fix as the live discuss tool: the most recent round's own content IS the real
86
+ // question -- the title becomes a plain orientation subtitle, not a labeled-backwards
87
+ // "Context:" section under a generic "Reply to <title>:" wrapper.
88
88
  const transcript = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.show", { id: discussion.id });
89
- const context = transcript.rounds.at(-1)?.content?.trim() || undefined;
89
+ const question = transcript.rounds.at(-1)?.content?.trim() || `Reply to "${discussion.title}":`;
90
+ const subtitle = discussion.title;
90
91
  const answer = pending?.pendingOptions && pending.pendingOptions.length > 0 && pending.pendingOptionsMode
91
- ? await askQuestion(commandCtx, { question, context, options: pending.pendingOptions.map((title) => ({ title })), allowMultiple: pending.pendingOptionsMode === "multi" })
92
- : await askQuestion(commandCtx, { question, context });
92
+ ? await askQuestion(commandCtx, { question, subtitle, options: pending.pendingOptions.map((title, index) => ({ title, description: pending.pendingOptionDescriptions?.[index] || undefined })), allowMultiple: pending.pendingOptionsMode === "multi" })
93
+ : await askQuestion(commandCtx, { question, subtitle });
93
94
  if (!answer) return; // canceled
94
95
  await callService("discuss.reply", { id: discussion.id, actor: ACTOR, content: answer.content, ...(answer.selected ? { selected: answer.selected } : {}), source: SOURCE });
95
96
  commandCtx.ui.notify(answer.selected ? `Selected: ${answer.selected.join(", ")}` : "Reply added.", "info");
@@ -39,25 +39,55 @@ 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; } })();
45
- const question = `Reply to "${discussion.title}":`;
46
- // The discussion's title alone is often not the actual question -- a human staring at a bare
47
- // "Reply to '<title>':" prompt with no visible content has no way to tell what's being asked.
48
- // The just-recorded round's own content is the real question text; show it as context.
49
- const context = latestContent?.trim() || undefined;
74
+ // The just-recorded round's own content IS the real question -- a generic "Reply to <title>:"
75
+ // wrapper as the primary question, with the real content demoted to "Context:", left a human
76
+ // staring at a labeled-backwards prompt (live-observed). The wrapper is now only a fallback for
77
+ // the degenerate case of empty content; the title becomes a plain orientation subtitle instead.
78
+ const question = latestContent?.trim() || `Reply to "${discussion.title}":`;
79
+ const subtitle = discussion.title;
50
80
  if (pending?.pendingOptions && pending.pendingOptions.length > 0 && pending.pendingOptionsMode) {
51
81
  return askQuestion(ctx, {
52
82
  question,
53
- context,
54
- options: pending.pendingOptions.map((title) => ({ title })),
83
+ subtitle,
84
+ options: pending.pendingOptions.map((title, index) => ({ title, description: pending.pendingOptionDescriptions?.[index] || undefined })),
55
85
  allowMultiple: pending.pendingOptionsMode === "multi",
56
86
  onUpdate,
57
87
  signal,
58
88
  });
59
89
  }
60
- return askQuestion(ctx, { question, context, onUpdate, signal });
90
+ return askQuestion(ctx, { question, subtitle, onUpdate, signal });
61
91
  }
62
92
 
63
93
  /**
@@ -680,7 +710,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
680
710
  pi.registerTool({
681
711
  name: "discuss",
682
712
  label: "Discuss",
683
- 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 (fine for a self-evident choice like yes/no) or {title, description} -- add a description only when there's a genuine tradeoff, risk, or consequence worth conveying (a real pro/con), keep it to one line, and never pad it with something the title already says; skip descriptions entirely when the options don't need them. 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.",
684
714
  parameters: Type.Object({
685
715
  action: Type.String(),
686
716
  id: Type.Optional(Type.String()),
@@ -699,7 +729,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
699
729
  state: Type.Optional(Type.String()),
700
730
  after_round: Type.Optional(Type.Number()),
701
731
  limit: Type.Optional(Type.Number()),
702
- 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()) })]))),
703
733
  options_mode: Type.Optional(Type.String()),
704
734
  selected: Type.Optional(Type.Array(Type.String())),
705
735
  live: Type.Optional(Type.Boolean()),
@@ -721,6 +751,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
721
751
  ]);
722
752
  await resolveNameArrayField(params, "blocks_task_names", "blocks_task_ids", "tasks.list", taskScope);
723
753
  if (action === "open" || action === "reply") {
754
+ normalizeDiscussOptions(params);
724
755
  const operation = action === "open" ? "discuss.open" : "discuss.reply";
725
756
  const result = await callService<Record<string, unknown>, DiscussionAndRounds>(operation, params);
726
757
  const fallback = action === "open"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.27.14",
3
+ "version": "0.28.0",
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,10 @@ 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;
178
182
  /** Bounds for the generic graph projection protocol (external bounded contexts). */
179
183
  export const GRAPH_PROJECTION_MAX_ARTIFACTS_PER_BATCH = 500;
180
184
  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,7 @@
20
20
  import {
21
21
  DISCUSSION_ACTOR_MAX_LENGTH,
22
22
  DISCUSSION_DEFER_REASON_MAX_CHARACTERS,
23
+ DISCUSSION_OPTION_DESCRIPTION_MAX_LENGTH,
23
24
  DISCUSSION_OPTION_MAX_LENGTH,
24
25
  DISCUSSION_OPTIONS_MAX_COUNT,
25
26
  DISCUSSION_OPTIONS_MIN_COUNT,
@@ -49,6 +50,10 @@ export interface DiscussionExtra {
49
50
  settledAt?: string;
50
51
  pendingOptions?: string[];
51
52
  pendingOptionsMode?: DiscussionOptionsMode;
53
+ /** Index-aligned with pendingOptions when present -- one entry per option, empty string meaning
54
+ * "no description for this one". Purely descriptive metadata: selection/validation only ever
55
+ * matches against pendingOptions itself, never against this array. */
56
+ pendingOptionDescriptions?: string[];
52
57
  }
53
58
 
54
59
  /** 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 +66,8 @@ export interface DiscussionRound {
61
66
  occurredAt: string;
62
67
  options?: string[];
63
68
  optionsMode?: DiscussionOptionsMode;
69
+ /** Index-aligned with options -- see DiscussionExtra.pendingOptionDescriptions. */
70
+ optionDescriptions?: string[];
64
71
  selected?: string[];
65
72
  }
66
73
 
@@ -71,6 +78,7 @@ export interface AppendDiscussionRound {
71
78
  content: string;
72
79
  options?: string[];
73
80
  optionsMode?: DiscussionOptionsMode;
81
+ optionDescriptions?: string[];
74
82
  selected?: string[];
75
83
  }
76
84
 
@@ -101,8 +109,8 @@ export function validateSettlement(settlement: string): string {
101
109
  return boundedString(settlement, "settlement", DISCUSSION_SETTLEMENT_MAX_CHARACTERS);
102
110
  }
103
111
 
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 } {
112
+ /** Validates a freshly-posed choice: 2..DISCUSSION_OPTIONS_MAX_COUNT unique, bounded-length options, a real mode, and -- if given -- one description per option (empty string means "none for this one"). */
113
+ export function validateDiscussionOptions(options: string[], mode: string, optionDescriptions?: string[]): { options: string[]; mode: DiscussionOptionsMode; optionDescriptions?: string[] } {
106
114
  if (!(DISCUSSION_OPTIONS_MODES as readonly string[]).includes(mode)) {
107
115
  throw new Error(`options_mode must be one of ${DISCUSSION_OPTIONS_MODES.join(", ")}`);
108
116
  }
@@ -111,7 +119,13 @@ export function validateDiscussionOptions(options: string[], mode: string): { op
111
119
  }
112
120
  for (const option of options) boundedString(option, "option", DISCUSSION_OPTION_MAX_LENGTH);
113
121
  if (new Set(options).size !== options.length) throw new Error("options must not repeat an entry");
114
- return { options: [...options], mode: mode as DiscussionOptionsMode };
122
+ if (optionDescriptions !== undefined) {
123
+ if (optionDescriptions.length !== options.length) throw new Error("option_descriptions must have exactly one entry per option (use an empty string for none)");
124
+ for (const description of optionDescriptions) {
125
+ if (description.length > DISCUSSION_OPTION_DESCRIPTION_MAX_LENGTH) throw new Error(`option description must be at most ${DISCUSSION_OPTION_DESCRIPTION_MAX_LENGTH} characters`);
126
+ }
127
+ }
128
+ return { options: [...options], mode: mode as DiscussionOptionsMode, ...(optionDescriptions !== undefined ? { optionDescriptions: [...optionDescriptions] } : {}) };
115
129
  }
116
130
 
117
131
  /** Validates an answer against the Discussion's currently pending posed choice, if any. */
@@ -153,6 +167,15 @@ export function readDiscussionExtra(extra: Record<string, unknown>): DiscussionE
153
167
  if (pendingOptionsMode !== undefined && !(DISCUSSION_OPTIONS_MODES as readonly unknown[]).includes(pendingOptionsMode)) {
154
168
  throw new Error("invalid Discussion pendingOptionsMode");
155
169
  }
170
+ const pendingOptionDescriptions = record["pendingOptionDescriptions"];
171
+ if (pendingOptionDescriptions !== undefined) {
172
+ if (!Array.isArray(pendingOptionDescriptions) || pendingOptionDescriptions.some((entry) => typeof entry !== "string")) {
173
+ throw new Error("invalid Discussion pendingOptionDescriptions");
174
+ }
175
+ if (!Array.isArray(pendingOptions) || pendingOptionDescriptions.length !== pendingOptions.length) {
176
+ throw new Error("invalid Discussion pendingOptionDescriptions: must align 1:1 with pendingOptions");
177
+ }
178
+ }
156
179
  return {
157
180
  state: state as DiscussionState,
158
181
  roundCount,
@@ -161,5 +184,6 @@ export function readDiscussionExtra(extra: Record<string, unknown>): DiscussionE
161
184
  ...(typeof record["settledAt"] === "string" ? { settledAt: record["settledAt"] } : {}),
162
185
  ...(pendingOptions !== undefined ? { pendingOptions: pendingOptions as string[] } : {}),
163
186
  ...(pendingOptionsMode !== undefined ? { pendingOptionsMode: pendingOptionsMode as DiscussionOptionsMode } : {}),
187
+ ...(pendingOptionDescriptions !== undefined ? { pendingOptionDescriptions: pendingOptionDescriptions as string[] } : {}),
164
188
  };
165
189
  }
@@ -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))),