@danypops/papyrus 0.19.1 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -162,11 +162,15 @@ Rounds are a dedicated append-only child table (mirroring Task history's own sha
162
162
 
163
163
  Blocking is real: `tasks.complete` is refused while any `active` Discussion has a `blocks` edge to that Task. A `deferred` Discussion does not block -- "we will get back to this" is distinct from "resolved."
164
164
 
165
- Run `/discuss` for the interactive panel: browse every Discussion (the real `active`/`deferred`/`settled` state shown per row, not just the shared Doc status glyph), open a scrollable transcript, and reply/defer/resume/settle or block/unblock a task without leaving the TUI. Opening a *new* Discussion is left to the agent (same as Docs/Rules/Skills) -- `/discuss` browses and drives existing ones.
165
+ `open`/`reply` can also pose a structured choice instead of (or alongside) free text: `options` (2-10 entries) plus `options_mode` -- `single` is mutually exclusive (exactly one pick), `multi` allows several. The Discussion remembers the pending choice (`extra.discussion.pendingOptions`/`pendingOptionsMode`) until a `reply` answers it with `selected`, validated against exactly what was offered and the mode's cardinality; a reply can also pose the *next* round's choice in the same call.
166
+
167
+ Run `/discuss` for the interactive panel: browse every Discussion (the real `active`/`deferred`/`settled` state shown per row, alongside any choice awaiting an answer), open a scrollable transcript showing what was posed and picked in each round, and reply/defer/resume/settle or block/unblock a task without leaving the TUI. Replying to a pending choice shows a real picker -- the native single-select list for `single`, or a checkbox multi-select (space to toggle, enter to confirm) for `multi`, since no built-in multi-select exists in the Pi extension UI. Opening a *new* Discussion is left to the agent (same as Docs/Rules/Skills) -- `/discuss` browses and drives existing ones.
166
168
 
167
169
  ```bash
168
170
  papyrus discuss open --title "Naming" --actor alice --content "Should we rename this?" --blocks-json '["task-id"]' --json
171
+ papyrus discuss open --title "Which approach" --actor alice --content "Pick one" --options-json '["A","B"]' --options-mode single --json
169
172
  papyrus discuss reply <discussion-id> --actor bob --content "I think so, here's why..." --json
173
+ papyrus discuss reply <discussion-id> --actor bob --content "Going with B" --selected-json '["B"]' --json
170
174
  papyrus discuss defer <discussion-id> --reason "Waiting on design review" --json
171
175
  papyrus discuss resume <discussion-id> --json
172
176
  papyrus discuss settle <discussion-id> --settlement "Agreed: renaming to X" --json
@@ -175,6 +179,8 @@ papyrus discuss show <discussion-id> --json
175
179
 
176
180
  ## Tasks
177
181
 
182
+ The `tasks` agent tool addresses a task by `name` (its exact title) wherever `id` would otherwise be required -- `dependency_name`/`parent_name`/`child_name`/`root_task_name`/`depends_on_names` are the name-based equivalents of `dependency_id`/`parent_id`/`child_id`/`root_task_id`/`depends_on`. Resolution is an exact, case-insensitive, trimmed title match scoped like a plain list call; an unmatched or ambiguous name fails with a clear error (ambiguous names list the real ids, since that's the one point disambiguation genuinely needs them). Task results returned to the agent likewise lead with name and status, never id, unless two tasks in the same result share a title -- id is a backend implementation detail, not a conversational handle. `id` itself still works exactly as before for every action.
183
+
178
184
  Run `/tasks` for the interactive task panel:
179
185
 
180
186
  - `/` filters; arrow keys navigate; Enter opens task actions; `s` switches among the persisted current-project, focused-root graph, and explicit all-projects views
@@ -15,9 +15,11 @@
15
15
  import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
16
16
  import type { Artifact } from "../../src/domain/artifact.ts";
17
17
  import type { DiscussionAndRounds } from "../../src/discussion-service.ts";
18
+ import { readDiscussionExtra } from "../../src/domain/discussion.ts";
18
19
  import { showArtifactBrowser } from "./artifact-browser.ts";
19
20
  import { DISCUSSION_STATE_PRESENTATION, DOC_STATUS_PRESENTATION } from "./artifact-status-presentation.ts";
20
21
  import { discussionRoundCountOf, discussionStateOf, showDiscussionDetailView } from "./discussion-detail-view.ts";
22
+ import { pickDiscussionOptions } from "./discussion-picker.ts";
21
23
  import { callService } from "./service-client.ts";
22
24
 
23
25
  const SOURCE = "discuss-tui";
@@ -28,7 +30,9 @@ export function discussionRowMeta(discussion: Artifact, theme: Theme): string {
28
30
  const presentation = DISCUSSION_STATE_PRESENTATION[state];
29
31
  const stateText = presentation ? theme.fg(presentation.color, `${presentation.glyph} ${presentation.label}`) : theme.fg("muted", "state unknown");
30
32
  const rounds = discussionRoundCountOf(discussion);
31
- return `${stateText} · ${rounds} round${rounds === 1 ? "" : "s"}`;
33
+ const pending = (() => { try { return readDiscussionExtra(discussion.extra).pendingOptions; } catch { return undefined; } })();
34
+ const pendingText = pending && pending.length > 0 ? theme.fg("accent", ` · awaiting: ${pending.join("/")}`) : "";
35
+ return `${stateText} · ${rounds} round${rounds === 1 ? "" : "s"}${pendingText}`;
32
36
  }
33
37
 
34
38
  function discussionActions(discussion: Artifact): string[] {
@@ -54,6 +58,16 @@ export async function showDiscussions(ctx: ExtensionCommandContext): Promise<voi
54
58
  return;
55
59
  }
56
60
  if (choice === "Reply") {
61
+ const pending = (() => { try { return readDiscussionExtra(discussion.extra); } catch { return undefined; } })();
62
+ if (pending?.pendingOptions && pending.pendingOptions.length > 0 && pending.pendingOptionsMode) {
63
+ const selected = await pickDiscussionOptions(commandCtx, pending.pendingOptionsMode, pending.pendingOptions);
64
+ if (!selected) return; // canceled
65
+ const elaboration = await commandCtx.ui.input("Elaborate (optional):", selected.join(", "));
66
+ if (elaboration === undefined) return; // canceled
67
+ await callService("discuss.reply", { id: discussion.id, actor: ACTOR, content: elaboration || selected.join(", "), selected, source: SOURCE });
68
+ commandCtx.ui.notify(`Selected: ${selected.join(", ")}`, "info");
69
+ return;
70
+ }
57
71
  const content = await commandCtx.ui.input("Reply:", "");
58
72
  if (!content) return;
59
73
  await callService("discuss.reply", { id: discussion.id, actor: ACTOR, content, source: SOURCE });
@@ -112,7 +112,13 @@ class DiscussionTranscriptViewport {
112
112
  const transcript: TranscriptLine[] = this.rounds.flatMap((round, index) => {
113
113
  const roundHeader = theme.fg("accent", `[round ${round.roundNumber}] `) + theme.bold(round.actor) + theme.fg("dim", ` · ${round.occurredAt}`);
114
114
  const body = renderMarkdownBody(round.content, width - 2, this.activeTheme).map((line) => ({ text: ` ${line}` }));
115
- return [{ text: roundHeader }, ...body, ...(index < this.rounds.length - 1 ? [{ text: "" }] : [])];
115
+ const posed = round.options && round.options.length > 0
116
+ ? [{ text: ` ${theme.fg("muted", `Posed (${round.optionsMode === "multi" ? "pick several" : "pick one"}): ${round.options.join(", ")}`)}` }]
117
+ : [];
118
+ const picked = round.selected && round.selected.length > 0
119
+ ? [{ text: ` ${theme.fg("success", `Selected: ${round.selected.join(", ")}`)}` }]
120
+ : [];
121
+ return [{ text: roundHeader }, ...body, ...posed, ...picked, ...(index < this.rounds.length - 1 ? [{ text: "" }] : [])];
116
122
  });
117
123
  this.lines = [...header, ...(transcript.length > 0 ? transcript : [{ text: theme.fg("muted", "No rounds recorded.") }])];
118
124
  this.offsetY = Math.min(this.offsetY, Math.max(0, this.lines.length - this.visibleLines));
@@ -0,0 +1,59 @@
1
+ /**
2
+ * discussion-picker.ts — the structured-choice picker for /discuss's "Reply" action.
3
+ *
4
+ * "single" mode (mutually exclusive) needs nothing bespoke: the Pi extension UI already
5
+ * provides exactly that (ctx.ui.select). "multi" (allow several) has no native equivalent
6
+ * anywhere in @earendil-works/pi-coding-agent or pi-tui (checked both) -- so this is a small,
7
+ * genuinely domain-specific checkbox-list component, not a generic library replacement.
8
+ */
9
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
10
+ import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
11
+ import type { DiscussionOptionsMode } from "../../src/domain/discussion.ts";
12
+
13
+ /** Toggle with space, confirm with enter (refuses an empty confirm -- at least one pick is required), cancel with escape. */
14
+ async function pickMultiple(ctx: ExtensionCommandContext, title: string, options: string[]): Promise<string[] | undefined> {
15
+ return ctx.ui.custom<string[] | undefined>((tui, theme, _keybindings, done) => {
16
+ const checked = new Set<number>();
17
+ let selectedIndex = 0;
18
+ return {
19
+ invalidate() {},
20
+ render(width: number): string[] {
21
+ const lines: string[] = [
22
+ theme.bold(title),
23
+ theme.fg("muted", "space toggle \u00b7 enter confirm \u00b7 esc cancel"),
24
+ "",
25
+ ];
26
+ options.forEach((option, index) => {
27
+ const cursor = index === selectedIndex ? theme.fg("accent", "\u276f") : " ";
28
+ const box = checked.has(index) ? theme.fg("success", "[x]") : "[ ]";
29
+ const label = index === selectedIndex ? theme.bold(option) : option;
30
+ lines.push(truncateToWidth(`${cursor} ${box} ${label}`, width, ""));
31
+ });
32
+ lines.push("");
33
+ lines.push(theme.fg("dim", `${checked.size} selected`));
34
+ return lines;
35
+ },
36
+ handleInput(data: string) {
37
+ if (matchesKey(data, "up")) selectedIndex = (selectedIndex - 1 + options.length) % options.length;
38
+ else if (matchesKey(data, "down")) selectedIndex = (selectedIndex + 1) % options.length;
39
+ else if (data === " ") { if (checked.has(selectedIndex)) checked.delete(selectedIndex); else checked.add(selectedIndex); }
40
+ else if (matchesKey(data, "enter")) {
41
+ if (checked.size === 0) return; // refuse an empty confirm -- selecting nothing isn't a valid answer
42
+ done([...checked].sort((a, b) => a - b).map((index) => options[index]!));
43
+ return;
44
+ } else if (matchesKey(data, "escape")) { done(undefined); return; }
45
+ else return;
46
+ tui.requestRender();
47
+ },
48
+ };
49
+ });
50
+ }
51
+
52
+ /** Picks one (single) or several (multi) of the given options, or undefined if the user cancels. */
53
+ export async function pickDiscussionOptions(ctx: ExtensionCommandContext, mode: DiscussionOptionsMode, options: string[]): Promise<string[] | undefined> {
54
+ if (mode === "single") {
55
+ const pick = await ctx.ui.select("Pick one:", options);
56
+ return pick ? [pick] : undefined;
57
+ }
58
+ return pickMultiple(ctx, "Pick one or more:", options);
59
+ }
@@ -33,6 +33,47 @@ function artifactLine(artifact: Artifact): string {
33
33
  return `${artifact.id} [${artifact.status}] ${artifact.title}`;
34
34
  }
35
35
 
36
+ /**
37
+ * Tasks-only: the model's primary interfacing point is the task's NAME, not its id -- id is a
38
+ * backend detail (a stable key other operations need, and titles aren't guaranteed unique), so
39
+ * it stays out of what the model reads by default. It only resurfaces when genuinely needed to
40
+ * tell two same-titled tasks apart (taskLines below), or in a matchTaskByName disambiguation
41
+ * error, never as a matter of course. This is scoped to the tasks tool specifically -- Docs/
42
+ * Rules/Skills/Discuss keep the shared artifactLine above unless a similar request covers them.
43
+ */
44
+ export function taskLine(task: Artifact): string {
45
+ return `[${task.status}] ${task.title}`;
46
+ }
47
+
48
+ /** Appends " (id)" only for tasks whose title collides with another in this same result set. */
49
+ export function taskLines(tasks: Artifact[]): string[] {
50
+ const titleCounts = new Map<string, number>();
51
+ for (const task of tasks) titleCounts.set(task.title, (titleCounts.get(task.title) ?? 0) + 1);
52
+ return tasks.map((task) => (titleCounts.get(task.title)! > 1 ? `${taskLine(task)} (${task.id})` : taskLine(task)));
53
+ }
54
+
55
+ /**
56
+ * Exact, case-insensitive, trimmed title match against an already-fetched candidate set. Throws
57
+ * a clear "not found" or "ambiguous -- use id" error rather than guessing at a fuzzy match -- id
58
+ * remains the one truly unambiguous key, so ambiguity is exactly where it's allowed to resurface.
59
+ * Pure and synchronous so it's directly testable without a service round-trip.
60
+ */
61
+ export function matchTaskByName(candidates: Artifact[], name: string): string {
62
+ const needle = name.trim().toLowerCase();
63
+ const matches = candidates.filter((task) => task.title.trim().toLowerCase() === needle);
64
+ if (matches.length === 0) throw new Error(`no task named "${name}" found in this scope`);
65
+ if (matches.length > 1) {
66
+ throw new Error(`${matches.length} tasks are named "${name}": ${matches.map((task) => `${task.title} (${task.id})`).join(", ")} -- use id to disambiguate`);
67
+ }
68
+ return matches[0]!.id;
69
+ }
70
+
71
+ /** Resolves a task name to its id, scoped the same way a plain `tasks list` call would be (same project_root/session_id/scope). */
72
+ async function resolveTaskIdByName(baseRequest: Record<string, unknown>, name: string): Promise<string> {
73
+ const candidates = await callService<Record<string, unknown>, Artifact[]>("tasks.list", { ...baseRequest, text: name });
74
+ return matchTaskByName(candidates, name);
75
+ }
76
+
36
77
  /**
37
78
  * Shared "remove"/"restore" dispatch for every domain tool (tasks/docs/rules/skills) --
38
79
  * artifact.remove/restore are kind-agnostic composition-root operations (see service.ts),
@@ -67,10 +108,11 @@ export function registerDomainTools(pi: ExtensionAPI): void {
67
108
  pi.registerTool({
68
109
  name: "tasks",
69
110
  label: "Tasks",
70
- description: "Task domain tool. ACTIONS: create, update, list, show, history, scope, set_scope, assign_project, graph, plan, active, focused, focus, pause, unpause, clear_focus, start, submit, complete, reject, retry, cancel, run_gates, set_checklist, depend, undepend, contain, uncontain, remove, restore. Lifecycle is todo → in-progress → review → done, with review failure → rejected and retry → in-progress; canceled is terminal. update can recover a Task accidentally created terminal by setting status=todo with a reason, but cannot rewrite legitimate lifecycle history. Active focus is independent and identifies the one task auto-drive continues. Completion runs gates and checklist-proof review, then focuses one deterministic ready successor without claiming effort. Dependency cycles are rejected. undepend/uncontain are idempotent for an already-absent relationship and never start, complete, or focus work merely because an edge disappeared; uncontain removes both contains and part_of edges atomically. remove moves a Task to a time-gated trash (restorable via restore until the purge deadline; refuses if it is the live Task Focus). Prefer this over low-level papyrus_* tools for task work.",
111
+ description: "Task domain tool. ACTIONS: create, update, list, show, history, scope, set_scope, assign_project, graph, plan, active, focused, focus, pause, unpause, clear_focus, start, submit, complete, reject, retry, cancel, run_gates, set_checklist, depend, undepend, contain, uncontain, remove, restore. Lifecycle is todo → in-progress → review → done, with review failure → rejected and retry → in-progress; canceled is terminal. update can recover a Task accidentally created terminal by setting status=todo with a reason, but cannot rewrite legitimate lifecycle history. Active focus is independent and identifies the one task auto-drive continues. Completion runs gates and checklist-proof review, then focuses one deterministic ready successor without claiming effort. Dependency cycles are rejected. undepend/uncontain are idempotent for an already-absent relationship and never start, complete, or focus work merely because an edge disappeared; uncontain removes both contains and part_of edges atomically. remove moves a Task to a time-gated trash (restorable via restore until the purge deadline; refuses if it is the live Task Focus). PREFER addressing a task by `name` (its exact title) over `id` for every action -- id is a backend implementation detail, resolved from name automatically, and only needs to appear explicitly when a name is genuinely ambiguous (two tasks share a title; the error will say so and list the real ids to disambiguate with). Task results likewise show name and status, not id, unless two shown tasks share a title. `dependency_name`/`parent_name`/`child_name`/`root_task_name`/`depends_on_names` are the name-based equivalents of `dependency_id`/`parent_id`/`child_id`/`root_task_id`/`depends_on`. Prefer this over low-level papyrus_* tools for task work.",
71
112
  parameters: Type.Object({
72
113
  action: Type.String(),
73
114
  id: Type.Optional(Type.String()),
115
+ name: Type.Optional(Type.String()),
74
116
  title: Type.Optional(Type.String()),
75
117
  body: Type.Optional(Type.String()),
76
118
  status: Type.Optional(Type.String()),
@@ -86,17 +128,23 @@ export function registerDomainTools(pi: ExtensionAPI): void {
86
128
  checklist: Type.Optional(Type.Record(Type.String(), checklistCriterionSchema)),
87
129
  template_id: Type.Optional(Type.String()),
88
130
  parent_id: Type.Optional(Type.String()),
131
+ parent_name: Type.Optional(Type.String()),
89
132
  child_id: Type.Optional(Type.String()),
133
+ child_name: Type.Optional(Type.String()),
90
134
  dependency_id: Type.Optional(Type.String()),
135
+ dependency_name: Type.Optional(Type.String()),
91
136
  depends_on: Type.Optional(Type.Array(Type.String())),
137
+ depends_on_names: Type.Optional(Type.Array(Type.String())),
92
138
  project_root: Type.Optional(Type.String()),
93
139
  scope: Type.Optional(Type.Union([Type.Literal("project"), Type.Literal("graph"), Type.Literal("all")])),
94
140
  root_task_id: Type.Optional(Type.String()),
141
+ root_task_name: Type.Optional(Type.String()),
95
142
  }),
96
143
  renderCall(args, theme) { return renderPapyrusToolCall("Tasks", args, theme); },
97
144
  renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
98
- async execute(_id, params, _signal, _onUpdate, ctx) {
145
+ async execute(_id, rawParams, _signal, _onUpdate, ctx) {
99
146
  try {
147
+ const params: Record<string, unknown> = { ...rawParams };
100
148
  const action = params.action;
101
149
  // Defaults to this Pi session's own id so Focus reads/writes are isolated per agent
102
150
  // without depending on the model to know or supply its own session identity.
@@ -105,18 +153,37 @@ export function registerDomainTools(pi: ExtensionAPI): void {
105
153
  // session never gets this session's secret smuggled in on its behalf -- the cache only
106
154
  // ever holds this extension's own registered session anyway (see session-identity.ts).
107
155
  const resolvedSessionId = params.session_id ?? ctx.sessionManager.getSessionId();
108
- const request = { ...params, project_root: params.project_root ?? ctx.cwd, actor: "agent", source: "pi-tool", session_id: resolvedSessionId, ...sessionSecretField(resolvedSessionId as string) };
156
+ const baseRequest = { project_root: params.project_root ?? ctx.cwd, actor: "agent", source: "pi-tool", session_id: resolvedSessionId, ...sessionSecretField(resolvedSessionId as string) };
157
+ // Resolves every *_name field to its *_id counterpart before dispatch, so every action
158
+ // below can go on reading id/dependency_id/parent_id/child_id/root_task_id exactly as
159
+ // before -- id-based calls are unaffected; name-based ones are transparently rewritten.
160
+ const resolveField = async (nameKey: string, idKey: string) => {
161
+ const nameValue = params[nameKey];
162
+ if (typeof nameValue === "string" && nameValue.length > 0 && !params[idKey]) {
163
+ params[idKey] = await resolveTaskIdByName(baseRequest, nameValue);
164
+ }
165
+ };
166
+ await resolveField("name", "id");
167
+ await resolveField("dependency_name", "dependency_id");
168
+ await resolveField("parent_name", "parent_id");
169
+ await resolveField("child_name", "child_id");
170
+ await resolveField("root_task_name", "root_task_id");
171
+ const dependsOnNames = params["depends_on_names"];
172
+ if (Array.isArray(dependsOnNames) && dependsOnNames.length > 0 && !params["depends_on"]) {
173
+ params["depends_on"] = await Promise.all(dependsOnNames.map((entry) => resolveTaskIdByName(baseRequest, String(entry))));
174
+ }
175
+ const request = { ...params, ...baseRequest };
109
176
  if (action === "create") {
110
177
  const artifact = await callService<Record<string, unknown>, Artifact>("tasks.create", request);
111
- return text(`Created task ${artifactLine(artifact)}`, createArtifactDetails("tasks.create", artifact));
178
+ return text(`Created task ${taskLine(artifact)}`, createArtifactDetails("tasks.create", artifact));
112
179
  }
113
180
  if (action === "list") {
114
181
  const rows = await callService<Record<string, unknown>, Artifact[]>("tasks.list", request);
115
- return text(rows.length ? rows.map(artifactLine).join("\n") : "No tasks found.", createArtifactListDetails("tasks.list", rows));
182
+ return text(rows.length ? taskLines(rows).join("\n") : "No tasks found.", createArtifactListDetails("tasks.list", rows));
116
183
  }
117
184
  if (action === "show") {
118
185
  const artifact = await callService<Record<string, unknown>, Artifact>("tasks.show", params);
119
- return text(`${artifactLine(artifact)}\n\n${artifact.body}`, createArtifactDetails("tasks.show", artifact));
186
+ return text(`${taskLine(artifact)}\n\n${artifact.body}`, createArtifactDetails("tasks.show", artifact));
120
187
  }
121
188
  if (action === "history") {
122
189
  const page = await callService<Record<string, unknown>, TaskHistoryPage>("tasks.history", request);
@@ -131,20 +198,20 @@ export function registerDomainTools(pi: ExtensionAPI): void {
131
198
  if (action === "active") {
132
199
  const artifact = await callService<Record<string, unknown>, Artifact | null>("tasks.active", request);
133
200
  return artifact
134
- ? text(`Active: ${artifactLine(artifact)}`, createArtifactDetails("tasks.active", artifact))
201
+ ? text(`Active: ${taskLine(artifact)}`, createArtifactDetails("tasks.active", artifact))
135
202
  : text("No active task.", createPreviewDetails("tasks.active", "Active task", "No active task."));
136
203
  }
137
204
  if (action === "focused") {
138
205
  const focus = await callService<Record<string, unknown>, { artifact: Artifact; status: string } | null>("tasks.focused", request);
139
206
  return focus
140
- ? text(`Focused (${focus.status}): ${artifactLine(focus.artifact)}`, createArtifactDetails("tasks.focused", focus.artifact))
207
+ ? text(`Focused (${focus.status}): ${taskLine(focus.artifact)}`, createArtifactDetails("tasks.focused", focus.artifact))
141
208
  : text("No focused task.", createPreviewDetails("tasks.focused", "Focused task", "No focused task."));
142
209
  }
143
210
  if (action === "pause" || action === "unpause") {
144
211
  const operation = action === "pause" ? "tasks.pause" : "tasks.unpause";
145
212
  const focus = await callService<Record<string, unknown>, { artifact: Artifact; status: string }>(operation, request);
146
213
  emitTaskFocusEvent({ taskId: focus.artifact.id, sessionId: request.session_id as string, status: action === "pause" ? "paused" : "unpaused" });
147
- return text(`Focused (${focus.status}): ${artifactLine(focus.artifact)}`, createArtifactDetails(operation, focus.artifact));
214
+ return text(`Focused (${focus.status}): ${taskLine(focus.artifact)}`, createArtifactDetails(operation, focus.artifact));
148
215
  }
149
216
  if (action === "clear_focus") {
150
217
  const result = await callService<Record<string, unknown>, { cleared: boolean }>("tasks.clear_focus", request);
@@ -168,11 +235,16 @@ export function registerDomainTools(pi: ExtensionAPI): void {
168
235
  if (action === "plan") {
169
236
  const plan = await callService<Record<string, unknown>, TaskExecutionPlan>("tasks.plan", request);
170
237
  const byId = new Map(plan.nodes.map((node) => [node.id, node]));
238
+ const titleCounts = new Map<string, number>();
239
+ for (const node of plan.nodes) titleCounts.set(node.title, (titleCounts.get(node.title) ?? 0) + 1);
171
240
  const lines = plan.layers.flatMap((layer, index) => [
172
241
  `Layer ${index + 1}`,
173
242
  ...layer.map((id) => {
174
243
  const node = byId.get(id);
175
- return node ? ` [${node.state}] ${node.id} ${node.title}` : ` [unknown] ${id}`;
244
+ if (!node) return ` [unknown] ${id}`;
245
+ return (titleCounts.get(node.title) ?? 0) > 1
246
+ ? ` [${node.state}] ${node.title} (${node.id})`
247
+ : ` [${node.state}] ${node.title}`;
176
248
  }),
177
249
  ]);
178
250
  if (plan.cycleIds.length > 0) lines.push(`Invalid cycle: ${plan.cycleIds.join(", ")}`);
@@ -181,24 +253,25 @@ export function registerDomainTools(pi: ExtensionAPI): void {
181
253
  }
182
254
  if (action === "set_checklist") {
183
255
  const artifact = await callService<Record<string, unknown>, Artifact>("tasks.set_checklist", params);
184
- return text(`Updated checklist: ${artifactLine(artifact)}`, createArtifactDetails("tasks.set_checklist", artifact));
256
+ return text(`Updated checklist: ${taskLine(artifact)}`, createArtifactDetails("tasks.set_checklist", artifact));
185
257
  }
186
258
  if (action === "complete") {
187
259
  const result = await callService<Record<string, unknown>, TaskCompletion>("tasks.complete", request);
188
260
  const gates = result.gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n");
189
261
  const checklist = result.checklist.map((item) => `${item.accepted ? "✓" : "✗"} proof: ${item.item}${item.reason ? ` — ${item.reason}` : ""}`).join("\n");
190
- const focused = result.focused ? `\nActive: ${artifactLine(result.focused)}` : "";
262
+ const focused = result.focused ? `\nActive: ${taskLine(result.focused)}` : "";
263
+ const blockedLines = taskLines(result.blocked.map((entry) => entry.artifact));
191
264
  const blocked = result.blocked.length > 0
192
- ? `\nBlocked: ${result.blocked.map((entry) => `${artifactLine(entry.artifact)} waits for ${entry.dependencyIds.join(", ")}`).join("; ")}`
265
+ ? `\nBlocked: ${result.blocked.map((entry, index) => `${blockedLines[index]} waits for ${entry.dependencyIds.join(", ")}`).join("; ")}`
193
266
  : "";
194
- const output = `${result.completed ? "Completed" : "Rejected"}: ${artifactLine(result.artifact)}${focused}${blocked}${checklist ? `\n${checklist}` : ""}${gates ? `\n${gates}` : ""}`;
267
+ const output = `${result.completed ? "Completed" : "Rejected"}: ${taskLine(result.artifact)}${focused}${blocked}${checklist ? `\n${checklist}` : ""}${gates ? `\n${gates}` : ""}`;
195
268
  return text(output, createPreviewDetails("tasks.complete", "Task completion", output));
196
269
  }
197
270
  if (action === "run_gates") {
198
271
  const gates = await callService<Record<string, unknown>, GateResult[]>("tasks.run_gates", request);
199
272
  return text(
200
273
  gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n") || "No gates configured.",
201
- createGateRunDetails("tasks.run_gates", params.id ?? "", gates.map((gate) => ({
274
+ createGateRunDetails("tasks.run_gates", (params.id as string | undefined) ?? "", gates.map((gate) => ({
202
275
  passed: gate.passed, type: gate.gate.type, target: gate.gate.target, output: gate.output,
203
276
  }))),
204
277
  );
@@ -224,7 +297,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
224
297
  if (!operation) throw new Error(`unknown tasks action: ${action}`);
225
298
  const artifact = await callService<Record<string, unknown>, Artifact>(operation, request);
226
299
  if (operation === "tasks.focus") emitTaskFocusEvent({ taskId: artifact.id, sessionId: request.session_id as string, status: "focused" });
227
- return text(artifactLine(artifact), createArtifactDetails(operation, artifact));
300
+ return text(taskLine(artifact), createArtifactDetails(operation, artifact));
228
301
  } catch (error) {
229
302
  throw new Error(`tasks failed: ${error instanceof Error ? error.message : error}`);
230
303
  }
@@ -438,7 +511,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
438
511
  pi.registerTool({
439
512
  name: "discuss",
440
513
  label: "Discuss",
441
- 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.",
514
+ 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.",
442
515
  parameters: Type.Object({
443
516
  action: Type.String(),
444
517
  id: Type.Optional(Type.String()),
@@ -454,6 +527,9 @@ export function registerDomainTools(pi: ExtensionAPI): void {
454
527
  state: Type.Optional(Type.String()),
455
528
  after_round: Type.Optional(Type.Number()),
456
529
  limit: Type.Optional(Type.Number()),
530
+ options: Type.Optional(Type.Array(Type.String())),
531
+ options_mode: Type.Optional(Type.String()),
532
+ selected: Type.Optional(Type.Array(Type.String())),
457
533
  }),
458
534
  renderCall(args, theme) { return renderPapyrusToolCall("Discuss", args, theme); },
459
535
  renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.19.1",
3
+ "version": "0.21.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"],
@@ -1,6 +1,14 @@
1
1
  import type { Db } from "../db.ts";
2
2
  import { DISCUSSION_ROUNDS_DEFAULT_LIMIT, DISCUSSION_ROUNDS_MAX_LIMIT } from "../constants.ts";
3
- import { validateDiscussionActor, validateDiscussionContent, type AppendDiscussionRound, type DiscussionRound, type DiscussionRoundQuery } from "../domain/discussion.ts";
3
+ import {
4
+ validateDiscussionActor,
5
+ validateDiscussionContent,
6
+ validateDiscussionOptions,
7
+ type AppendDiscussionRound,
8
+ type DiscussionOptionsMode,
9
+ type DiscussionRound,
10
+ type DiscussionRoundQuery,
11
+ } from "../domain/discussion.ts";
4
12
  import type { DiscussionRoundStore } from "../ports/discussion-round-store.ts";
5
13
 
6
14
  interface DiscussionRoundRow {
@@ -10,6 +18,9 @@ interface DiscussionRoundRow {
10
18
  actor: string;
11
19
  content: string;
12
20
  occurred_at: string;
21
+ options: string | null;
22
+ options_mode: string | null;
23
+ selected: string | null;
13
24
  }
14
25
 
15
26
  function mapRow(row: DiscussionRoundRow): DiscussionRound {
@@ -20,6 +31,9 @@ function mapRow(row: DiscussionRoundRow): DiscussionRound {
20
31
  actor: row.actor,
21
32
  content: row.content,
22
33
  occurredAt: row.occurred_at,
34
+ ...(row.options !== null ? { options: JSON.parse(row.options) as string[] } : {}),
35
+ ...(row.options_mode !== null ? { optionsMode: row.options_mode as DiscussionOptionsMode } : {}),
36
+ ...(row.selected !== null ? { selected: JSON.parse(row.selected) as string[] } : {}),
23
37
  };
24
38
  }
25
39
 
@@ -29,10 +43,21 @@ export class SQLiteDiscussionRoundStore implements DiscussionRoundStore {
29
43
  append(round: AppendDiscussionRound, occurredAt: string): DiscussionRound {
30
44
  const content = validateDiscussionContent(round.content);
31
45
  const actor = validateDiscussionActor(round.actor);
46
+ // selected isn't validated here -- it requires cross-referencing the Discussion's
47
+ // currently pending options (extra.discussion), which this store, deliberately scoped to
48
+ // the rounds table alone, has no access to. discussion-service.ts validates it beforehand.
49
+ const posed = round.options !== undefined || round.optionsMode !== undefined
50
+ ? validateDiscussionOptions(round.options ?? [], round.optionsMode ?? "")
51
+ : undefined;
32
52
  const result = this.db.prepare(`
33
- INSERT INTO discussion_rounds (discussion_id, round_number, actor, content, occurred_at, event_schema_version)
34
- VALUES (?, ?, ?, ?, ?, 1)
35
- `).run(round.discussionId, round.roundNumber, actor, content, occurredAt);
53
+ INSERT INTO discussion_rounds (discussion_id, round_number, actor, content, occurred_at, event_schema_version, options, options_mode, selected)
54
+ VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)
55
+ `).run(
56
+ round.discussionId, round.roundNumber, actor, content, occurredAt,
57
+ posed ? JSON.stringify(posed.options) : null,
58
+ posed ? posed.mode : null,
59
+ round.selected !== undefined ? JSON.stringify(round.selected) : null,
60
+ );
36
61
  return {
37
62
  id: Number(result.lastInsertRowid),
38
63
  discussionId: round.discussionId,
@@ -40,13 +65,15 @@ export class SQLiteDiscussionRoundStore implements DiscussionRoundStore {
40
65
  actor,
41
66
  content,
42
67
  occurredAt,
68
+ ...(posed ? { options: posed.options, optionsMode: posed.mode } : {}),
69
+ ...(round.selected !== undefined ? { selected: [...round.selected] } : {}),
43
70
  };
44
71
  }
