@danypops/papyrus 0.19.0 → 0.20.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 +6 -0
- package/extension/src/artifact-status-presentation.ts +13 -0
- package/extension/src/discuss.ts +110 -0
- package/extension/src/discussion-detail-view.ts +136 -0
- package/extension/src/discussion-picker.ts +59 -0
- package/extension/src/domain-tools.ts +4 -1
- package/extension/src/index.ts +6 -1
- package/package.json +1 -1
- package/src/adapters/sqlite-discussion-round-store.ts +32 -5
- package/src/cli.ts +10 -4
- package/src/constants.ts +9 -1
- package/src/db.ts +20 -0
- package/src/discussion-service.ts +57 -10
- package/src/domain/discussion.ts +57 -2
- package/src/modules/discuss.ts +16 -1
package/README.md
CHANGED
|
@@ -162,9 +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
|
+
`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.
|
|
168
|
+
|
|
165
169
|
```bash
|
|
166
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
|
|
167
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
|
|
168
174
|
papyrus discuss defer <discussion-id> --reason "Waiting on design review" --json
|
|
169
175
|
papyrus discuss resume <discussion-id> --json
|
|
170
176
|
papyrus discuss settle <discussion-id> --settlement "Agreed: renaming to X" --json
|
|
@@ -36,6 +36,19 @@ export const SKILL_STATUS_PRESENTATION: Record<string, StatusPresentation> = {
|
|
|
36
36
|
deprecated: { label: "deprecated", glyph: "○", color: "muted" },
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Keyed by extra.discussion.state, not the shared Doc status column -- a settled Discussion's
|
|
41
|
+
* doc.status becomes "archived", but a deferred one stays "active" at the doc level (see
|
|
42
|
+
* domain/discussion.ts's header comment). Reusing DOC_STATUS_PRESENTATION here would render
|
|
43
|
+
* "deferred" and "active" Discussions with the identical glyph, silently losing the one piece
|
|
44
|
+
* of state this feature exists to distinguish.
|
|
45
|
+
*/
|
|
46
|
+
export const DISCUSSION_STATE_PRESENTATION: Record<string, StatusPresentation> = {
|
|
47
|
+
active: { label: "active", glyph: "●", color: "accent" },
|
|
48
|
+
deferred: { label: "deferred", glyph: "⏸", color: "warning" },
|
|
49
|
+
settled: { label: "settled", glyph: "✓", color: "success" },
|
|
50
|
+
};
|
|
51
|
+
|
|
39
52
|
/** Rule severity gets its own color independent of status -- block is the loudest, info the quietest. */
|
|
40
53
|
export const RULE_SEVERITY_PRESENTATION: Record<string, ThemeColor> = {
|
|
41
54
|
block: "error",
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* discuss.ts — /discuss interactive panel.
|
|
3
|
+
* Reuses the generic artifact browser (artifact-browser.ts), same as docs.ts/rules.ts/notes.ts:
|
|
4
|
+
* a Discussion is a `doc` artifact, so the browser's list/filter/refresh/select-action loop
|
|
5
|
+
* applies unchanged. The one real wrinkle is that Discuss's meaningful lifecycle state
|
|
6
|
+
* (active/deferred/settled) lives in extra.discussion, not the shared doc status column the
|
|
7
|
+
* browser colors its row glyph by (see artifact-status-presentation.ts's DISCUSSION_STATE_PRESENTATION
|
|
8
|
+
* comment) -- so the real state is surfaced in rowMeta text instead, the same way rules.ts
|
|
9
|
+
* surfaces severity and notes.ts surfaces history count, both also not the row glyph.
|
|
10
|
+
*
|
|
11
|
+
* Creating a new Discussion is left to the agent (the discuss tool), matching docs.ts/rules.ts/
|
|
12
|
+
* skills.ts precedent -- Notes is the one kind with a human-facing creation command (/note),
|
|
13
|
+
* because Notes exists specifically as a human-authored inbox.
|
|
14
|
+
*/
|
|
15
|
+
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
17
|
+
import type { DiscussionAndRounds } from "../../src/discussion-service.ts";
|
|
18
|
+
import { readDiscussionExtra } from "../../src/domain/discussion.ts";
|
|
19
|
+
import { showArtifactBrowser } from "./artifact-browser.ts";
|
|
20
|
+
import { DISCUSSION_STATE_PRESENTATION, DOC_STATUS_PRESENTATION } from "./artifact-status-presentation.ts";
|
|
21
|
+
import { discussionRoundCountOf, discussionStateOf, showDiscussionDetailView } from "./discussion-detail-view.ts";
|
|
22
|
+
import { pickDiscussionOptions } from "./discussion-picker.ts";
|
|
23
|
+
import { callService } from "./service-client.ts";
|
|
24
|
+
|
|
25
|
+
const SOURCE = "discuss-tui";
|
|
26
|
+
const ACTOR = "human";
|
|
27
|
+
|
|
28
|
+
export function discussionRowMeta(discussion: Artifact, theme: Theme): string {
|
|
29
|
+
const state = discussionStateOf(discussion);
|
|
30
|
+
const presentation = DISCUSSION_STATE_PRESENTATION[state];
|
|
31
|
+
const stateText = presentation ? theme.fg(presentation.color, `${presentation.glyph} ${presentation.label}`) : theme.fg("muted", "state unknown");
|
|
32
|
+
const rounds = discussionRoundCountOf(discussion);
|
|
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}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function discussionActions(discussion: Artifact): string[] {
|
|
39
|
+
const state = discussionStateOf(discussion);
|
|
40
|
+
if (state === "active") return ["Show transcript", "Reply", "Defer", "Settle", "Block a task", "Unblock a task"];
|
|
41
|
+
if (state === "deferred") return ["Show transcript", "Resume", "Settle"];
|
|
42
|
+
return ["Show transcript"]; // settled, or an unrecognized/corrupt state -- read-only either way
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function showDiscussions(ctx: ExtensionCommandContext): Promise<void> {
|
|
46
|
+
await showArtifactBrowser(ctx, {
|
|
47
|
+
kind: "doc",
|
|
48
|
+
title: "Discussions",
|
|
49
|
+
listOperation: "discuss.list",
|
|
50
|
+
statusOrder: ["draft", "active", "archived"],
|
|
51
|
+
presentation: DOC_STATUS_PRESENTATION,
|
|
52
|
+
rowMeta: discussionRowMeta,
|
|
53
|
+
actions: discussionActions,
|
|
54
|
+
handleAction: async (choice, discussion, commandCtx) => {
|
|
55
|
+
if (choice === "Show transcript") {
|
|
56
|
+
const result = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.show", { id: discussion.id });
|
|
57
|
+
await showDiscussionDetailView(commandCtx, result.discussion, result.rounds);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
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
|
+
}
|
|
71
|
+
const content = await commandCtx.ui.input("Reply:", "");
|
|
72
|
+
if (!content) return;
|
|
73
|
+
await callService("discuss.reply", { id: discussion.id, actor: ACTOR, content, source: SOURCE });
|
|
74
|
+
commandCtx.ui.notify("Round added.", "info");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (choice === "Defer") {
|
|
78
|
+
const reason = await commandCtx.ui.input("Defer reason (optional):", "");
|
|
79
|
+
await callService("discuss.defer", { id: discussion.id, ...(reason ? { reason } : {}), actor: ACTOR, source: SOURCE });
|
|
80
|
+
commandCtx.ui.notify("Deferred.", "info");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (choice === "Resume") {
|
|
84
|
+
await callService("discuss.resume", { id: discussion.id, actor: ACTOR, source: SOURCE });
|
|
85
|
+
commandCtx.ui.notify("Resumed.", "info");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (choice === "Settle") {
|
|
89
|
+
const settlement = await commandCtx.ui.input("Settlement:", "");
|
|
90
|
+
if (!settlement) return;
|
|
91
|
+
await callService("discuss.settle", { id: discussion.id, settlement, actor: ACTOR, source: SOURCE });
|
|
92
|
+
commandCtx.ui.notify("Settled.", "info");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (choice === "Block a task") {
|
|
96
|
+
const taskId = await commandCtx.ui.input("Task artifact id to block:", "");
|
|
97
|
+
if (!taskId) return;
|
|
98
|
+
await callService("discuss.block", { id: discussion.id, task_id: taskId, actor: ACTOR, source: SOURCE });
|
|
99
|
+
commandCtx.ui.notify(`${discussion.id} now blocks ${taskId}`, "info");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (choice === "Unblock a task") {
|
|
103
|
+
const taskId = await commandCtx.ui.input("Task artifact id to unblock:", "");
|
|
104
|
+
if (!taskId) return;
|
|
105
|
+
const result = await callService<Record<string, unknown>, { unblocked: boolean }>("discuss.unblock", { id: discussion.id, task_id: taskId, actor: ACTOR, source: SOURCE });
|
|
106
|
+
commandCtx.ui.notify(result.unblocked ? `${discussion.id} no longer blocks ${taskId}` : "No such blocking relationship.", "info");
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* discussion-detail-view.ts — the transcript view for a single Discussion.
|
|
3
|
+
*
|
|
4
|
+
* The generic artifact detail view (artifact-detail-view.ts) formats an artifact's own
|
|
5
|
+
* fields (title, body, extra as JSON, edges); it has no way to show a Discussion's rounds,
|
|
6
|
+
* since those live in a dedicated child table fetched separately (discuss.show / discuss.rounds),
|
|
7
|
+
* not in the artifact row itself. Tasks needed the same kind of dedicated view for the same
|
|
8
|
+
* underlying reason (task-detail-view.ts) -- this mirrors that scrolling-viewport idiom rather
|
|
9
|
+
* than inventing a new one.
|
|
10
|
+
*/
|
|
11
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { matchesKey, truncateToWidth, type TUI } from "@earendil-works/pi-tui";
|
|
13
|
+
import {
|
|
14
|
+
ARTIFACT_DETAIL_MAX_VISIBLE_LINES,
|
|
15
|
+
ARTIFACT_DETAIL_MIN_VISIBLE_LINES,
|
|
16
|
+
ARTIFACT_DETAIL_RESERVED_ROWS,
|
|
17
|
+
} from "../../src/constants.ts";
|
|
18
|
+
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
19
|
+
import { readDiscussionExtra, type DiscussionRound } from "../../src/domain/discussion.ts";
|
|
20
|
+
import { renderMarkdownBody, type ActiveTheme } from "./markdown.ts";
|
|
21
|
+
import { DISCUSSION_STATE_PRESENTATION } from "./artifact-status-presentation.ts";
|
|
22
|
+
|
|
23
|
+
interface TranscriptLine {
|
|
24
|
+
text: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Reads state defensively for display -- a corrupt/foreign extra.discussion shape shows as "unknown" rather than crashing the whole panel over one bad row. */
|
|
28
|
+
export function discussionStateOf(discussion: Artifact): string {
|
|
29
|
+
try {
|
|
30
|
+
return readDiscussionExtra(discussion.extra).state;
|
|
31
|
+
} catch {
|
|
32
|
+
return "unknown";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function discussionRoundCountOf(discussion: Artifact): number {
|
|
37
|
+
try {
|
|
38
|
+
return readDiscussionExtra(discussion.extra).roundCount;
|
|
39
|
+
} catch {
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
class DiscussionTranscriptViewport {
|
|
45
|
+
private offsetY = 0;
|
|
46
|
+
private renderedWidth = 0;
|
|
47
|
+
private lines: TranscriptLine[] = [];
|
|
48
|
+
private readonly visibleLines: number;
|
|
49
|
+
|
|
50
|
+
constructor(
|
|
51
|
+
private readonly tui: TUI,
|
|
52
|
+
private readonly activeTheme: ActiveTheme,
|
|
53
|
+
private readonly discussion: Artifact,
|
|
54
|
+
private readonly rounds: DiscussionRound[],
|
|
55
|
+
private readonly close: () => void,
|
|
56
|
+
) {
|
|
57
|
+
this.visibleLines = Math.max(
|
|
58
|
+
ARTIFACT_DETAIL_MIN_VISIBLE_LINES,
|
|
59
|
+
Math.min(ARTIFACT_DETAIL_MAX_VISIBLE_LINES, tui.terminal.rows - ARTIFACT_DETAIL_RESERVED_ROWS),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
invalidate(): void { this.renderedWidth = 0; }
|
|
64
|
+
|
|
65
|
+
render(width: number): string[] {
|
|
66
|
+
const contentWidth = Math.max(1, width - 2);
|
|
67
|
+
this.buildLines(contentWidth);
|
|
68
|
+
this.offsetY = Math.min(this.offsetY, Math.max(0, this.lines.length - this.visibleLines));
|
|
69
|
+
const end = Math.min(this.lines.length, this.offsetY + this.visibleLines);
|
|
70
|
+
const theme = this.activeTheme();
|
|
71
|
+
const border = theme.fg("borderMuted", "─".repeat(Math.max(1, width)));
|
|
72
|
+
const footer = [
|
|
73
|
+
this.lines.length > this.visibleLines ? `↑/↓ scroll · ${this.offsetY + 1}-${end}/${this.lines.length}` : "",
|
|
74
|
+
"Esc back",
|
|
75
|
+
].filter(Boolean).join(" · ");
|
|
76
|
+
return [
|
|
77
|
+
border,
|
|
78
|
+
truncateToWidth(theme.fg("accent", theme.bold("Discussion transcript")), width, ""),
|
|
79
|
+
border,
|
|
80
|
+
...this.lines.slice(this.offsetY, end).map((line) => truncateToWidth(` ${line.text}`, width, "")),
|
|
81
|
+
truncateToWidth(theme.fg("dim", footer), width, ""),
|
|
82
|
+
border,
|
|
83
|
+
];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
handleInput(data: string): void {
|
|
87
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.close(); return; }
|
|
88
|
+
if (matchesKey(data, "up")) this.offsetY = Math.max(0, this.offsetY - 1);
|
|
89
|
+
else if (matchesKey(data, "down")) this.offsetY = Math.min(Math.max(0, this.lines.length - this.visibleLines), this.offsetY + 1);
|
|
90
|
+
else if (matchesKey(data, "pageDown")) this.offsetY = Math.min(Math.max(0, this.lines.length - this.visibleLines), this.offsetY + this.visibleLines);
|
|
91
|
+
else if (matchesKey(data, "pageUp")) this.offsetY = Math.max(0, this.offsetY - this.visibleLines);
|
|
92
|
+
else return;
|
|
93
|
+
this.tui.requestRender();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private buildLines(width: number): void {
|
|
97
|
+
if (this.renderedWidth === width) return;
|
|
98
|
+
this.renderedWidth = width;
|
|
99
|
+
const theme = this.activeTheme();
|
|
100
|
+
const extra = (() => { try { return readDiscussionExtra(this.discussion.extra); } catch { return undefined; } })();
|
|
101
|
+
const presentation = extra ? DISCUSSION_STATE_PRESENTATION[extra.state] : undefined;
|
|
102
|
+
const stateLine = presentation
|
|
103
|
+
? theme.fg(presentation.color, `${presentation.glyph} ${presentation.label}`)
|
|
104
|
+
: theme.fg("muted", "state unknown");
|
|
105
|
+
const header: TranscriptLine[] = [
|
|
106
|
+
{ text: theme.bold(this.discussion.title) },
|
|
107
|
+
{ text: `${stateLine}${theme.fg("dim", ` · ${this.discussion.id}`)}` },
|
|
108
|
+
...(extra?.deferredReason ? [{ text: theme.fg("muted", `Deferred: ${extra.deferredReason}`) }] : []),
|
|
109
|
+
...(extra?.settlement ? [{ text: theme.fg("success", `Settled: ${extra.settlement}`) }] : []),
|
|
110
|
+
{ text: "" },
|
|
111
|
+
];
|
|
112
|
+
const transcript: TranscriptLine[] = this.rounds.flatMap((round, index) => {
|
|
113
|
+
const roundHeader = theme.fg("accent", `[round ${round.roundNumber}] `) + theme.bold(round.actor) + theme.fg("dim", ` · ${round.occurredAt}`);
|
|
114
|
+
const body = renderMarkdownBody(round.content, width - 2, this.activeTheme).map((line) => ({ text: ` ${line}` }));
|
|
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: "" }] : [])];
|
|
122
|
+
});
|
|
123
|
+
this.lines = [...header, ...(transcript.length > 0 ? transcript : [{ text: theme.fg("muted", "No rounds recorded.") }])];
|
|
124
|
+
this.offsetY = Math.min(this.offsetY, Math.max(0, this.lines.length - this.visibleLines));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function showDiscussionDetailView(ctx: ExtensionCommandContext, discussion: Artifact, rounds: DiscussionRound[]): Promise<void> {
|
|
129
|
+
if (ctx.mode !== "tui") {
|
|
130
|
+
const lines = rounds.map((round) => `[round ${round.roundNumber}] ${round.actor}: ${round.content}`);
|
|
131
|
+
ctx.ui.notify([discussion.title, ...lines].join("\n"), "info");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
await ctx.ui.custom<void>((tui, theme, _keybindings, done) =>
|
|
135
|
+
new DiscussionTranscriptViewport(tui, () => ctx.ui.theme ?? theme, discussion, rounds, done));
|
|
136
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -438,7 +438,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
438
438
|
pi.registerTool({
|
|
439
439
|
name: "discuss",
|
|
440
440
|
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.",
|
|
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. 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
442
|
parameters: Type.Object({
|
|
443
443
|
action: Type.String(),
|
|
444
444
|
id: Type.Optional(Type.String()),
|
|
@@ -454,6 +454,9 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
454
454
|
state: Type.Optional(Type.String()),
|
|
455
455
|
after_round: Type.Optional(Type.Number()),
|
|
456
456
|
limit: Type.Optional(Type.Number()),
|
|
457
|
+
options: Type.Optional(Type.Array(Type.String())),
|
|
458
|
+
options_mode: Type.Optional(Type.String()),
|
|
459
|
+
selected: Type.Optional(Type.Array(Type.String())),
|
|
457
460
|
}),
|
|
458
461
|
renderCall(args, theme) { return renderPapyrusToolCall("Discuss", args, theme); },
|
|
459
462
|
renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
|
package/extension/src/index.ts
CHANGED
|
@@ -416,12 +416,13 @@ export default async function (pi: ExtensionAPI) {
|
|
|
416
416
|
// ── Interactive artifact browsers ──────────────────────────────────
|
|
417
417
|
|
|
418
418
|
// Lazy imports keep TUI components out of non-interactive startup paths.
|
|
419
|
-
const [tasksModule, docsModule, notesModule, rulesModule, skillsModule] = await Promise.all([
|
|
419
|
+
const [tasksModule, docsModule, notesModule, rulesModule, skillsModule, discussModule] = await Promise.all([
|
|
420
420
|
import("./tasks.ts"),
|
|
421
421
|
import("./docs.ts"),
|
|
422
422
|
import("./notes.ts"),
|
|
423
423
|
import("./rules.ts"),
|
|
424
424
|
import("./skills.ts"),
|
|
425
|
+
import("./discuss.ts"),
|
|
425
426
|
]);
|
|
426
427
|
let overlay: TaskOverlay | undefined;
|
|
427
428
|
|
|
@@ -454,6 +455,10 @@ export default async function (pi: ExtensionAPI) {
|
|
|
454
455
|
description: "Browse and invoke Papyrus skills and templates (interactive)",
|
|
455
456
|
handler: async (_args, ctx) => { await skillsModule.showSkills(ctx); },
|
|
456
457
|
});
|
|
458
|
+
pi.registerCommand("discuss", {
|
|
459
|
+
description: "Browse Papyrus Discussions and reply, defer, resume, settle, or block/unblock a task (interactive)",
|
|
460
|
+
handler: async (_args, ctx) => { await discussModule.showDiscussions(ctx); },
|
|
461
|
+
});
|
|
457
462
|
pi.registerCommand("context", {
|
|
458
463
|
description: "Structured, per-segment breakdown of the context window: real usage against the model's window, drilling into Papyrus Rules and the Pi-native skill catalog",
|
|
459
464
|
handler: async (_args, ctx) => {
|
package/package.json
CHANGED
|
@@ -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 {
|
|
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(
|
|
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 =
|
|
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
|
/**
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* append-only rounds (via DiscussionRoundStore). See domain/discussion.ts for the full
|
|
4
4
|
* design rationale.
|
|
5
5
|
*/
|
|
6
|
-
import { DISCUSSION_MAX_ROUNDS } from "./constants.ts";
|
|
6
|
+
import { DISCUSSION_LIST_DEFAULT_LIMIT, DISCUSSION_LIST_MAX_LIMIT, DISCUSSION_MAX_ROUNDS } from "./constants.ts";
|
|
7
7
|
import {
|
|
8
8
|
DISCUSSION_SUBTYPE,
|
|
9
9
|
isDiscussionArtifact,
|
|
@@ -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,7 +32,19 @@ export interface OpenDiscussionInput {
|
|
|
29
32
|
body?: string;
|
|
30
33
|
labels?: string[];
|
|
31
34
|
blocksTaskIds?: string[];
|
|
32
|
-
|
|
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;
|
|
33
48
|
}
|
|
34
49
|
|
|
35
50
|
export interface DiscussionAndRounds {
|
|
@@ -53,9 +68,16 @@ export class Discussions {
|
|
|
53
68
|
return readDiscussionExtra(discussion.extra);
|
|
54
69
|
}
|
|
55
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
|
+
|
|
56
77
|
open(input: OpenDiscussionInput, context?: ArtifactEventContext): DiscussionAndRounds {
|
|
57
78
|
const actor = validateDiscussionActor(input.actor);
|
|
58
79
|
const content = validateDiscussionContent(input.content);
|
|
80
|
+
const posed = this.validatePosedOptions(input.options, input.optionsMode);
|
|
59
81
|
return this.artifacts.atomic(() => {
|
|
60
82
|
const discussion = this.artifacts.create({
|
|
61
83
|
kind: "doc",
|
|
@@ -64,25 +86,46 @@ export class Discussions {
|
|
|
64
86
|
body: input.body ?? "",
|
|
65
87
|
status: "active",
|
|
66
88
|
labels: input.labels,
|
|
67
|
-
extra: {
|
|
89
|
+
extra: {
|
|
90
|
+
discussion: {
|
|
91
|
+
state: "active",
|
|
92
|
+
roundCount: 1,
|
|
93
|
+
...(posed ? { pendingOptions: posed.options, pendingOptionsMode: posed.mode } : {}),
|
|
94
|
+
},
|
|
95
|
+
},
|
|
68
96
|
}, context);
|
|
69
|
-
const round = this.rounds.append({
|
|
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());
|
|
70
101
|
for (const taskId of input.blocksTaskIds ?? []) this.block(discussion.id, taskId, context);
|
|
71
102
|
return { discussion: this.artifacts.get(discussion.id)!, rounds: [round] };
|
|
72
103
|
});
|
|
73
104
|
}
|
|
74
105
|
|
|
75
|
-
reply(discussionId: string,
|
|
76
|
-
const validActor = validateDiscussionActor(actor);
|
|
77
|
-
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);
|
|
78
110
|
return this.artifacts.atomic(() => {
|
|
79
111
|
const discussion = requireDiscussion(this.artifacts.get(discussionId), discussionId);
|
|
80
112
|
const state = this.extra(discussion);
|
|
81
113
|
if (state.state !== "active") throw new DiscussionError(`discussion "${discussionId}" is ${state.state}; resume it before replying`);
|
|
82
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;
|
|
83
116
|
const nextRound = state.roundCount + 1;
|
|
84
|
-
const round = this.rounds.append({
|
|
85
|
-
|
|
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)!;
|
|
86
129
|
return { discussion: updated, rounds: [round] };
|
|
87
130
|
});
|
|
88
131
|
}
|
|
@@ -150,7 +193,11 @@ export class Discussions {
|
|
|
150
193
|
}
|
|
151
194
|
|
|
152
195
|
list(filter: { state?: string; limit?: number } = {}): Artifact[] {
|
|
153
|
-
|
|
196
|
+
// DISCUSSION_LIST_MAX_LIMIT/DEFAULT_LIMIT exist specifically so an unqualified discuss.list
|
|
197
|
+
// (limit omitted) can never fall through to queryArtifacts' own unbounded default -- the same
|
|
198
|
+
// class of gap notes.ts's noteListInput comment documents fixing for Notes.
|
|
199
|
+
const limit = Math.min(DISCUSSION_LIST_MAX_LIMIT, Math.max(1, Math.floor(filter.limit ?? DISCUSSION_LIST_DEFAULT_LIMIT)));
|
|
200
|
+
const rows = this.artifacts.query({ kind: "doc", subtype: DISCUSSION_SUBTYPE, limit });
|
|
154
201
|
if (!filter.state) return rows;
|
|
155
202
|
return rows.filter((row) => {
|
|
156
203
|
try { return this.extra(row).state === filter.state; } catch { return false; }
|
package/src/domain/discussion.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
}
|
package/src/modules/discuss.ts
CHANGED
|
@@ -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))),
|