@danypops/papyrus 0.18.0 → 0.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -0
- package/extension/src/artifact-status-presentation.ts +13 -0
- package/extension/src/discuss.ts +96 -0
- package/extension/src/discussion-detail-view.ts +130 -0
- package/extension/src/domain-tools.ts +70 -0
- package/extension/src/index.ts +6 -1
- package/package.json +1 -1
- package/src/adapters/sqlite-discussion-round-store.ts +61 -0
- package/src/cli.ts +115 -0
- package/src/constants.ts +22 -1
- package/src/db.ts +46 -1
- package/src/discussion-service.ts +162 -0
- package/src/domain/discussion.ts +107 -0
- package/src/modules/discuss.ts +87 -0
- package/src/ports/discussion-round-store.ts +8 -0
- package/src/service.ts +16 -0
- package/src/task-service.ts +27 -0
package/README.md
CHANGED
|
@@ -154,6 +154,25 @@ papyrus notes promote <note-id> <target-id> --reason "Converted to tracked work"
|
|
|
154
154
|
papyrus notes archive <note-id> declined --reason "No longer relevant" --json
|
|
155
155
|
```
|
|
156
156
|
|
|
157
|
+
## Discuss
|
|
158
|
+
|
|
159
|
+
Discuss is a native, persistent deliberation, distinct from a one-shot ask: it survives across turns and sessions, takes multiple rounds, and can genuinely block a Task's completion until settled or deferred. A Discussion is a `doc` artifact with `subtype: "discussion"` -- real graph citizenship (edges, show/list) without a fifth enforced artifact kind. Its fine-grained lifecycle (`active`/`deferred`/`settled`) lives in `extra.discussion`, since Papyrus enforces status vocabulary per kind, not per subtype.
|
|
160
|
+
|
|
161
|
+
Rounds are a dedicated append-only child table (mirroring Task history's own shape): `open` records round 1, `reply` appends further rounds, refused once the Discussion is `deferred` or `settled` -- resume first. `defer` is explicitly non-blocking (paused, reason optional, resumable); `settle` is terminal, records an outcome, and archives the Doc. `block`/`unblock` manage the blocking relationship to a Task independently of `open`.
|
|
162
|
+
|
|
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
|
+
|
|
165
|
+
Run `/discuss` for the interactive panel: browse every Discussion (the real `active`/`deferred`/`settled` state shown per row, not just the shared Doc status glyph), open a scrollable transcript, and reply/defer/resume/settle or block/unblock a task without leaving the TUI. Opening a *new* Discussion is left to the agent (same as Docs/Rules/Skills) -- `/discuss` browses and drives existing ones.
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
papyrus discuss open --title "Naming" --actor alice --content "Should we rename this?" --blocks-json '["task-id"]' --json
|
|
169
|
+
papyrus discuss reply <discussion-id> --actor bob --content "I think so, here's why..." --json
|
|
170
|
+
papyrus discuss defer <discussion-id> --reason "Waiting on design review" --json
|
|
171
|
+
papyrus discuss resume <discussion-id> --json
|
|
172
|
+
papyrus discuss settle <discussion-id> --settlement "Agreed: renaming to X" --json
|
|
173
|
+
papyrus discuss show <discussion-id> --json
|
|
174
|
+
```
|
|
175
|
+
|
|
157
176
|
## Tasks
|
|
158
177
|
|
|
159
178
|
Run `/tasks` for the interactive task panel:
|
|
@@ -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,96 @@
|
|
|
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 { showArtifactBrowser } from "./artifact-browser.ts";
|
|
19
|
+
import { DISCUSSION_STATE_PRESENTATION, DOC_STATUS_PRESENTATION } from "./artifact-status-presentation.ts";
|
|
20
|
+
import { discussionRoundCountOf, discussionStateOf, showDiscussionDetailView } from "./discussion-detail-view.ts";
|
|
21
|
+
import { callService } from "./service-client.ts";
|
|
22
|
+
|
|
23
|
+
const SOURCE = "discuss-tui";
|
|
24
|
+
const ACTOR = "human";
|
|
25
|
+
|
|
26
|
+
export function discussionRowMeta(discussion: Artifact, theme: Theme): string {
|
|
27
|
+
const state = discussionStateOf(discussion);
|
|
28
|
+
const presentation = DISCUSSION_STATE_PRESENTATION[state];
|
|
29
|
+
const stateText = presentation ? theme.fg(presentation.color, `${presentation.glyph} ${presentation.label}`) : theme.fg("muted", "state unknown");
|
|
30
|
+
const rounds = discussionRoundCountOf(discussion);
|
|
31
|
+
return `${stateText} · ${rounds} round${rounds === 1 ? "" : "s"}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function discussionActions(discussion: Artifact): string[] {
|
|
35
|
+
const state = discussionStateOf(discussion);
|
|
36
|
+
if (state === "active") return ["Show transcript", "Reply", "Defer", "Settle", "Block a task", "Unblock a task"];
|
|
37
|
+
if (state === "deferred") return ["Show transcript", "Resume", "Settle"];
|
|
38
|
+
return ["Show transcript"]; // settled, or an unrecognized/corrupt state -- read-only either way
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function showDiscussions(ctx: ExtensionCommandContext): Promise<void> {
|
|
42
|
+
await showArtifactBrowser(ctx, {
|
|
43
|
+
kind: "doc",
|
|
44
|
+
title: "Discussions",
|
|
45
|
+
listOperation: "discuss.list",
|
|
46
|
+
statusOrder: ["draft", "active", "archived"],
|
|
47
|
+
presentation: DOC_STATUS_PRESENTATION,
|
|
48
|
+
rowMeta: discussionRowMeta,
|
|
49
|
+
actions: discussionActions,
|
|
50
|
+
handleAction: async (choice, discussion, commandCtx) => {
|
|
51
|
+
if (choice === "Show transcript") {
|
|
52
|
+
const result = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.show", { id: discussion.id });
|
|
53
|
+
await showDiscussionDetailView(commandCtx, result.discussion, result.rounds);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (choice === "Reply") {
|
|
57
|
+
const content = await commandCtx.ui.input("Reply:", "");
|
|
58
|
+
if (!content) return;
|
|
59
|
+
await callService("discuss.reply", { id: discussion.id, actor: ACTOR, content, source: SOURCE });
|
|
60
|
+
commandCtx.ui.notify("Round added.", "info");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (choice === "Defer") {
|
|
64
|
+
const reason = await commandCtx.ui.input("Defer reason (optional):", "");
|
|
65
|
+
await callService("discuss.defer", { id: discussion.id, ...(reason ? { reason } : {}), actor: ACTOR, source: SOURCE });
|
|
66
|
+
commandCtx.ui.notify("Deferred.", "info");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (choice === "Resume") {
|
|
70
|
+
await callService("discuss.resume", { id: discussion.id, actor: ACTOR, source: SOURCE });
|
|
71
|
+
commandCtx.ui.notify("Resumed.", "info");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (choice === "Settle") {
|
|
75
|
+
const settlement = await commandCtx.ui.input("Settlement:", "");
|
|
76
|
+
if (!settlement) return;
|
|
77
|
+
await callService("discuss.settle", { id: discussion.id, settlement, actor: ACTOR, source: SOURCE });
|
|
78
|
+
commandCtx.ui.notify("Settled.", "info");
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (choice === "Block a task") {
|
|
82
|
+
const taskId = await commandCtx.ui.input("Task artifact id to block:", "");
|
|
83
|
+
if (!taskId) return;
|
|
84
|
+
await callService("discuss.block", { id: discussion.id, task_id: taskId, actor: ACTOR, source: SOURCE });
|
|
85
|
+
commandCtx.ui.notify(`${discussion.id} now blocks ${taskId}`, "info");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (choice === "Unblock a task") {
|
|
89
|
+
const taskId = await commandCtx.ui.input("Task artifact id to unblock:", "");
|
|
90
|
+
if (!taskId) return;
|
|
91
|
+
const result = await callService<Record<string, unknown>, { unblocked: boolean }>("discuss.unblock", { id: discussion.id, task_id: taskId, actor: ACTOR, source: SOURCE });
|
|
92
|
+
commandCtx.ui.notify(result.unblocked ? `${discussion.id} no longer blocks ${taskId}` : "No such blocking relationship.", "info");
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
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
|
+
return [{ text: roundHeader }, ...body, ...(index < this.rounds.length - 1 ? [{ text: "" }] : [])];
|
|
116
|
+
});
|
|
117
|
+
this.lines = [...header, ...(transcript.length > 0 ? transcript : [{ text: theme.fg("muted", "No rounds recorded.") }])];
|
|
118
|
+
this.offsetY = Math.min(this.offsetY, Math.max(0, this.lines.length - this.visibleLines));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function showDiscussionDetailView(ctx: ExtensionCommandContext, discussion: Artifact, rounds: DiscussionRound[]): Promise<void> {
|
|
123
|
+
if (ctx.mode !== "tui") {
|
|
124
|
+
const lines = rounds.map((round) => `[round ${round.roundNumber}] ${round.actor}: ${round.content}`);
|
|
125
|
+
ctx.ui.notify([discussion.title, ...lines].join("\n"), "info");
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
await ctx.ui.custom<void>((tui, theme, _keybindings, done) =>
|
|
129
|
+
new DiscussionTranscriptViewport(tui, () => ctx.ui.theme ?? theme, discussion, rounds, done));
|
|
130
|
+
}
|
|
@@ -7,6 +7,8 @@ import type { TaskExecutionPlan } from "../../src/task-execution.ts";
|
|
|
7
7
|
import type { TaskHistoryPage } from "../../src/domain/task-event.ts";
|
|
8
8
|
import type { TaskCompletion, TaskGraph } from "../../src/task-service.ts";
|
|
9
9
|
import type { SkillWorkflowRunResult } from "../../src/skill-execution.ts";
|
|
10
|
+
import type { DiscussionAndRounds } from "../../src/discussion-service.ts";
|
|
11
|
+
import type { DiscussionRound } from "../../src/domain/discussion.ts";
|
|
10
12
|
import { emitTaskFocusEvent } from "./task-focus-events.ts";
|
|
11
13
|
import { sessionSecretField } from "./session-identity.ts";
|
|
12
14
|
import { NOTE_DISPOSITIONS } from "../../src/note-service.ts";
|
|
@@ -432,4 +434,72 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
432
434
|
}
|
|
433
435
|
},
|
|
434
436
|
});
|
|
437
|
+
|
|
438
|
+
pi.registerTool({
|
|
439
|
+
name: "discuss",
|
|
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.",
|
|
442
|
+
parameters: Type.Object({
|
|
443
|
+
action: Type.String(),
|
|
444
|
+
id: Type.Optional(Type.String()),
|
|
445
|
+
title: Type.Optional(Type.String()),
|
|
446
|
+
actor: Type.Optional(Type.String()),
|
|
447
|
+
content: Type.Optional(Type.String()),
|
|
448
|
+
body: Type.Optional(Type.String()),
|
|
449
|
+
labels: Type.Optional(Type.Array(Type.String())),
|
|
450
|
+
blocks_task_ids: Type.Optional(Type.Array(Type.String())),
|
|
451
|
+
task_id: Type.Optional(Type.String()),
|
|
452
|
+
reason: Type.Optional(Type.String()),
|
|
453
|
+
settlement: Type.Optional(Type.String()),
|
|
454
|
+
state: Type.Optional(Type.String()),
|
|
455
|
+
after_round: Type.Optional(Type.Number()),
|
|
456
|
+
limit: Type.Optional(Type.Number()),
|
|
457
|
+
}),
|
|
458
|
+
renderCall(args, theme) { return renderPapyrusToolCall("Discuss", args, theme); },
|
|
459
|
+
renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
|
|
460
|
+
async execute(_id, params) {
|
|
461
|
+
try {
|
|
462
|
+
const action = params.action;
|
|
463
|
+
if (action === "open") {
|
|
464
|
+
const result = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.open", params);
|
|
465
|
+
return text(`Opened discussion ${artifactLine(result.discussion)}`, createArtifactDetails("discuss.open", result.discussion));
|
|
466
|
+
}
|
|
467
|
+
if (action === "reply") {
|
|
468
|
+
const result = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.reply", params);
|
|
469
|
+
return text(`Round ${result.rounds[0]?.roundNumber} added to ${result.discussion.id}`, createArtifactDetails("discuss.reply", result.discussion));
|
|
470
|
+
}
|
|
471
|
+
if (action === "block") {
|
|
472
|
+
await callService<Record<string, unknown>, { blocked: boolean }>("discuss.block", params);
|
|
473
|
+
const message = `${params.id} now blocks ${params.task_id}`;
|
|
474
|
+
return text(message, createPreviewDetails("discuss.block", "Blocked", message));
|
|
475
|
+
}
|
|
476
|
+
if (action === "unblock") {
|
|
477
|
+
const result = await callService<Record<string, unknown>, { unblocked: boolean }>("discuss.unblock", params);
|
|
478
|
+
const message = result.unblocked ? `${params.id} no longer blocks ${params.task_id}` : "No such blocking relationship.";
|
|
479
|
+
return text(message, createPreviewDetails("discuss.unblock", "Unblocked", message));
|
|
480
|
+
}
|
|
481
|
+
if (action === "show") {
|
|
482
|
+
const result = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.show", params);
|
|
483
|
+
const rounds = result.rounds.map((round) => ` [round ${round.roundNumber}] ${round.actor}: ${round.content}`).join("\n");
|
|
484
|
+
return text(`${artifactLine(result.discussion)}\n\n${rounds}`, createArtifactDetails("discuss.show", result.discussion));
|
|
485
|
+
}
|
|
486
|
+
if (action === "rounds") {
|
|
487
|
+
const rounds = await callService<Record<string, unknown>, DiscussionRound[]>("discuss.rounds", params);
|
|
488
|
+
const output = rounds.map((round) => `[round ${round.roundNumber}] ${round.actor}: ${round.content}`).join("\n") || "No rounds.";
|
|
489
|
+
return text(output, createPreviewDetails("discuss.rounds", "Discussion rounds", output));
|
|
490
|
+
}
|
|
491
|
+
if (action === "list") {
|
|
492
|
+
const rows = await callService<Record<string, unknown>, Artifact[]>("discuss.list", params);
|
|
493
|
+
return text(rows.length ? rows.map(artifactLine).join("\n") : "No discussions found.", createArtifactListDetails("discuss.list", rows));
|
|
494
|
+
}
|
|
495
|
+
const operations = { defer: "discuss.defer", resume: "discuss.resume", settle: "discuss.settle" } as const;
|
|
496
|
+
const operation = operations[action as keyof typeof operations];
|
|
497
|
+
if (!operation) throw new Error(`unknown discuss action: ${action}`);
|
|
498
|
+
const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
|
|
499
|
+
return text(artifactLine(artifact), createArtifactDetails(operation, artifact));
|
|
500
|
+
} catch (error) {
|
|
501
|
+
throw new Error(`discuss failed: ${error instanceof Error ? error.message : error}`);
|
|
502
|
+
}
|
|
503
|
+
},
|
|
504
|
+
});
|
|
435
505
|
}
|
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
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Db } from "../db.ts";
|
|
2
|
+
import { DISCUSSION_ROUNDS_DEFAULT_LIMIT, DISCUSSION_ROUNDS_MAX_LIMIT } from "../constants.ts";
|
|
3
|
+
import { validateDiscussionActor, validateDiscussionContent, type AppendDiscussionRound, type DiscussionRound, type DiscussionRoundQuery } from "../domain/discussion.ts";
|
|
4
|
+
import type { DiscussionRoundStore } from "../ports/discussion-round-store.ts";
|
|
5
|
+
|
|
6
|
+
interface DiscussionRoundRow {
|
|
7
|
+
id: number;
|
|
8
|
+
discussion_id: string;
|
|
9
|
+
round_number: number;
|
|
10
|
+
actor: string;
|
|
11
|
+
content: string;
|
|
12
|
+
occurred_at: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function mapRow(row: DiscussionRoundRow): DiscussionRound {
|
|
16
|
+
return {
|
|
17
|
+
id: row.id,
|
|
18
|
+
discussionId: row.discussion_id,
|
|
19
|
+
roundNumber: row.round_number,
|
|
20
|
+
actor: row.actor,
|
|
21
|
+
content: row.content,
|
|
22
|
+
occurredAt: row.occurred_at,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class SQLiteDiscussionRoundStore implements DiscussionRoundStore {
|
|
27
|
+
constructor(private readonly db: Db) {}
|
|
28
|
+
|
|
29
|
+
append(round: AppendDiscussionRound, occurredAt: string): DiscussionRound {
|
|
30
|
+
const content = validateDiscussionContent(round.content);
|
|
31
|
+
const actor = validateDiscussionActor(round.actor);
|
|
32
|
+
const result = this.db.prepare(`
|
|
33
|
+
INSERT INTO discussion_rounds (discussion_id, round_number, actor, content, occurred_at, event_schema_version)
|
|
34
|
+
VALUES (?, ?, ?, ?, ?, 1)
|
|
35
|
+
`).run(round.discussionId, round.roundNumber, actor, content, occurredAt);
|
|
36
|
+
return {
|
|
37
|
+
id: Number(result.lastInsertRowid),
|
|
38
|
+
discussionId: round.discussionId,
|
|
39
|
+
roundNumber: round.roundNumber,
|
|
40
|
+
actor,
|
|
41
|
+
content,
|
|
42
|
+
occurredAt,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
list(query: DiscussionRoundQuery): DiscussionRound[] {
|
|
47
|
+
const limit = Math.min(DISCUSSION_ROUNDS_MAX_LIMIT, Math.max(1, Math.floor(query.limit ?? DISCUSSION_ROUNDS_DEFAULT_LIMIT)));
|
|
48
|
+
const rows = this.db.prepare(`
|
|
49
|
+
SELECT id, discussion_id, round_number, actor, content, occurred_at
|
|
50
|
+
FROM discussion_rounds
|
|
51
|
+
WHERE discussion_id = ? AND round_number > ?
|
|
52
|
+
ORDER BY round_number ASC
|
|
53
|
+
LIMIT ?
|
|
54
|
+
`).all(query.discussionId, query.afterRound ?? 0, limit) as DiscussionRoundRow[];
|
|
55
|
+
return rows.map(mapRow);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
count(discussionId: string): number {
|
|
59
|
+
return (this.db.prepare("SELECT COUNT(*) AS c FROM discussion_rounds WHERE discussion_id = ?").get(discussionId) as { c: number }).c;
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -118,6 +118,16 @@ 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]
|
|
123
|
+
papyrus discuss defer <id> [--reason <text>] [--json]
|
|
124
|
+
papyrus discuss resume <id> [--json]
|
|
125
|
+
papyrus discuss settle <id> --settlement <text> [--json]
|
|
126
|
+
papyrus discuss block <id> --task-id <task-id> [--json]
|
|
127
|
+
papyrus discuss unblock <id> --task-id <task-id> [--json]
|
|
128
|
+
papyrus discuss show <id> [--json]
|
|
129
|
+
papyrus discuss rounds <id> [--after-round <n>] [--limit <n>] [--json]
|
|
130
|
+
papyrus discuss list [--state active|deferred|settled] [--limit <n>] [--json]
|
|
121
131
|
papyrus log query --source <id> [--since <iso>] [--level <debug|info|warning|error>] [--limit <count>] [--json]
|
|
122
132
|
papyrus tasks plan [--session-id <id>] [--json]
|
|
123
133
|
papyrus tasks graph [--session-id <id>] [--json]
|
|
@@ -974,6 +984,106 @@ export async function runSessionIdentityCli(args: string[], client: TaskCliClien
|
|
|
974
984
|
throw new Error("session action must be register or release");
|
|
975
985
|
}
|
|
976
986
|
|
|
987
|
+
export async function runDiscussCli(args: string[], client: TaskCliClient): Promise<string> {
|
|
988
|
+
const json = args.includes("--json");
|
|
989
|
+
const positional: string[] = [];
|
|
990
|
+
let title: string | undefined;
|
|
991
|
+
let actor: string | undefined;
|
|
992
|
+
let content: string | undefined;
|
|
993
|
+
let body: string | undefined;
|
|
994
|
+
let labels: string[] | undefined;
|
|
995
|
+
let blocksTaskIds: string[] | undefined;
|
|
996
|
+
let taskId: string | undefined;
|
|
997
|
+
let reason: string | undefined;
|
|
998
|
+
let settlement: string | undefined;
|
|
999
|
+
let state: string | undefined;
|
|
1000
|
+
let afterRound: number | undefined;
|
|
1001
|
+
let limit: number | undefined;
|
|
1002
|
+
for (let index = 0; index < args.length; index++) {
|
|
1003
|
+
const argument = args[index]!;
|
|
1004
|
+
if (argument === "--json") continue;
|
|
1005
|
+
if (argument === "--title") { title = args[++index]; if (!title) throw new Error("--title requires a value"); continue; }
|
|
1006
|
+
if (argument === "--actor") { actor = args[++index]; if (!actor) throw new Error("--actor requires a value"); continue; }
|
|
1007
|
+
if (argument === "--content") { content = args[++index]; if (content === undefined) throw new Error("--content requires a value"); continue; }
|
|
1008
|
+
if (argument === "--body") { body = args[++index]; if (body === undefined) throw new Error("--body requires a value"); continue; }
|
|
1009
|
+
if (argument === "--labels-json") { labels = parseJsonStringArrayFlag(args[++index], "--labels-json"); continue; }
|
|
1010
|
+
if (argument === "--blocks-json") { blocksTaskIds = parseJsonStringArrayFlag(args[++index], "--blocks-json"); continue; }
|
|
1011
|
+
if (argument === "--task-id") { taskId = args[++index]; if (!taskId) throw new Error("--task-id requires a value"); continue; }
|
|
1012
|
+
if (argument === "--reason") { reason = args[++index]; if (reason === undefined) throw new Error("--reason requires a value"); continue; }
|
|
1013
|
+
if (argument === "--settlement") { settlement = args[++index]; if (!settlement) throw new Error("--settlement requires a value"); continue; }
|
|
1014
|
+
if (argument === "--state") { state = args[++index]; if (!state) throw new Error("--state requires a value"); continue; }
|
|
1015
|
+
if (argument === "--after-round") {
|
|
1016
|
+
const value = args[++index];
|
|
1017
|
+
if (!value || Number.isNaN(Number(value))) throw new Error("--after-round requires a numeric value");
|
|
1018
|
+
afterRound = Number(value);
|
|
1019
|
+
continue;
|
|
1020
|
+
}
|
|
1021
|
+
if (argument === "--limit") {
|
|
1022
|
+
const value = args[++index];
|
|
1023
|
+
if (!value || Number.isNaN(Number(value))) throw new Error("--limit requires a numeric value");
|
|
1024
|
+
limit = Number(value);
|
|
1025
|
+
continue;
|
|
1026
|
+
}
|
|
1027
|
+
if (argument.startsWith("--")) throw new Error(`unknown discuss option ${argument}`);
|
|
1028
|
+
positional.push(argument);
|
|
1029
|
+
}
|
|
1030
|
+
const [action, id] = positional;
|
|
1031
|
+
switch (action) {
|
|
1032
|
+
case "open": {
|
|
1033
|
+
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 });
|
|
1035
|
+
return json ? JSON.stringify(result) : JSON.stringify(result, null, 2);
|
|
1036
|
+
}
|
|
1037
|
+
case "reply": {
|
|
1038
|
+
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 });
|
|
1040
|
+
return json ? JSON.stringify(result) : JSON.stringify(result, null, 2);
|
|
1041
|
+
}
|
|
1042
|
+
case "defer": {
|
|
1043
|
+
if (!id) throw new Error("discuss defer requires exactly one discussion id");
|
|
1044
|
+
const result = await client.call<Record<string, unknown>, unknown>("discuss.defer", { id, reason });
|
|
1045
|
+
return json ? JSON.stringify(result) : JSON.stringify(result, null, 2);
|
|
1046
|
+
}
|
|
1047
|
+
case "resume": {
|
|
1048
|
+
if (!id) throw new Error("discuss resume requires exactly one discussion id");
|
|
1049
|
+
const result = await client.call<Record<string, unknown>, unknown>("discuss.resume", { id });
|
|
1050
|
+
return json ? JSON.stringify(result) : JSON.stringify(result, null, 2);
|
|
1051
|
+
}
|
|
1052
|
+
case "settle": {
|
|
1053
|
+
if (!id) throw new Error("discuss settle requires exactly one discussion id");
|
|
1054
|
+
const result = await client.call<Record<string, unknown>, unknown>("discuss.settle", { id, settlement });
|
|
1055
|
+
return json ? JSON.stringify(result) : JSON.stringify(result, null, 2);
|
|
1056
|
+
}
|
|
1057
|
+
case "block": {
|
|
1058
|
+
if (!id) throw new Error("discuss block requires exactly one discussion id");
|
|
1059
|
+
const result = await client.call<Record<string, unknown>, unknown>("discuss.block", { id, task_id: taskId });
|
|
1060
|
+
return json ? JSON.stringify(result) : JSON.stringify(result, null, 2);
|
|
1061
|
+
}
|
|
1062
|
+
case "unblock": {
|
|
1063
|
+
if (!id) throw new Error("discuss unblock requires exactly one discussion id");
|
|
1064
|
+
const result = await client.call<Record<string, unknown>, unknown>("discuss.unblock", { id, task_id: taskId });
|
|
1065
|
+
return json ? JSON.stringify(result) : JSON.stringify(result, null, 2);
|
|
1066
|
+
}
|
|
1067
|
+
case "show": {
|
|
1068
|
+
if (!id) throw new Error("discuss show requires exactly one discussion id");
|
|
1069
|
+
const result = await client.call<Record<string, unknown>, unknown>("discuss.show", { id });
|
|
1070
|
+
return json ? JSON.stringify(result) : JSON.stringify(result, null, 2);
|
|
1071
|
+
}
|
|
1072
|
+
case "rounds": {
|
|
1073
|
+
if (!id) throw new Error("discuss rounds requires exactly one discussion id");
|
|
1074
|
+
const result = await client.call<Record<string, unknown>, unknown>("discuss.rounds", { id, after_round: afterRound, limit });
|
|
1075
|
+
return json ? JSON.stringify(result) : JSON.stringify(result, null, 2);
|
|
1076
|
+
}
|
|
1077
|
+
case "list": {
|
|
1078
|
+
if (id) throw new Error("discuss list accepts no positional arguments");
|
|
1079
|
+
const result = await client.call<Record<string, unknown>, unknown>("discuss.list", { state, limit });
|
|
1080
|
+
return json ? JSON.stringify(result) : JSON.stringify(result, null, 2);
|
|
1081
|
+
}
|
|
1082
|
+
default:
|
|
1083
|
+
throw new Error("discuss action must be open, reply, defer, resume, settle, block, unblock, show, rounds, or list");
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
977
1087
|
export async function runNoteCli(args: string[], client: TaskCliClient, projectRoot: string = process.cwd()): Promise<string> {
|
|
978
1088
|
const json = args.includes("--json");
|
|
979
1089
|
const positional: string[] = [];
|
|
@@ -1395,6 +1505,11 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
|
|
|
1395
1505
|
console.log(await runSessionIdentityCli(args.slice(1), client));
|
|
1396
1506
|
return;
|
|
1397
1507
|
}
|
|
1508
|
+
if (command === "discuss") {
|
|
1509
|
+
const client = await connectPapyrusClient();
|
|
1510
|
+
console.log(await runDiscussCli(args.slice(1), client));
|
|
1511
|
+
return;
|
|
1512
|
+
}
|
|
1398
1513
|
if (command === "migrate") {
|
|
1399
1514
|
const client = await connectPapyrusClient();
|
|
1400
1515
|
console.log(await runMigrationCli(args.slice(1), client));
|
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 = 15;
|
|
11
11
|
export const SERVICE_MAX_BODY_BYTES = 1_048_576;
|
|
12
12
|
|
|
13
13
|
export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
|
|
@@ -137,6 +137,27 @@ export const NOTE_REASON_MAX_CHARACTERS = 2_000;
|
|
|
137
137
|
export const ARTIFACT_EVENT_ACTOR_MAX_LENGTH = 128;
|
|
138
138
|
export const ARTIFACT_EVENT_HISTORY_DEFAULT_LIMIT = 25;
|
|
139
139
|
export const ARTIFACT_EVENT_HISTORY_MAX_LIMIT = 200;
|
|
140
|
+
/**
|
|
141
|
+
* Discuss: a native, blocking-capable deliberation, distinct from Discourse's forum (kept
|
|
142
|
+
* fully standalone, no dependency here) and from the removed ConversationJournal (see Doc
|
|
143
|
+
* 285681a7-bd44-4f33-93b1-1e10198d6d16 -- that domain never had a forcing real caller; a
|
|
144
|
+
* Discussion's ability to block a Task's completion is exactly that forcing caller).
|
|
145
|
+
* A Discussion is a `doc` with subtype "discussion"; its fine-grained lifecycle
|
|
146
|
+
* (active/deferred/settled) lives in extra.discussion, not the shared doc status
|
|
147
|
+
* vocabulary, since Papyrus enforces status per-kind, not per-subtype. Rounds are a
|
|
148
|
+
* dedicated append-only child table, mirroring task_events' proven shape -- a round
|
|
149
|
+
* carries substantive content, unlike the generic artifact_events log's transition markers.
|
|
150
|
+
*/
|
|
151
|
+
export const DISCUSSION_ROUND_CONTENT_MAX_CHARACTERS = 10_000;
|
|
152
|
+
export const DISCUSSION_ROUNDS_DEFAULT_LIMIT = 25;
|
|
153
|
+
export const DISCUSSION_ROUNDS_MAX_LIMIT = 200;
|
|
154
|
+
/** Hard ceiling on total rounds a single Discussion can ever accumulate -- forces settlement or deferral rather than an unbounded back-and-forth. */
|
|
155
|
+
export const DISCUSSION_MAX_ROUNDS = 200;
|
|
156
|
+
export const DISCUSSION_LIST_DEFAULT_LIMIT = 50;
|
|
157
|
+
export const DISCUSSION_LIST_MAX_LIMIT = 200;
|
|
158
|
+
export const DISCUSSION_SETTLEMENT_MAX_CHARACTERS = 4_000;
|
|
159
|
+
export const DISCUSSION_DEFER_REASON_MAX_CHARACTERS = 2_000;
|
|
160
|
+
export const DISCUSSION_ACTOR_MAX_LENGTH = 128;
|
|
140
161
|
/** Bounds for the generic graph projection protocol (external bounded contexts). */
|
|
141
162
|
export const GRAPH_PROJECTION_MAX_ARTIFACTS_PER_BATCH = 500;
|
|
142
163
|
export const GRAPH_PROJECTION_MAX_EDGES_PER_BATCH = 1_000;
|
package/src/db.ts
CHANGED
|
@@ -227,6 +227,22 @@ CREATE TABLE IF NOT EXISTS artifact_trash (
|
|
|
227
227
|
reason TEXT
|
|
228
228
|
);
|
|
229
229
|
CREATE INDEX IF NOT EXISTS artifact_trash_purge_idx ON artifact_trash(purge_after);
|
|
230
|
+
CREATE TABLE IF NOT EXISTS discussion_rounds (
|
|
231
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
232
|
+
discussion_id TEXT NOT NULL REFERENCES artifacts(id),
|
|
233
|
+
round_number INTEGER NOT NULL,
|
|
234
|
+
actor TEXT NOT NULL,
|
|
235
|
+
content TEXT NOT NULL,
|
|
236
|
+
occurred_at TEXT NOT NULL,
|
|
237
|
+
event_schema_version INTEGER NOT NULL DEFAULT 1,
|
|
238
|
+
UNIQUE (discussion_id, round_number)
|
|
239
|
+
);
|
|
240
|
+
CREATE INDEX IF NOT EXISTS discussion_rounds_discussion_idx ON discussion_rounds(discussion_id, round_number, id);
|
|
241
|
+
CREATE TRIGGER IF NOT EXISTS discussion_rounds_no_update BEFORE UPDATE ON discussion_rounds
|
|
242
|
+
BEGIN SELECT RAISE(ABORT, 'discussion_rounds are append-only'); END;
|
|
243
|
+
CREATE TRIGGER IF NOT EXISTS discussion_rounds_no_delete BEFORE DELETE ON discussion_rounds
|
|
244
|
+
WHEN NOT EXISTS (SELECT 1 FROM artifact_trash WHERE artifact_id = OLD.discussion_id AND purge_after <= strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
245
|
+
BEGIN SELECT RAISE(ABORT, 'discussion_rounds are append-only except during an explicit, elapsed-grace-period artifact trash purge'); END;
|
|
230
246
|
`;
|
|
231
247
|
|
|
232
248
|
const SEED_SQL = `
|
|
@@ -252,7 +268,7 @@ INSERT OR IGNORE INTO relation_names VALUES ('implements','This work satisfies t
|
|
|
252
268
|
INSERT OR IGNORE INTO relation_names VALUES ('follows','This work obeys that (task→rule, task→skill)');
|
|
253
269
|
INSERT OR IGNORE INTO relation_names VALUES ('depends_on','DAG ordering (task→task)');
|
|
254
270
|
INSERT OR IGNORE INTO relation_names VALUES ('documents','Describes (doc→task, doc→rule, doc→skill)');
|
|
255
|
-
INSERT OR IGNORE INTO relation_names VALUES ('blocks','Blocking relationship (task→task)');
|
|
271
|
+
INSERT OR IGNORE INTO relation_names VALUES ('blocks','Blocking relationship (task→task, or an active Discussion doc→task)');
|
|
256
272
|
INSERT OR IGNORE INTO relation_names VALUES ('supersedes','Replaces (doc→doc, rule→rule)');
|
|
257
273
|
INSERT OR IGNORE INTO relation_names VALUES ('relates_to','Catch-all (any→any)');
|
|
258
274
|
INSERT OR IGNORE INTO relation_names VALUES ('gates','This rule gates that task (rule→task)');
|
|
@@ -306,6 +322,7 @@ const CORE_LEDGER_VERSIONS: ReadonlyArray<{ version: number; name: string; check
|
|
|
306
322
|
{ version: 4, name: "remove-discourse", checksum: "b923f41c44460f0aaeb2f4e60e28f8b8e1425d03f527955bd991434b46de4c82" },
|
|
307
323
|
{ version: 5, name: "session-identity", checksum: "1c6a165bbe37f82a100fd34762db70c3f8ab15ff20c3a53c2e60448edc815a5e" },
|
|
308
324
|
{ version: 6, name: "artifact-trash", checksum: "4a75dbec2892deb54bcc1afdf0d51d81f03a8d10861787d083784a29e5c7e8f9" },
|
|
325
|
+
{ version: 7, name: "discuss-native", checksum: "ab7bdd04824bd93681917807b817d6e08b9825af90161e3ccd6d6663021dc6a0" },
|
|
309
326
|
];
|
|
310
327
|
|
|
311
328
|
export function migrationLedger(db: Db): ModuleMigrationRow[] {
|
|
@@ -425,6 +442,34 @@ const FUTURE_MIGRATIONS: ReadonlyArray<PapyrusMigration> = [
|
|
|
425
442
|
`);
|
|
426
443
|
},
|
|
427
444
|
},
|
|
445
|
+
{
|
|
446
|
+
version: 15,
|
|
447
|
+
name: "discuss-native",
|
|
448
|
+
// See domain/discussion.ts. discussion_rounds mirrors task_events' proven shape (append-only,
|
|
449
|
+
// with the identical trash-purge trigger carve-out); the blocks relation's description is
|
|
450
|
+
// widened to reflect that an active Discussion doc can now block a task too.
|
|
451
|
+
up: (db) => {
|
|
452
|
+
db.exec(`
|
|
453
|
+
CREATE TABLE IF NOT EXISTS discussion_rounds (
|
|
454
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
455
|
+
discussion_id TEXT NOT NULL REFERENCES artifacts(id),
|
|
456
|
+
round_number INTEGER NOT NULL,
|
|
457
|
+
actor TEXT NOT NULL,
|
|
458
|
+
content TEXT NOT NULL,
|
|
459
|
+
occurred_at TEXT NOT NULL,
|
|
460
|
+
event_schema_version INTEGER NOT NULL DEFAULT 1,
|
|
461
|
+
UNIQUE (discussion_id, round_number)
|
|
462
|
+
);
|
|
463
|
+
CREATE INDEX IF NOT EXISTS discussion_rounds_discussion_idx ON discussion_rounds(discussion_id, round_number, id);
|
|
464
|
+
CREATE TRIGGER IF NOT EXISTS discussion_rounds_no_update BEFORE UPDATE ON discussion_rounds
|
|
465
|
+
BEGIN SELECT RAISE(ABORT, 'discussion_rounds are append-only'); END;
|
|
466
|
+
CREATE TRIGGER IF NOT EXISTS discussion_rounds_no_delete BEFORE DELETE ON discussion_rounds
|
|
467
|
+
WHEN NOT EXISTS (SELECT 1 FROM artifact_trash WHERE artifact_id = OLD.discussion_id AND purge_after <= strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
468
|
+
BEGIN SELECT RAISE(ABORT, 'discussion_rounds are append-only except during an explicit, elapsed-grace-period artifact trash purge'); END;
|
|
469
|
+
UPDATE relation_names SET description = 'Blocking relationship (task→task, or an active Discussion doc→task)' WHERE name = 'blocks';
|
|
470
|
+
`);
|
|
471
|
+
},
|
|
472
|
+
},
|
|
428
473
|
];
|
|
429
474
|
|
|
430
475
|
/**
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discuss: application service composing the Discussion Doc (via ArtifactStore) with its
|
|
3
|
+
* append-only rounds (via DiscussionRoundStore). See domain/discussion.ts for the full
|
|
4
|
+
* design rationale.
|
|
5
|
+
*/
|
|
6
|
+
import { DISCUSSION_LIST_DEFAULT_LIMIT, DISCUSSION_LIST_MAX_LIMIT, DISCUSSION_MAX_ROUNDS } from "./constants.ts";
|
|
7
|
+
import {
|
|
8
|
+
DISCUSSION_SUBTYPE,
|
|
9
|
+
isDiscussionArtifact,
|
|
10
|
+
readDiscussionExtra,
|
|
11
|
+
validateDeferReason,
|
|
12
|
+
validateDiscussionActor,
|
|
13
|
+
validateDiscussionContent,
|
|
14
|
+
validateSettlement,
|
|
15
|
+
type DiscussionExtra,
|
|
16
|
+
type DiscussionRound,
|
|
17
|
+
} from "./domain/discussion.ts";
|
|
18
|
+
import type { Artifact } from "./domain/artifact.ts";
|
|
19
|
+
import type { ArtifactEventContext } from "./domain/artifact-event.ts";
|
|
20
|
+
import type { AtomicArtifactStore } from "./ports/atomic-artifact-store.ts";
|
|
21
|
+
import type { DiscussionRoundStore } from "./ports/discussion-round-store.ts";
|
|
22
|
+
|
|
23
|
+
export class DiscussionError extends Error {}
|
|
24
|
+
|
|
25
|
+
export interface OpenDiscussionInput {
|
|
26
|
+
title: string;
|
|
27
|
+
actor: string;
|
|
28
|
+
content: string;
|
|
29
|
+
body?: string;
|
|
30
|
+
labels?: string[];
|
|
31
|
+
blocksTaskIds?: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DiscussionAndRounds {
|
|
35
|
+
discussion: Artifact;
|
|
36
|
+
rounds: DiscussionRound[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function requireDiscussion(artifact: Artifact | null, id: string): Artifact {
|
|
40
|
+
if (!artifact) throw new DiscussionError(`discussion "${id}" not found`);
|
|
41
|
+
if (!isDiscussionArtifact(artifact)) throw new DiscussionError(`artifact "${id}" is not a Discussion`);
|
|
42
|
+
return artifact;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class Discussions {
|
|
46
|
+
constructor(
|
|
47
|
+
private readonly artifacts: AtomicArtifactStore,
|
|
48
|
+
private readonly rounds: DiscussionRoundStore,
|
|
49
|
+
) {}
|
|
50
|
+
|
|
51
|
+
private extra(discussion: Artifact): DiscussionExtra {
|
|
52
|
+
return readDiscussionExtra(discussion.extra);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
open(input: OpenDiscussionInput, context?: ArtifactEventContext): DiscussionAndRounds {
|
|
56
|
+
const actor = validateDiscussionActor(input.actor);
|
|
57
|
+
const content = validateDiscussionContent(input.content);
|
|
58
|
+
return this.artifacts.atomic(() => {
|
|
59
|
+
const discussion = this.artifacts.create({
|
|
60
|
+
kind: "doc",
|
|
61
|
+
subtype: DISCUSSION_SUBTYPE,
|
|
62
|
+
title: input.title,
|
|
63
|
+
body: input.body ?? "",
|
|
64
|
+
status: "active",
|
|
65
|
+
labels: input.labels,
|
|
66
|
+
extra: { discussion: { state: "active", roundCount: 1 } },
|
|
67
|
+
}, context);
|
|
68
|
+
const round = this.rounds.append({ discussionId: discussion.id, roundNumber: 1, actor, content }, new Date().toISOString());
|
|
69
|
+
for (const taskId of input.blocksTaskIds ?? []) this.block(discussion.id, taskId, context);
|
|
70
|
+
return { discussion: this.artifacts.get(discussion.id)!, rounds: [round] };
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
reply(discussionId: string, actor: string, content: string, context?: ArtifactEventContext): DiscussionAndRounds {
|
|
75
|
+
const validActor = validateDiscussionActor(actor);
|
|
76
|
+
const validContent = validateDiscussionContent(content);
|
|
77
|
+
return this.artifacts.atomic(() => {
|
|
78
|
+
const discussion = requireDiscussion(this.artifacts.get(discussionId), discussionId);
|
|
79
|
+
const state = this.extra(discussion);
|
|
80
|
+
if (state.state !== "active") throw new DiscussionError(`discussion "${discussionId}" is ${state.state}; resume it before replying`);
|
|
81
|
+
if (state.roundCount >= DISCUSSION_MAX_ROUNDS) throw new DiscussionError(`discussion "${discussionId}" has reached its ${DISCUSSION_MAX_ROUNDS}-round limit; settle or defer it`);
|
|
82
|
+
const nextRound = state.roundCount + 1;
|
|
83
|
+
const round = this.rounds.append({ discussionId, roundNumber: nextRound, actor: validActor, content: validContent }, new Date().toISOString());
|
|
84
|
+
const updated = this.artifacts.setExtra(discussionId, { ...discussion.extra, discussion: { ...state, roundCount: nextRound } }, context)!;
|
|
85
|
+
return { discussion: updated, rounds: [round] };
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
defer(discussionId: string, reason?: string, context?: ArtifactEventContext): Artifact {
|
|
90
|
+
const validReason = reason === undefined ? undefined : validateDeferReason(reason);
|
|
91
|
+
return this.artifacts.atomic(() => {
|
|
92
|
+
const discussion = requireDiscussion(this.artifacts.get(discussionId), discussionId);
|
|
93
|
+
const state = this.extra(discussion);
|
|
94
|
+
if (state.state !== "active") throw new DiscussionError(`discussion "${discussionId}" is ${state.state}; only an active Discussion can be deferred`);
|
|
95
|
+
return this.artifacts.setExtra(discussionId, {
|
|
96
|
+
...discussion.extra,
|
|
97
|
+
discussion: { ...state, state: "deferred", ...(validReason === undefined ? {} : { deferredReason: validReason }) },
|
|
98
|
+
}, context)!;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
resume(discussionId: string, context?: ArtifactEventContext): Artifact {
|
|
103
|
+
return this.artifacts.atomic(() => {
|
|
104
|
+
const discussion = requireDiscussion(this.artifacts.get(discussionId), discussionId);
|
|
105
|
+
const state = this.extra(discussion);
|
|
106
|
+
if (state.state !== "deferred") throw new DiscussionError(`discussion "${discussionId}" is ${state.state}; only a deferred Discussion can be resumed`);
|
|
107
|
+
const { deferredReason: _deferredReason, ...rest } = state;
|
|
108
|
+
return this.artifacts.setExtra(discussionId, { ...discussion.extra, discussion: { ...rest, state: "active" } }, context)!;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
settle(discussionId: string, settlement: string, context?: ArtifactEventContext): Artifact {
|
|
113
|
+
const validSettlement = validateSettlement(settlement);
|
|
114
|
+
return this.artifacts.atomic(() => {
|
|
115
|
+
const discussion = requireDiscussion(this.artifacts.get(discussionId), discussionId);
|
|
116
|
+
const state = this.extra(discussion);
|
|
117
|
+
if (state.state === "settled") throw new DiscussionError(`discussion "${discussionId}" is already settled`);
|
|
118
|
+
const updated = this.artifacts.setExtra(discussionId, {
|
|
119
|
+
...discussion.extra,
|
|
120
|
+
discussion: { ...state, state: "settled", settlement: validSettlement, settledAt: new Date().toISOString() },
|
|
121
|
+
}, context)!;
|
|
122
|
+
return this.artifacts.setStatus(discussionId, "archived", context) ?? updated;
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Links an existing active Discussion to a Task it blocks; refuses a non-task target or an already-settled Discussion. */
|
|
127
|
+
block(discussionId: string, taskId: string, context?: ArtifactEventContext): void {
|
|
128
|
+
const discussion = requireDiscussion(this.artifacts.get(discussionId), discussionId);
|
|
129
|
+
if (this.extra(discussion).state === "settled") throw new DiscussionError(`discussion "${discussionId}" is settled; it can no longer block anything`);
|
|
130
|
+
const task = this.artifacts.get(taskId);
|
|
131
|
+
if (!task) throw new DiscussionError(`task "${taskId}" not found`);
|
|
132
|
+
if (task.kind !== "task") throw new DiscussionError(`artifact "${taskId}" is not a task`);
|
|
133
|
+
this.artifacts.link({ from: discussionId, relation: "blocks", to: taskId }, context);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Idempotent: unblocking an already-absent relationship is a no-op. */
|
|
137
|
+
unblock(discussionId: string, taskId: string, context?: ArtifactEventContext): boolean {
|
|
138
|
+
return this.artifacts.unlink({ from: discussionId, relation: "blocks", to: taskId }, context);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
show(discussionId: string): DiscussionAndRounds {
|
|
142
|
+
const discussion = requireDiscussion(this.artifacts.get(discussionId), discussionId);
|
|
143
|
+
return { discussion, rounds: this.rounds.list({ discussionId }) };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
listRounds(discussionId: string, afterRound?: number, limit?: number): DiscussionRound[] {
|
|
147
|
+
requireDiscussion(this.artifacts.get(discussionId), discussionId);
|
|
148
|
+
return this.rounds.list({ discussionId, afterRound, limit });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
list(filter: { state?: string; limit?: number } = {}): Artifact[] {
|
|
152
|
+
// DISCUSSION_LIST_MAX_LIMIT/DEFAULT_LIMIT exist specifically so an unqualified discuss.list
|
|
153
|
+
// (limit omitted) can never fall through to queryArtifacts' own unbounded default -- the same
|
|
154
|
+
// class of gap notes.ts's noteListInput comment documents fixing for Notes.
|
|
155
|
+
const limit = Math.min(DISCUSSION_LIST_MAX_LIMIT, Math.max(1, Math.floor(filter.limit ?? DISCUSSION_LIST_DEFAULT_LIMIT)));
|
|
156
|
+
const rows = this.artifacts.query({ kind: "doc", subtype: DISCUSSION_SUBTYPE, limit });
|
|
157
|
+
if (!filter.state) return rows;
|
|
158
|
+
return rows.filter((row) => {
|
|
159
|
+
try { return this.extra(row).state === filter.state; } catch { return false; }
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discuss: a native Papyrus deliberation with a real lifecycle, distinct from a one-shot
|
|
3
|
+
* "ask" (see the design discussion this implements) and from Discourse's forum (kept fully
|
|
4
|
+
* standalone by design -- no dependency here, Discuss reuses none of its storage or wire
|
|
5
|
+
* shape). A Discussion is a `doc` artifact with subtype "discussion": real graph citizenship
|
|
6
|
+
* (edges, show/list) without a fifth enforced artifact kind. Its fine-grained lifecycle
|
|
7
|
+
* lives in extra.discussion rather than the shared doc status vocabulary, since Papyrus
|
|
8
|
+
* enforces status per-kind, not per-subtype -- "deferred" has no equivalent among a plain
|
|
9
|
+
* doc's draft/active/archived. The doc's own status column follows loosely: "active" while
|
|
10
|
+
* extra.discussion.state is active or deferred, "archived" once settled.
|
|
11
|
+
*
|
|
12
|
+
* Blocking is the forcing, load-bearing behavior a Discussion adds over a passive record:
|
|
13
|
+
* an "active" Discussion that `blocks` a Task refuses that Task's completion (see
|
|
14
|
+
* task-service.ts's blockingDiscussions) until the Discussion is settled or deferred.
|
|
15
|
+
* Deferred is explicitly non-blocking -- "we will get back to this," not "resolved".
|
|
16
|
+
*/
|
|
17
|
+
import {
|
|
18
|
+
DISCUSSION_ACTOR_MAX_LENGTH,
|
|
19
|
+
DISCUSSION_DEFER_REASON_MAX_CHARACTERS,
|
|
20
|
+
DISCUSSION_ROUND_CONTENT_MAX_CHARACTERS,
|
|
21
|
+
DISCUSSION_SETTLEMENT_MAX_CHARACTERS,
|
|
22
|
+
} from "../constants.ts";
|
|
23
|
+
|
|
24
|
+
export const DISCUSSION_SUBTYPE = "discussion";
|
|
25
|
+
|
|
26
|
+
export const DISCUSSION_STATES = ["active", "deferred", "settled"] as const;
|
|
27
|
+
export type DiscussionState = typeof DISCUSSION_STATES[number];
|
|
28
|
+
|
|
29
|
+
/** Persisted in a discussion Doc's `extra.discussion`. */
|
|
30
|
+
export interface DiscussionExtra {
|
|
31
|
+
state: DiscussionState;
|
|
32
|
+
roundCount: number;
|
|
33
|
+
deferredReason?: string;
|
|
34
|
+
settlement?: string;
|
|
35
|
+
settledAt?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One append-only round of a Discussion -- opening statement is round 1. */
|
|
39
|
+
export interface DiscussionRound {
|
|
40
|
+
id: number;
|
|
41
|
+
discussionId: string;
|
|
42
|
+
roundNumber: number;
|
|
43
|
+
actor: string;
|
|
44
|
+
content: string;
|
|
45
|
+
occurredAt: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface AppendDiscussionRound {
|
|
49
|
+
discussionId: string;
|
|
50
|
+
roundNumber: number;
|
|
51
|
+
actor: string;
|
|
52
|
+
content: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface DiscussionRoundQuery {
|
|
56
|
+
discussionId: string;
|
|
57
|
+
afterRound?: number;
|
|
58
|
+
limit?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function boundedString(value: string, field: string, maximum: number): string {
|
|
62
|
+
if (value.length === 0 || value.length > maximum) throw new Error(`${field} must be between 1 and ${maximum} characters`);
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function validateDiscussionContent(content: string): string {
|
|
67
|
+
return boundedString(content, "content", DISCUSSION_ROUND_CONTENT_MAX_CHARACTERS);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function validateDiscussionActor(actor: string): string {
|
|
71
|
+
return boundedString(actor, "actor", DISCUSSION_ACTOR_MAX_LENGTH);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function validateDeferReason(reason: string): string {
|
|
75
|
+
return boundedString(reason, "reason", DISCUSSION_DEFER_REASON_MAX_CHARACTERS);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function validateSettlement(settlement: string): string {
|
|
79
|
+
return boundedString(settlement, "settlement", DISCUSSION_SETTLEMENT_MAX_CHARACTERS);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** True for any artifact (already fetched) that is a Discussion, regardless of its current lifecycle state. */
|
|
83
|
+
export function isDiscussionArtifact(artifact: { kind: string; subtype: string }): boolean {
|
|
84
|
+
return artifact.kind === "doc" && artifact.subtype === DISCUSSION_SUBTYPE;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Reads and defensively validates the extra.discussion shape; throws on a corrupt/foreign shape rather than silently treating it as some default state. */
|
|
88
|
+
export function readDiscussionExtra(extra: Record<string, unknown>): DiscussionExtra {
|
|
89
|
+
const raw = extra["discussion"];
|
|
90
|
+
if (typeof raw !== "object" || raw === null) throw new Error("artifact is not a Discussion (missing extra.discussion)");
|
|
91
|
+
const record = raw as Record<string, unknown>;
|
|
92
|
+
const state = record["state"];
|
|
93
|
+
if (typeof state !== "string" || !(DISCUSSION_STATES as readonly string[]).includes(state)) {
|
|
94
|
+
throw new Error(`invalid Discussion state "${String(state)}"`);
|
|
95
|
+
}
|
|
96
|
+
const roundCount = record["roundCount"];
|
|
97
|
+
if (typeof roundCount !== "number" || !Number.isInteger(roundCount) || roundCount < 0) {
|
|
98
|
+
throw new Error("invalid Discussion roundCount");
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
state: state as DiscussionState,
|
|
102
|
+
roundCount,
|
|
103
|
+
...(typeof record["deferredReason"] === "string" ? { deferredReason: record["deferredReason"] } : {}),
|
|
104
|
+
...(typeof record["settlement"] === "string" ? { settlement: record["settlement"] } : {}),
|
|
105
|
+
...(typeof record["settledAt"] === "string" ? { settledAt: record["settledAt"] } : {}),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* modules/discuss.ts — Discuss as a Papyrus-native registered module. See
|
|
3
|
+
* domain/discussion.ts and discussion-service.ts for the full design.
|
|
4
|
+
*/
|
|
5
|
+
import type { ArtifactEventContext } from "../domain/artifact-event.ts";
|
|
6
|
+
import type { Discussions } from "../discussion-service.ts";
|
|
7
|
+
import type { OperationDefinition } from "../module-registry.ts";
|
|
8
|
+
|
|
9
|
+
const MODULE_ID = "discuss";
|
|
10
|
+
|
|
11
|
+
type OperationInput = Record<string, unknown>;
|
|
12
|
+
|
|
13
|
+
function string(input: OperationInput, key: string): string {
|
|
14
|
+
const value = input[key];
|
|
15
|
+
if (typeof value !== "string" || value.length === 0) throw new Error(`${key} is required`);
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function optionalString(input: OperationInput, key: string): string | undefined {
|
|
20
|
+
const value = input[key];
|
|
21
|
+
if (value === undefined) return undefined;
|
|
22
|
+
if (typeof value !== "string") throw new Error(`${key} must be a string`);
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function optionalStringArray(input: OperationInput, key: string): string[] | undefined {
|
|
27
|
+
const value = input[key];
|
|
28
|
+
if (value === undefined) return undefined;
|
|
29
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) throw new Error(`${key} must be an array of strings`);
|
|
30
|
+
return value as string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function optionalNumber(input: OperationInput, key: string): number | undefined {
|
|
34
|
+
const value = input[key];
|
|
35
|
+
if (value === undefined) return undefined;
|
|
36
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${key} must be a number`);
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const eventContext = (input: OperationInput): ArtifactEventContext => ({
|
|
41
|
+
actor: optionalString(input, "actor"),
|
|
42
|
+
source: optionalString(input, "source"),
|
|
43
|
+
sessionId: optionalString(input, "session_id") ?? optionalString(input, "sessionId"),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function taskId(input: OperationInput): string {
|
|
47
|
+
const value = optionalString(input, "task_id") ?? optionalString(input, "taskId");
|
|
48
|
+
if (!value) throw new Error("task_id is required");
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 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
|
+
export const DISCUSS_OPERATION_NAMES = [
|
|
54
|
+
"discuss.open", "discuss.reply", "discuss.defer", "discuss.resume", "discuss.settle",
|
|
55
|
+
"discuss.block", "discuss.unblock", "discuss.show", "discuss.rounds", "discuss.list",
|
|
56
|
+
] as const;
|
|
57
|
+
|
|
58
|
+
/** Registers every discuss.* operation against one Discussions instance. */
|
|
59
|
+
export function discussOperations(discussions: Discussions): OperationDefinition[] {
|
|
60
|
+
const define = <Input, Output>(name: string, execute: (input: Input) => Output): OperationDefinition<Input, Output> => ({
|
|
61
|
+
name, moduleId: MODULE_ID, execute,
|
|
62
|
+
});
|
|
63
|
+
return [
|
|
64
|
+
define("discuss.open", (input: OperationInput) => discussions.open({
|
|
65
|
+
title: string(input, "title"),
|
|
66
|
+
actor: string(input, "actor"),
|
|
67
|
+
content: string(input, "content"),
|
|
68
|
+
body: optionalString(input, "body"),
|
|
69
|
+
labels: optionalStringArray(input, "labels"),
|
|
70
|
+
blocksTaskIds: optionalStringArray(input, "blocks_task_ids") ?? optionalStringArray(input, "blocksTaskIds"),
|
|
71
|
+
}, eventContext(input))),
|
|
72
|
+
define("discuss.reply", (input: OperationInput) => discussions.reply(string(input, "id"), string(input, "actor"), string(input, "content"), eventContext(input))),
|
|
73
|
+
define("discuss.defer", (input: OperationInput) => discussions.defer(string(input, "id"), optionalString(input, "reason"), eventContext(input))),
|
|
74
|
+
define("discuss.resume", (input: OperationInput) => discussions.resume(string(input, "id"), eventContext(input))),
|
|
75
|
+
define("discuss.settle", (input: OperationInput) => discussions.settle(string(input, "id"), string(input, "settlement"), eventContext(input))),
|
|
76
|
+
define("discuss.block", (input: OperationInput) => {
|
|
77
|
+
discussions.block(string(input, "id"), taskId(input), eventContext(input));
|
|
78
|
+
return { blocked: true };
|
|
79
|
+
}),
|
|
80
|
+
define("discuss.unblock", (input: OperationInput) => ({
|
|
81
|
+
unblocked: discussions.unblock(string(input, "id"), taskId(input), eventContext(input)),
|
|
82
|
+
})),
|
|
83
|
+
define("discuss.show", (input: OperationInput) => discussions.show(string(input, "id"))),
|
|
84
|
+
define("discuss.rounds", (input: OperationInput) => discussions.listRounds(string(input, "id"), optionalNumber(input, "after_round") ?? optionalNumber(input, "afterRound"), optionalNumber(input, "limit"))),
|
|
85
|
+
define("discuss.list", (input: OperationInput) => discussions.list({ state: optionalString(input, "state"), limit: optionalNumber(input, "limit") })),
|
|
86
|
+
];
|
|
87
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AppendDiscussionRound, DiscussionRound, DiscussionRoundQuery } from "../domain/discussion.ts";
|
|
2
|
+
|
|
3
|
+
/** Persistence port for a Discussion's append-only rounds (see domain/discussion.ts). */
|
|
4
|
+
export interface DiscussionRoundStore {
|
|
5
|
+
append(round: AppendDiscussionRound, occurredAt: string): DiscussionRound;
|
|
6
|
+
list(query: DiscussionRoundQuery): DiscussionRound[];
|
|
7
|
+
count(discussionId: string): number;
|
|
8
|
+
}
|
package/src/service.ts
CHANGED
|
@@ -34,7 +34,10 @@ import { notesOperations, NOTES_OPERATION_NAMES } from "./modules/notes.ts";
|
|
|
34
34
|
import { rulesOperations, RULES_OPERATION_NAMES } from "./modules/rules.ts";
|
|
35
35
|
import { skillsOperations, SKILLS_OPERATION_NAMES } from "./modules/skills.ts";
|
|
36
36
|
import { sessionIdentityOperations, SESSION_IDENTITY_OPERATION_NAMES } from "./modules/session-identity.ts";
|
|
37
|
+
import { discussOperations, DISCUSS_OPERATION_NAMES } from "./modules/discuss.ts";
|
|
37
38
|
import { tasksOperations, TASKS_OPERATION_NAMES } from "./modules/tasks.ts";
|
|
39
|
+
import { Discussions } from "./discussion-service.ts";
|
|
40
|
+
import { SQLiteDiscussionRoundStore } from "./adapters/sqlite-discussion-round-store.ts";
|
|
38
41
|
|
|
39
42
|
/**
|
|
40
43
|
* Operations with no registered module: the generic, cross-cutting kernel surface
|
|
@@ -73,6 +76,7 @@ export const EXPECTED_OPERATION_NAMES = [
|
|
|
73
76
|
...GRAPH_PROJECTION_OPERATION_NAMES,
|
|
74
77
|
...LOGS_OPERATION_NAMES,
|
|
75
78
|
...SESSION_IDENTITY_OPERATION_NAMES,
|
|
79
|
+
...DISCUSS_OPERATION_NAMES,
|
|
76
80
|
] as const;
|
|
77
81
|
|
|
78
82
|
export type OperationName = typeof EXPECTED_OPERATION_NAMES[number];
|
|
@@ -383,6 +387,16 @@ function handlers(
|
|
|
383
387
|
"logs.query": forwardToModule("logs.query"),
|
|
384
388
|
"session.register": forwardToModule("session.register"),
|
|
385
389
|
"session.release": forwardToModule("session.release"),
|
|
390
|
+
"discuss.open": forwardToModule("discuss.open"),
|
|
391
|
+
"discuss.reply": forwardToModule("discuss.reply"),
|
|
392
|
+
"discuss.defer": forwardToModule("discuss.defer"),
|
|
393
|
+
"discuss.resume": forwardToModule("discuss.resume"),
|
|
394
|
+
"discuss.settle": forwardToModule("discuss.settle"),
|
|
395
|
+
"discuss.block": forwardToModule("discuss.block"),
|
|
396
|
+
"discuss.unblock": forwardToModule("discuss.unblock"),
|
|
397
|
+
"discuss.show": forwardToModule("discuss.show"),
|
|
398
|
+
"discuss.rounds": forwardToModule("discuss.rounds"),
|
|
399
|
+
"discuss.list": forwardToModule("discuss.list"),
|
|
386
400
|
};
|
|
387
401
|
}
|
|
388
402
|
|
|
@@ -399,11 +413,13 @@ export function createPapyrusService(path: string): PapyrusService {
|
|
|
399
413
|
const artifactScopes = new SQLiteArtifactScopeStore(db);
|
|
400
414
|
const logs = new Logs(new SQLiteLogStore(db));
|
|
401
415
|
const sessionIdentity = new SessionIdentity(new SQLiteSessionIdentityStore(db));
|
|
416
|
+
const discussions = new Discussions(artifacts, new SQLiteDiscussionRoundStore(db));
|
|
402
417
|
const authority = createAuthorityRegistry();
|
|
403
418
|
const moduleRegistry = new OperationRegistry();
|
|
404
419
|
moduleRegistry.registerAll(notesOperations(notes));
|
|
405
420
|
moduleRegistry.registerAll(logsOperations(logs));
|
|
406
421
|
moduleRegistry.registerAll(sessionIdentityOperations(sessionIdentity));
|
|
422
|
+
moduleRegistry.registerAll(discussOperations(discussions));
|
|
407
423
|
moduleRegistry.registerAll(tasksOperations(tasks, artifacts, sessionIdentity));
|
|
408
424
|
moduleRegistry.registerAll(docsOperations(artifacts, artifactScopes, authority));
|
|
409
425
|
moduleRegistry.registerAll(rulesOperations(artifacts, artifactScopes));
|
package/src/task-service.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "./constants.ts";
|
|
12
12
|
import type { Artifact } from "./domain/artifact.ts";
|
|
13
13
|
import { checklistEntries, validateChecklist, type Checklist, type ProofReference } from "./domain/checklist.ts";
|
|
14
|
+
import { isDiscussionArtifact, readDiscussionExtra } from "./domain/discussion.ts";
|
|
14
15
|
import type { Gate, GateResult } from "./domain/gate.ts";
|
|
15
16
|
import type { AppendTaskEvent, TaskEventContext, TaskHistoryPage, TaskHistoryQuery, TaskLifecycleStatus } from "./domain/task-event.ts";
|
|
16
17
|
import { normalizeProjectRoot, taskScopeLabel, type TaskScopeSource, type TaskViewMode, type TaskViewSelection } from "./domain/task-scope.ts";
|
|
@@ -419,6 +420,7 @@ export class Tasks {
|
|
|
419
420
|
|
|
420
421
|
complete(id: string, context: TaskEventContext = {}, options: TaskCompletionOptions = {}): TaskCompletion {
|
|
421
422
|
const task = this.requireReview(id);
|
|
423
|
+
this.requireNotBlocked(id);
|
|
422
424
|
const attemptId = crypto.randomUUID();
|
|
423
425
|
this.events.atomic(() => this.appendEvent({ taskId: id, type: "completion_attempted", fromStatus: "review", toStatus: "review", attemptId }, context));
|
|
424
426
|
const checklist = this.reviewChecklist(task);
|
|
@@ -428,6 +430,7 @@ export class Tasks {
|
|
|
428
430
|
|
|
429
431
|
async completeAsync(id: string, context: TaskEventContext = {}, options: TaskCompletionOptions = {}): Promise<TaskCompletion> {
|
|
430
432
|
const task = this.requireReview(id);
|
|
433
|
+
this.requireNotBlocked(id);
|
|
431
434
|
const attemptId = crypto.randomUUID();
|
|
432
435
|
this.events.atomic(() => this.appendEvent({ taskId: id, type: "completion_attempted", fromStatus: "review", toStatus: "review", attemptId }, context));
|
|
433
436
|
const checklist = this.reviewChecklist(task);
|
|
@@ -678,4 +681,28 @@ export class Tasks {
|
|
|
678
681
|
if (task.status !== "review") throw new Error(`cannot complete task from ${task.status}`);
|
|
679
682
|
return task;
|
|
680
683
|
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Discuss's forcing behavior (see domain/discussion.ts): an active Discussion doc that
|
|
687
|
+
* `blocks` this task refuses its completion until settled or deferred. A discussion whose
|
|
688
|
+
* extra.discussion shape is missing or corrupt is treated as non-blocking rather than
|
|
689
|
+
* crashing completion -- the same fail-open posture Task Focus's opt-in armor uses for an
|
|
690
|
+
* unrecognized shape.
|
|
691
|
+
*/
|
|
692
|
+
private blockingDiscussions(id: string): Artifact[] {
|
|
693
|
+
return this.artifacts.relationships({ artifactIds: [id] })
|
|
694
|
+
.filter((edge) => edge.relation === "blocks" && edge.to === id)
|
|
695
|
+
.map((edge) => this.artifacts.get(edge.from))
|
|
696
|
+
.filter((source): source is Artifact => source !== null && isDiscussionArtifact(source))
|
|
697
|
+
.filter((discussion) => {
|
|
698
|
+
try { return readDiscussionExtra(discussion.extra).state === "active"; } catch { return false; }
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
private requireNotBlocked(id: string): void {
|
|
703
|
+
const blockers = this.blockingDiscussions(id);
|
|
704
|
+
if (blockers.length > 0) {
|
|
705
|
+
throw new Error(`task "${id}" is blocked by ${blockers.length} active Discussion(s): ${blockers.map((discussion) => discussion.id).join(", ")}`);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
681
708
|
}
|