45
72
 
46
73
  list(query: DiscussionRoundQuery): DiscussionRound[] {
47
74
  const limit = Math.min(DISCUSSION_ROUNDS_MAX_LIMIT, Math.max(1, Math.floor(query.limit ?? DISCUSSION_ROUNDS_DEFAULT_LIMIT)));
48
75
  const rows = this.db.prepare(`
49
- SELECT id, discussion_id, round_number, actor, content, occurred_at
76
+ SELECT id, discussion_id, round_number, actor, content, occurred_at, options, options_mode, selected
50
77
  FROM discussion_rounds
51
78
  WHERE discussion_id = ? AND round_number > ?
52
79
  ORDER BY round_number ASC
package/src/cli.ts CHANGED
@@ -118,8 +118,8 @@ const USAGE = `Usage:
118
118
  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]
119
119
  papyrus session register --session-id <id> [--json]
120
120
  papyrus session release --session-id <id> [--session-secret <secret>] [--json]
121
- papyrus discuss open --title <t> --actor <a> --content <c> [--body <b>] [--labels-json <json>] [--blocks-json <json>] [--json]
122
- papyrus discuss reply <id> --actor <a> --content <c> [--json]
121
+ 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]
122
+ papyrus discuss reply <id> --actor <a> --content <c> [--selected-json <json>] [--options-json <json>] [--options-mode single|multi] [--json]
123
123
  papyrus discuss defer <id> [--reason <text>] [--json]
