@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 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
  }
@@ -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,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.18.0",
3
+ "version": "0.19.1",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],
@@ -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 = 14;
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));
@@ -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
  }