@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.
- package/extension/src/discuss-ask-view.ts +20 -4
- package/extension/src/discuss.ts +7 -6
- package/extension/src/domain-tools.ts +41 -10
- package/package.json +1 -1
- package/src/adapters/sqlite-discussion-round-store.ts +8 -4
- package/src/cli.ts +6 -4
- package/src/constants.ts +5 -1
- package/src/db.ts +12 -0
- package/src/discussion-service.ts +18 -10
- package/src/domain/discussion.ts +27 -3
- package/src/modules/discuss.ts +2 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
989
|
-
|
|
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;
|
package/extension/src/discuss.ts
CHANGED
|
@@ -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
|
-
|
|
86
|
-
//
|
|
87
|
-
//
|
|
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
|
|
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,
|
|
92
|
-
: await askQuestion(commandCtx, { question,
|
|
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
|
-
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
const
|
|
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
|
-
|
|
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,
|
|
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
|
@@ -21,6 +21,7 @@ interface DiscussionRoundRow {
|
|
|
21
21
|
options: string | null;
|
|
22
22
|
options_mode: string | null;
|
|
23
23
|
selected: string | null;
|
|
24
|
+
option_descriptions: string | null;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
function mapRow(row: DiscussionRoundRow): DiscussionRound {
|
|
@@ -34,6 +35,7 @@ function mapRow(row: DiscussionRoundRow): DiscussionRound {
|
|
|
34
35
|
...(row.options !== null ? { options: JSON.parse(row.options) as string[] } : {}),
|
|
35
36
|
...(row.options_mode !== null ? { optionsMode: row.options_mode as DiscussionOptionsMode } : {}),
|
|
36
37
|
...(row.selected !== null ? { selected: JSON.parse(row.selected) as string[] } : {}),
|
|
38
|
+
...(row.option_descriptions !== null ? { optionDescriptions: JSON.parse(row.option_descriptions) as string[] } : {}),
|
|
37
39
|
};
|
|
38
40
|
}
|
|
39
41
|
|
|
@@ -47,16 +49,17 @@ export class SQLiteDiscussionRoundStore implements DiscussionRoundStore {
|
|
|
47
49
|
// currently pending options (extra.discussion), which this store, deliberately scoped to
|
|
48
50
|
// the rounds table alone, has no access to. discussion-service.ts validates it beforehand.
|
|
49
51
|
const posed = round.options !== undefined || round.optionsMode !== undefined
|
|
50
|
-
? validateDiscussionOptions(round.options ?? [], round.optionsMode ?? "")
|
|
52
|
+
? validateDiscussionOptions(round.options ?? [], round.optionsMode ?? "", round.optionDescriptions)
|
|
51
53
|
: undefined;
|
|
52
54
|
const result = this.db.prepare(`
|
|
53
|
-
INSERT INTO discussion_rounds (discussion_id, round_number, actor, content, occurred_at, event_schema_version, options, options_mode, selected)
|
|
54
|
-
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)
|
|
55
|
+
INSERT INTO discussion_rounds (discussion_id, round_number, actor, content, occurred_at, event_schema_version, options, options_mode, selected, option_descriptions)
|
|
56
|
+
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?)
|
|
55
57
|
`).run(
|
|
56
58
|
round.discussionId, round.roundNumber, actor, content, occurredAt,
|
|
57
59
|
posed ? JSON.stringify(posed.options) : null,
|
|
58
60
|
posed ? posed.mode : null,
|
|
59
61
|
round.selected !== undefined ? JSON.stringify(round.selected) : null,
|
|
62
|
+
posed?.optionDescriptions ? JSON.stringify(posed.optionDescriptions) : null,
|
|
60
63
|
);
|
|
61
64
|
return {
|
|
62
65
|
id: Number(result.lastInsertRowid),
|
|
@@ -66,6 +69,7 @@ export class SQLiteDiscussionRoundStore implements DiscussionRoundStore {
|
|
|
66
69
|
content,
|
|
67
70
|
occurredAt,
|
|
68
71
|
...(posed ? { options: posed.options, optionsMode: posed.mode } : {}),
|
|
72
|
+
...(posed?.optionDescriptions ? { optionDescriptions: posed.optionDescriptions } : {}),
|
|
69
73
|
...(round.selected !== undefined ? { selected: [...round.selected] } : {}),
|
|
70
74
|
};
|
|
71
75
|
}
|
|
@@ -73,7 +77,7 @@ export class SQLiteDiscussionRoundStore implements DiscussionRoundStore {
|
|
|
73
77
|
list(query: DiscussionRoundQuery): DiscussionRound[] {
|
|
74
78
|
const limit = Math.min(DISCUSSION_ROUNDS_MAX_LIMIT, Math.max(1, Math.floor(query.limit ?? DISCUSSION_ROUNDS_DEFAULT_LIMIT)));
|
|
75
79
|
const rows = this.db.prepare(`
|
|
76
|
-
SELECT id, discussion_id, round_number, actor, content, occurred_at, options, options_mode, selected
|
|
80
|
+
SELECT id, discussion_id, round_number, actor, content, occurred_at, options, options_mode, selected, option_descriptions
|
|
77
81
|
FROM discussion_rounds
|
|
78
82
|
WHERE discussion_id = ? AND round_number > ?
|
|
79
83
|
ORDER BY round_number ASC
|
package/src/cli.ts
CHANGED
|
@@ -128,8 +128,8 @@ const USAGE = `Usage:
|
|
|
128
128
|
papyrus log append --source <id> --level <debug|info|warning|error> --message <text> --operation-id <id> [--source-label <text>] [--fields-json <json>] [--session-id <id>] [--occurred-at <iso>] [--global] [--json]
|
|
129
129
|
papyrus session register --session-id <id> [--json]
|
|
130
130
|
papyrus session release --session-id <id> [--session-secret <secret>] [--json]
|
|
131
|
-
papyrus discuss open --title <t> --actor <a> --content <c> [--body <b>] [--labels-json <json>] [--blocks-json <json>] [--options-json <json>] [--options-mode single|multi] [--json]
|
|
132
|
-
papyrus discuss reply <id> --actor <a> --content <c> [--selected-json <json>] [--options-json <json>] [--options-mode single|multi] [--json]
|
|
131
|
+
papyrus discuss open --title <t> --actor <a> --content <c> [--body <b>] [--labels-json <json>] [--blocks-json <json>] [--options-json <json>] [--options-mode single|multi] [--option-descriptions-json <json>] [--json]
|
|
132
|
+
papyrus discuss reply <id> --actor <a> --content <c> [--selected-json <json>] [--options-json <json>] [--options-mode single|multi] [--option-descriptions-json <json>] [--json]
|
|
133
133
|
papyrus discuss defer <id> [--reason <text>] [--json]
|
|
134
134
|
papyrus discuss resume <id> [--json]
|
|
135
135
|
papyrus discuss settle <id> --settlement <text> [--json]
|
|
@@ -1134,6 +1134,7 @@ export async function runDiscussCli(args: string[], client: TaskCliClient): Prom
|
|
|
1134
1134
|
let limit: number | undefined;
|
|
1135
1135
|
let options: string[] | undefined;
|
|
1136
1136
|
let optionsMode: string | undefined;
|
|
1137
|
+
let optionDescriptions: string[] | undefined;
|
|
1137
1138
|
let selected: string[] | undefined;
|
|
1138
1139
|
for (let index = 0; index < args.length; index++) {
|
|
1139
1140
|
const argument = args[index]!;
|
|
@@ -1147,6 +1148,7 @@ export async function runDiscussCli(args: string[], client: TaskCliClient): Prom
|
|
|
1147
1148
|
if (argument === "--task-id") { taskId = args[++index]; if (!taskId) throw new Error("--task-id requires a value"); continue; }
|
|
1148
1149
|
if (argument === "--options-json") { options = parseJsonStringArrayFlag(args[++index], "--options-json"); continue; }
|
|
1149
1150
|
if (argument === "--options-mode") { optionsMode = args[++index]; if (!optionsMode) throw new Error("--options-mode requires a value"); continue; }
|
|
1151
|
+
if (argument === "--option-descriptions-json") { optionDescriptions = parseJsonStringArrayFlag(args[++index], "--option-descriptions-json"); continue; }
|
|
1150
1152
|
if (argument === "--selected-json") { selected = parseJsonStringArrayFlag(args[++index], "--selected-json"); continue; }
|
|
1151
1153
|
if (argument === "--reason") { reason = args[++index]; if (reason === undefined) throw new Error("--reason requires a value"); continue; }
|
|
1152
1154
|
if (argument === "--settlement") { settlement = args[++index]; if (!settlement) throw new Error("--settlement requires a value"); continue; }
|
|
@@ -1170,12 +1172,12 @@ export async function runDiscussCli(args: string[], client: TaskCliClient): Prom
|
|
|
1170
1172
|
switch (action) {
|
|
1171
1173
|
case "open": {
|
|
1172
1174
|
if (id) throw new Error("discuss open accepts no positional arguments");
|
|
1173
|
-
const result = await client.call<Record<string, unknown>, unknown>("discuss.open", { title, actor, content, body, labels, blocks_task_ids: blocksTaskIds, options, options_mode: optionsMode });
|
|
1175
|
+
const result = await client.call<Record<string, unknown>, unknown>("discuss.open", { title, actor, content, body, labels, blocks_task_ids: blocksTaskIds, options, options_mode: optionsMode, option_descriptions: optionDescriptions });
|
|
1174
1176
|
return json ? JSON.stringify(result) : JSON.stringify(result, null, 2);
|
|
1175
1177
|
}
|
|
1176
1178
|
case "reply": {
|
|
1177
1179
|
if (!id) throw new Error("discuss reply requires exactly one discussion id");
|
|
1178
|
-
const result = await client.call<Record<string, unknown>, unknown>("discuss.reply", { id, actor, content, selected, options, options_mode: optionsMode });
|
|
1180
|
+
const result = await client.call<Record<string, unknown>, unknown>("discuss.reply", { id, actor, content, selected, options, options_mode: optionsMode, option_descriptions: optionDescriptions });
|
|
1179
1181
|
return json ? JSON.stringify(result) : JSON.stringify(result, null, 2);
|
|
1180
1182
|
}
|
|
1181
1183
|
case "defer": {
|
package/src/constants.ts
CHANGED
|
@@ -7,7 +7,7 @@ export const DAEMON_PROBE_TIMEOUT_MS = 800;
|
|
|
7
7
|
export const DAEMON_UNIT_NAME = "papyrus.service";
|
|
8
8
|
export const DAEMON_DIR_ENV = "PAPYRUS_DAEMON_DIR";
|
|
9
9
|
export const SQLITE_BUSY_TIMEOUT_MS = 5_000;
|
|
10
|
-
export const SQLITE_SCHEMA_VERSION =
|
|
10
|
+
export const SQLITE_SCHEMA_VERSION = 19;
|
|
11
11
|
export const SERVICE_MAX_BODY_BYTES = 1_048_576;
|
|
12
12
|
|
|
13
13
|
export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
|
|
@@ -175,6 +175,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
|
-
|
|
125
|
+
// Whenever this round answers the pending choice OR poses a new one, the base must drop ALL
|
|
126
|
+
// three pending* fields first -- otherwise a re-pose that omits descriptions this time would
|
|
127
|
+
// leave a stale pendingOptionDescriptions array (sized for the OLD options) spread through
|
|
128
|
+
// unchanged, no longer aligned 1:1 with the new pendingOptions. Only a plain reply that
|
|
129
|
+
// neither answers nor re-poses leaves the existing pending state untouched.
|
|
130
|
+
const { pendingOptions: _clearedOptions, pendingOptionsMode: _clearedMode, pendingOptionDescriptions: _clearedDescriptions, ...withoutPending } = state;
|
|
123
131
|
const nextState = {
|
|
124
|
-
...(selected ?
|
|
132
|
+
...(selected || posed ? withoutPending : state),
|
|
125
133
|
roundCount: nextRound,
|
|
126
|
-
...(posed ? { pendingOptions: posed.options, pendingOptionsMode: posed.mode } : {}),
|
|
134
|
+
...(posed ? { pendingOptions: posed.options, pendingOptionsMode: posed.mode, ...(posed.optionDescriptions ? { pendingOptionDescriptions: posed.optionDescriptions } : {}) } : {}),
|
|
127
135
|
};
|
|
128
136
|
const updated = this.artifacts.setExtra(discussionId, { ...discussion.extra, discussion: nextState }, context)!;
|
|
129
137
|
return { discussion: updated, rounds: [round] };
|
package/src/domain/discussion.ts
CHANGED
|
@@ -20,6 +20,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
|
|
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
|
-
|
|
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
|
}
|
package/src/modules/discuss.ts
CHANGED
|
@@ -77,6 +77,7 @@ export function discussOperations(discussions: Discussions): OperationDefinition
|
|
|
77
77
|
blocksTaskIds: optionalStringArray(input, "blocks_task_ids") ?? optionalStringArray(input, "blocksTaskIds"),
|
|
78
78
|
options: optionalStringArray(input, "options"),
|
|
79
79
|
optionsMode: optionsMode(input),
|
|
80
|
+
optionDescriptions: optionalStringArray(input, "option_descriptions") ?? optionalStringArray(input, "optionDescriptions"),
|
|
80
81
|
}, eventContext(input))),
|
|
81
82
|
define("discuss.reply", (input: OperationInput) => discussions.reply(string(input, "id"), {
|
|
82
83
|
actor: string(input, "actor"),
|
|
@@ -84,6 +85,7 @@ export function discussOperations(discussions: Discussions): OperationDefinition
|
|
|
84
85
|
selected: optionalStringArray(input, "selected"),
|
|
85
86
|
options: optionalStringArray(input, "options"),
|
|
86
87
|
optionsMode: optionsMode(input),
|
|
88
|
+
optionDescriptions: optionalStringArray(input, "option_descriptions") ?? optionalStringArray(input, "optionDescriptions"),
|
|
87
89
|
}, eventContext(input))),
|
|
88
90
|
define("discuss.defer", (input: OperationInput) => discussions.defer(string(input, "id"), optionalString(input, "reason"), eventContext(input))),
|
|
89
91
|
define("discuss.resume", (input: OperationInput) => discussions.resume(string(input, "id"), eventContext(input))),
|