124
124
  papyrus discuss resume <id> [--json]
125
125
  papyrus discuss settle <id> --settlement <text> [--json]
@@ -999,6 +999,9 @@ export async function runDiscussCli(args: string[], client: TaskCliClient): Prom
999
999
  let state: string | undefined;
1000
1000
  let afterRound: number | undefined;
1001
1001
  let limit: number | undefined;
1002
+ let options: string[] | undefined;
1003
+ let optionsMode: string | undefined;
1004
+ let selected: string[] | undefined;
1002
1005
  for (let index = 0; index < args.length; index++) {
1003
1006
  const argument = args[index]!;
1004
1007
  if (argument === "--json") continue;
@@ -1009,6 +1012,9 @@ export async function runDiscussCli(args: string[], client: TaskCliClient): Prom
1009
1012
  if (argument === "--labels-json") { labels = parseJsonStringArrayFlag(args[++index], "--labels-json"); continue; }
1010
1013
  if (argument === "--blocks-json") { blocksTaskIds = parseJsonStringArrayFlag(args[++index], "--blocks-json"); continue; }
1011
1014
  if (argument === "--task-id") { taskId = args[++index]; if (!taskId) throw new Error("--task-id requires a value"); continue; }
1015
+ if (argument === "--options-json") { options = parseJsonStringArrayFlag(args[++index], "--options-json"); continue; }
1016
+ if (argument === "--options-mode") { optionsMode = args[++index]; if (!optionsMode) throw new Error("--options-mode requires a value"); continue; }
1017
+ if (argument === "--selected-json") { selected = parseJsonStringArrayFlag(args[++index], "--selected-json"); continue; }
1012
1018
  if (argument === "--reason") { reason = args[++index]; if (reason === undefined) throw new Error("--reason requires a value"); continue; }
1013
1019
  if (argument === "--settlement") { settlement = args[++index]; if (!settlement) throw new Error("--settlement requires a value"); continue; }
1014
1020
  if (argument === "--state") { state = args[++index]; if (!state) throw new Error("--state requires a value"); continue; }
@@ -1031,12 +1037,12 @@ export async function runDiscussCli(args: string[], client: TaskCliClient): Prom
1031
1037
  switch (action) {
1032
1038
  case "open": {
1033
1039
  if (id) throw new Error("discuss open accepts no positional arguments");
1034
- const result = await client.call<Record<string, unknown>, unknown>("discuss.open", { title, actor, content, body, labels, blocks_task_ids: blocksTaskIds });
1040
+ const result = await client.call<Record<string, unknown>, unknown>("discuss.open", { title, actor, content, body, labels, blocks_task_ids: blocksTaskIds, options, options_mode: optionsMode });
1035
1041
  return json ? JSON.stringify(result) : JSON.stringify(result, null, 2);
1036
1042
  }
1037
1043
  case "reply": {
1038
1044
  if (!id) throw new Error("discuss reply requires exactly one discussion id");
1039
- const result = await client.call<Record<string, unknown>, unknown>("discuss.reply", { id, actor, content });
1045
+ const result = await client.call<Record<string, unknown>, unknown>("discuss.reply", { id, actor, content, selected, options, options_mode: optionsMode });
1040
1046
  return json ? JSON.stringify(result) : JSON.stringify(result, null, 2);
1041
1047
  }
1042
1048
  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 = 15;
10
+ export const SQLITE_SCHEMA_VERSION = 16;
11
11
  export const SERVICE_MAX_BODY_BYTES = 1_048_576;
12
12
 
13
13
  export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
@@ -158,6 +158,14 @@ export const DISCUSSION_LIST_MAX_LIMIT = 200;
158
158
  export const DISCUSSION_SETTLEMENT_MAX_CHARACTERS = 4_000;
159
159
  export const DISCUSSION_DEFER_REASON_MAX_CHARACTERS = 2_000;
160
160
  export const DISCUSSION_ACTOR_MAX_LENGTH = 128;
161
+ /**
162
+ * A posed choice (open/reply with options): "single" is mutually exclusive (exactly one pick),
163
+ * "multi" allows several. Mirrors opencode's QuestionV2 labeled-multiple-choice model (the one
164
+ * piece of prior art surveyed that already solves this), kept native and dependency-free here.
165
+ */
166
+ export const DISCUSSION_OPTIONS_MIN_COUNT = 2;
167
+ export const DISCUSSION_OPTIONS_MAX_COUNT = 10;
168
+ export const DISCUSSION_OPTION_MAX_LENGTH = 200;
161
169
  /** Bounds for the generic graph projection protocol (external bounded contexts). */
162
170
  export const GRAPH_PROJECTION_MAX_ARTIFACTS_PER_BATCH = 500;
163
171
  export const GRAPH_PROJECTION_MAX_EDGES_PER_BATCH = 1_000;
package/src/db.ts CHANGED
@@ -235,6 +235,9 @@ CREATE TABLE IF NOT EXISTS discussion_rounds (
235
235
  content TEXT NOT NULL,
236
236
  occurred_at TEXT NOT NULL,
237
237
  event_schema_version INTEGER NOT NULL DEFAULT 1,
238
+ options TEXT,
239
+ options_mode TEXT,
240
+ selected TEXT,
238
241
  UNIQUE (discussion_id, round_number)
239
242
  );
240
243
  CREATE INDEX IF NOT EXISTS discussion_rounds_discussion_idx ON discussion_rounds(discussion_id, round_number, id);
@@ -323,6 +326,7 @@ const CORE_LEDGER_VERSIONS: ReadonlyArray<{ version: number; name: string; check
323
326
  { version: 5, name: "session-identity", checksum: "1c6a165bbe37f82a100fd34762db70c3f8ab15ff20c3a53c2e60448edc815a5e" },
324
327
  { version: 6, name: "artifact-trash", checksum: "4a75dbec2892deb54bcc1afdf0d51d81f03a8d10861787d083784a29e5c7e8f9" },
325
328
  { version: 7, name: "discuss-native", checksum: "ab7bdd04824bd93681917807b817d6e08b9825af90161e3ccd6d6663021dc6a0" },
329
+ { version: 8, name: "discuss-options", checksum: "ba1fc5ab7cfe9166d71cc1842f13bc32a0905d08696006cec202196a70c832e8" },
326
330
  ];
327
331
 
328
332
  export function migrationLedger(db: Db): ModuleMigrationRow[] {
@@ -470,6 +474,22 @@ const FUTURE_MIGRATIONS: ReadonlyArray<PapyrusMigration> = [
470
474
  `);
471
475
  },
472
476
  },
477
+ {
478
+ version: 16,
479
+ name: "discuss-options",
480
+ // See domain/discussion.ts. Nullable, purely additive columns: a round with no posed choice
481
+ // (the overwhelming majority, before this feature existed) simply stores NULL in all three.
482
+ // Guarded per-column (SQLite has no `ADD COLUMN IF NOT EXISTS`): a fixture that bootstraps
483
+ // from the CURRENT SCHEMA text (which already declares these columns) and then only fakes an
484
+ // older user_version to exercise this migration path must not fail with "duplicate column
485
+ // name" -- the same class of already-bootstrapped-fixture concern version 9's comment covers.
486
+ up: (db) => {
487
+ const existing = new Set((db.prepare("PRAGMA table_info(discussion_rounds)").all() as Array<{ name: string }>).map((row) => row.name));
488
+ for (const column of ["options", "options_mode", "selected"]) {
489
+ if (!existing.has(column)) db.exec(`ALTER TABLE discussion_rounds ADD COLUMN ${column} TEXT`);
490
+ }
491
+ },
492
+ },
473
493
  ];
474
494
 
475
495
  /**
@@ -11,8 +11,11 @@ import {
11
11
  validateDeferReason,
12
12
  validateDiscussionActor,
13
13
  validateDiscussionContent,
14
+ validateDiscussionOptions,
15
+ validateSelectedOptions,
14
16
  validateSettlement,
15
17
  type DiscussionExtra,
18
+ type DiscussionOptionsMode,
16
19
  type DiscussionRound,
17
20
  } from "./domain/discussion.ts";
18
21
  import type { Artifact } from "./domain/artifact.ts";
@@ -29,6 +32,19 @@ export interface OpenDiscussionInput {
29
32
  body?: string;
30
33
  labels?: string[];
31
34
  blocksTaskIds?: string[];
35
+ /** Poses a choice on round 1 -- both or neither; see domain/discussion.ts's DiscussionOptionsMode. */
36
+ options?: string[];
37
+ optionsMode?: DiscussionOptionsMode;
38
+ }
39
+
40
+ export interface ReplyInput {
41
+ actor: string;
42
+ content: string;
43
+ /** Answers the Discussion's currently pending posed choice, if any; validated against it. */
44
+ selected?: string[];
45
+ /** Poses a new choice on this same round, replacing whatever was previously pending. */
46
+ options?: string[];
47
+ optionsMode?: DiscussionOptionsMode;
32
48
  }
33
49
 
34
50
  export interface DiscussionAndRounds {
@@ -52,9 +68,16 @@ export class Discussions {
52
68
  return readDiscussionExtra(discussion.extra);
53
69
  }
54
70
 
71
+ /** 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 {
73
+ if (options === undefined && optionsMode === undefined) return undefined;
74
+ return validateDiscussionOptions(options ?? [], optionsMode ?? "");
75
+ }
76
+
55
77
  open(input: OpenDiscussionInput, context?: ArtifactEventContext): DiscussionAndRounds {
56
78
  const actor = validateDiscussionActor(input.actor);
57
79
  const content = validateDiscussionContent(input.content);
80
+ const posed = this.validatePosedOptions(input.options, input.optionsMode);
58
81
  return this.artifacts.atomic(() => {
59
82
  const discussion = this.artifacts.create({
60
83
  kind: "doc",
@@ -63,25 +86,46 @@ export class Discussions {
63
86
  body: input.body ?? "",
64
87
  status: "active",
65
88
  labels: input.labels,
66
- extra: { discussion: { state: "active", roundCount: 1 } },
89
+ extra: {
90
+ discussion: {
91
+ state: "active",
92
+ roundCount: 1,
93
+ ...(posed ? { pendingOptions: posed.options, pendingOptionsMode: posed.mode } : {}),
94
+ },
95
+ },
67
96
  }, context);
68
- const round = this.rounds.append({ discussionId: discussion.id, roundNumber: 1, actor, content }, new Date().toISOString());
97
+ const round = this.rounds.append({
98
+ discussionId: discussion.id, roundNumber: 1, actor, content,
99
+ ...(posed ? { options: posed.options, optionsMode: posed.mode } : {}),
100
+ }, new Date().toISOString());
69
101
  for (const taskId of input.blocksTaskIds ?? []) this.block(discussion.id, taskId, context);
70
102
  return { discussion: this.artifacts.get(discussion.id)!, rounds: [round] };
71
103
  });
72
104
  }
73
105
 
74
- reply(discussionId: string, actor: string, content: string, context?: ArtifactEventContext): DiscussionAndRounds {
75
- const validActor = validateDiscussionActor(actor);
76
- const validContent = validateDiscussionContent(content);
106
+ reply(discussionId: string, input: ReplyInput, context?: ArtifactEventContext): DiscussionAndRounds {
107
+ const validActor = validateDiscussionActor(input.actor);
108
+ const validContent = validateDiscussionContent(input.content);
109
+ const posed = this.validatePosedOptions(input.options, input.optionsMode);
77
110
  return this.artifacts.atomic(() => {
78
111
  const discussion = requireDiscussion(this.artifacts.get(discussionId), discussionId);
79
112
  const state = this.extra(discussion);
80
113
  if (state.state !== "active") throw new DiscussionError(`discussion "${discussionId}" is ${state.state}; resume it before replying`);
81
114
  if (state.roundCount >= DISCUSSION_MAX_ROUNDS) throw new DiscussionError(`discussion "${discussionId}" has reached its ${DISCUSSION_MAX_ROUNDS}-round limit; settle or defer it`);
115
+ const selected = input.selected !== undefined ? validateSelectedOptions(input.selected, state.pendingOptions, state.pendingOptionsMode) : undefined;
82
116
  const nextRound = state.roundCount + 1;
83
- const round = this.rounds.append({ discussionId, roundNumber: nextRound, actor: validActor, content: validContent }, new Date().toISOString());
84
- const updated = this.artifacts.setExtra(discussionId, { ...discussion.extra, discussion: { ...state, roundCount: nextRound } }, context)!;
117
+ const round = this.rounds.append({
118
+ discussionId, roundNumber: nextRound, actor: validActor, content: validContent,
119
+ ...(posed ? { options: posed.options, optionsMode: posed.mode } : {}),
120
+ ...(selected ? { selected } : {}),
121
+ }, new Date().toISOString());
122
+ const { pendingOptions: _clearedOptions, pendingOptionsMode: _clearedMode, ...answered } = state;
123
+ const nextState = {
124
+ ...(selected ? answered : state),
125
+ roundCount: nextRound,
126
+ ...(posed ? { pendingOptions: posed.options, pendingOptionsMode: posed.mode } : {}),
127
+ };
128
+ const updated = this.artifacts.setExtra(discussionId, { ...discussion.extra, discussion: nextState }, context)!;
85
129
  return { discussion: updated, rounds: [round] };
86
130
  });
87
131
  }
@@ -17,6 +17,9 @@
17
17
  import {
18
18
  DISCUSSION_ACTOR_MAX_LENGTH,
19
19
  DISCUSSION_DEFER_REASON_MAX_CHARACTERS,
20
+ DISCUSSION_OPTION_MAX_LENGTH,
21
+ DISCUSSION_OPTIONS_MAX_COUNT,
22
+ DISCUSSION_OPTIONS_MIN_COUNT,
20
23
  DISCUSSION_ROUND_CONTENT_MAX_CHARACTERS,
21
24
  DISCUSSION_SETTLEMENT_MAX_CHARACTERS,
22
25
  } from "../constants.ts";
@@ -26,16 +29,26 @@ export const DISCUSSION_SUBTYPE = "discussion";
26
29
  export const DISCUSSION_STATES = ["active", "deferred", "settled"] as const;
27
30
  export type DiscussionState = typeof DISCUSSION_STATES[number];
28
31
 
29
- /** Persisted in a discussion Doc's `extra.discussion`. */
32
+ /**
33
+ * A round can pose a choice (options + optionsMode) the way opencode's QuestionV2 poses
34
+ * labeled multiple-choice options, or answer one (selected). "single" is mutually exclusive
35
+ * (exactly one pick); "multi" allows several -- see constants.ts.
36
+ */
37
+ export const DISCUSSION_OPTIONS_MODES = ["single", "multi"] as const;
38
+ export type DiscussionOptionsMode = typeof DISCUSSION_OPTIONS_MODES[number];
39
+
40
+ /** Persisted in a discussion Doc's `extra.discussion`. pendingOptions/-Mode is the current-state cache of "is there an unanswered posed choice right now" -- cleared once answered, set again whenever a round poses a new one. */
30
41
  export interface DiscussionExtra {
31
42
  state: DiscussionState;
32
43
  roundCount: number;
33
44
  deferredReason?: string;
34
45
  settlement?: string;
35
46
  settledAt?: string;
47
+ pendingOptions?: string[];
48
+ pendingOptionsMode?: DiscussionOptionsMode;
36
49
  }
37
50
 
38
- /** One append-only round of a Discussion -- opening statement is round 1. */
51
+ /** 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). */
39
52
  export interface DiscussionRound {
40
53
  id: number;
41
54
  discussionId: string;
@@ -43,6 +56,9 @@ export interface DiscussionRound {
43
56
  actor: string;
44
57
  content: string;
45
58
  occurredAt: string;
59
+ options?: string[];
60
+ optionsMode?: DiscussionOptionsMode;
61
+ selected?: string[];
46
62
  }
47
63
 
48
64
  export interface AppendDiscussionRound {
@@ -50,6 +66,9 @@ export interface AppendDiscussionRound {
50
66
  roundNumber: number;
51
67
  actor: string;
52
68
  content: string;
69
+ options?: string[];
70
+ optionsMode?: DiscussionOptionsMode;
71
+ selected?: string[];
53
72
  }
54
73
 
55
74
  export interface DiscussionRoundQuery {
@@ -79,6 +98,32 @@ export function validateSettlement(settlement: string): string {
79
98
  return boundedString(settlement, "settlement", DISCUSSION_SETTLEMENT_MAX_CHARACTERS);
80
99
  }
81
100
 
101
+ /** Validates a freshly-posed choice: 2..DISCUSSION_OPTIONS_MAX_COUNT unique, bounded-length options and a real mode. */
102
+ export function validateDiscussionOptions(options: string[], mode: string): { options: string[]; mode: DiscussionOptionsMode } {
103
+ if (!(DISCUSSION_OPTIONS_MODES as readonly string[]).includes(mode)) {
104
+ throw new Error(`options_mode must be one of ${DISCUSSION_OPTIONS_MODES.join(", ")}`);
105
+ }
106
+ if (options.length < DISCUSSION_OPTIONS_MIN_COUNT || options.length > DISCUSSION_OPTIONS_MAX_COUNT) {
107
+ throw new Error(`options must have between ${DISCUSSION_OPTIONS_MIN_COUNT} and ${DISCUSSION_OPTIONS_MAX_COUNT} entries`);
108
+ }
109
+ for (const option of options) boundedString(option, "option", DISCUSSION_OPTION_MAX_LENGTH);
110
+ if (new Set(options).size !== options.length) throw new Error("options must not repeat an entry");
111
+ return { options: [...options], mode: mode as DiscussionOptionsMode };
112
+ }
113
+
114
+ /** Validates an answer against the Discussion's currently pending posed choice, if any. */
115
+ export function validateSelectedOptions(selected: string[], pendingOptions: string[] | undefined, pendingMode: DiscussionOptionsMode | undefined): string[] {
116
+ if (!pendingOptions || pendingOptions.length === 0 || !pendingMode) {
117
+ throw new Error("this Discussion has no pending options to select from");
118
+ }
119
+ if (selected.length === 0) throw new Error("selected must not be empty");
120
+ if (new Set(selected).size !== selected.length) throw new Error("selected must not repeat an option");
121
+ const unknown = selected.filter((entry) => !pendingOptions.includes(entry));
122
+ if (unknown.length > 0) throw new Error(`selected option(s) not offered: ${unknown.join(", ")}`);
123
+ if (pendingMode === "single" && selected.length > 1) throw new Error('this Discussion\'s pending options are "single": pick exactly one');
124
+ return [...selected];
125
+ }
126
+
82
127
  /** True for any artifact (already fetched) that is a Discussion, regardless of its current lifecycle state. */
83
128
  export function isDiscussionArtifact(artifact: { kind: string; subtype: string }): boolean {
84
129
  return artifact.kind === "doc" && artifact.subtype === DISCUSSION_SUBTYPE;
@@ -97,11 +142,21 @@ export function readDiscussionExtra(extra: Record<string, unknown>): DiscussionE
97
142
  if (typeof roundCount !== "number" || !Number.isInteger(roundCount) || roundCount < 0) {
98
143
  throw new Error("invalid Discussion roundCount");
99
144
  }
145
+ const pendingOptions = record["pendingOptions"];
146
+ if (pendingOptions !== undefined && (!Array.isArray(pendingOptions) || pendingOptions.some((entry) => typeof entry !== "string"))) {
147
+ throw new Error("invalid Discussion pendingOptions");
148
+ }
149
+ const pendingOptionsMode = record["pendingOptionsMode"];
150
+ if (pendingOptionsMode !== undefined && !(DISCUSSION_OPTIONS_MODES as readonly unknown[]).includes(pendingOptionsMode)) {
151
+ throw new Error("invalid Discussion pendingOptionsMode");
152
+ }
100
153
  return {
101
154
  state: state as DiscussionState,
102
155
  roundCount,
103
156
  ...(typeof record["deferredReason"] === "string" ? { deferredReason: record["deferredReason"] } : {}),
104
157
  ...(typeof record["settlement"] === "string" ? { settlement: record["settlement"] } : {}),
105
158
  ...(typeof record["settledAt"] === "string" ? { settledAt: record["settledAt"] } : {}),
159
+ ...(pendingOptions !== undefined ? { pendingOptions: pendingOptions as string[] } : {}),
160
+ ...(pendingOptionsMode !== undefined ? { pendingOptionsMode: pendingOptionsMode as DiscussionOptionsMode } : {}),
106
161
  };
107
162
  }
@@ -49,6 +49,13 @@ function taskId(input: OperationInput): string {
49
49
  return value;
50
50
  }
51
51
 
52
+ function optionsMode(input: OperationInput): "single" | "multi" | undefined {
53
+ const value = optionalString(input, "options_mode") ?? optionalString(input, "optionsMode");
54
+ if (value === undefined) return undefined;
55
+ if (value !== "single" && value !== "multi") throw new Error('options_mode must be "single" or "multi"');
56
+ return value;
57
+ }
58
+
52
59
  /** This module's own operation names, the single source of truth src/service.ts's EXPECTED_OPERATION_NAMES spreads in rather than re-listing by hand. */
53
60
  export const DISCUSS_OPERATION_NAMES = [
54
61
  "discuss.open", "discuss.reply", "discuss.defer", "discuss.resume", "discuss.settle",
@@ -68,8 +75,16 @@ export function discussOperations(discussions: Discussions): OperationDefinition
68
75
  body: optionalString(input, "body"),
69
76
  labels: optionalStringArray(input, "labels"),
70
77
  blocksTaskIds: optionalStringArray(input, "blocks_task_ids") ?? optionalStringArray(input, "blocksTaskIds"),
78
+ options: optionalStringArray(input, "options"),
79
+ optionsMode: optionsMode(input),
80
+ }, eventContext(input))),
81
+ define("discuss.reply", (input: OperationInput) => discussions.reply(string(input, "id"), {
82
+ actor: string(input, "actor"),
83
+ content: string(input, "content"),
84
+ selected: optionalStringArray(input, "selected"),
85
+ options: optionalStringArray(input, "options"),
86
+ optionsMode: optionsMode(input),
71
87
  }, eventContext(input))),
72
- define("discuss.reply", (input: OperationInput) => discussions.reply(string(input, "id"), string(input, "actor"), string(input, "content"), eventContext(input))),
73
88
  define("discuss.defer", (input: OperationInput) => discussions.defer(string(input, "id"), optionalString(input, "reason"), eventContext(input))),
74
89
  define("discuss.resume", (input: OperationInput) => discussions.resume(string(input, "id"), eventContext(input))),
75
90
  define("discuss.settle", (input: OperationInput) => discussions.settle(string(input, "id"), string(input, "settlement"), eventContext(input))),