@danypops/papyrus 0.17.2 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -100,6 +100,12 @@ papyrus skills run <skill-id> \
100
100
 
101
101
  The existing `artifact-template` skill subtype remains a compatibility mechanism for one-artifact templates with metadata `{targetKind, defaults, required}`. Instantiate it through `papyrus_create` with `template_id`; defaults merge recursively, explicit arrays replace defaults, required paths such as `extra.owner` are validated, and target-kind mismatches are rejected.
102
102
 
103
+ ### Removing an artifact
104
+
105
+ Artifacts are never hard-deleted on request: every artifact gets a permanent, immutable `created` row in the mutation event log the moment it exists, so removal is a real, time-gated trash rather than a status flip. `remove` (any of the `tasks`/`docs`/`rules`/`skills` domain tools, or `papyrus artifact remove <id> [--reason <text>]`) moves an artifact to the trash: it is immediately excluded from every list/query, still directly reachable by id, and fully recoverable via `restore` until its purge deadline (30 days later) passes. `remove` refuses a Task that is the live Task Focus in any scope.
106
+
107
+ Once the deadline passes, the daemon's periodic sweep performs a real, cascading, irreversible deletion — the one deliberate, narrow exception to Papyrus's otherwise-absolute append-only history, enforced by the database itself (not merely application code) via a trigger condition checked at delete time.
108
+
103
109
  ## Tools
104
110
 
105
111
  The `papyrus_*` tools are the low-level graph-store API:
@@ -148,6 +154,23 @@ papyrus notes promote <note-id> <target-id> --reason "Converted to tracked work"
148
154
  papyrus notes archive <note-id> declined --reason "No longer relevant" --json
149
155
  ```
150
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
+ ```bash
166
+ papyrus discuss open --title "Naming" --actor alice --content "Should we rename this?" --blocks-json '["task-id"]' --json
167
+ papyrus discuss reply <discussion-id> --actor bob --content "I think so, here's why..." --json
168
+ papyrus discuss defer <discussion-id> --reason "Waiting on design review" --json
169
+ papyrus discuss resume <discussion-id> --json
170
+ papyrus discuss settle <discussion-id> --settlement "Agreed: renaming to X" --json
171
+ papyrus discuss show <discussion-id> --json
172
+ ```
173
+
151
174
  ## Tasks
152
175
 
153
176
  Run `/tasks` for the interactive task panel:
@@ -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";
@@ -31,6 +33,26 @@ function artifactLine(artifact: Artifact): string {
31
33
  return `${artifact.id} [${artifact.status}] ${artifact.title}`;
32
34
  }
33
35
 
36
+ /**
37
+ * Shared "remove"/"restore" dispatch for every domain tool (tasks/docs/rules/skills) --
38
+ * artifact.remove/restore are kind-agnostic composition-root operations (see service.ts),
39
+ * not owned by any one domain module, so every domain tool exposes the same two actions
40
+ * over the same two operations rather than reinventing trash semantics four times.
41
+ * Returns null when action is neither, so callers fall through to their own dispatch.
42
+ */
43
+ async function handleArtifactRemoveRestore(action: unknown, params: Record<string, unknown>): Promise<ReturnType<typeof text> | null> {
44
+ if (action === "remove") {
45
+ const record = await callService<Record<string, unknown>, { artifactId: string; trashedAt: string; purgeAfter: string; reason?: string }>("artifact.remove", params);
46
+ return text(`Trashed ${record.artifactId}, eligible for purge at ${record.purgeAfter}.`, createPreviewDetails("artifact.remove", "Trashed", record.artifactId));
47
+ }
48
+ if (action === "restore") {
49
+ const outcome = await callService<Record<string, unknown>, { restored: boolean }>("artifact.restore", params);
50
+ const output = outcome.restored ? `Restored ${params["id"]}.` : `${params["id"]} was not trashed.`;
51
+ return text(output, createPreviewDetails("artifact.restore", "Restored", output));
52
+ }
53
+ return null;
54
+ }
55
+
34
56
  const proofReferenceSchema = Type.Object({
35
57
  type: Type.Union(PROOF_TYPES.map((type) => Type.Literal(type))),
36
58
  target: Type.String(),
@@ -45,7 +67,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
45
67
  pi.registerTool({
46
68
  name: "tasks",
47
69
  label: "Tasks",
48
- description: "Task domain tool. ACTIONS: create, update, list, show, history, scope, set_scope, assign_project, graph, plan, active, focused, focus, pause, unpause, clear_focus, start, submit, complete, reject, retry, cancel, run_gates, set_checklist, depend, undepend, contain, uncontain. Lifecycle is todo → in-progress → review → done, with review failure → rejected and retry → in-progress; canceled is terminal. update can recover a Task accidentally created terminal by setting status=todo with a reason, but cannot rewrite legitimate lifecycle history. Active focus is independent and identifies the one task auto-drive continues. Completion runs gates and checklist-proof review, then focuses one deterministic ready successor without claiming effort. Dependency cycles are rejected. undepend/uncontain are idempotent for an already-absent relationship and never start, complete, or focus work merely because an edge disappeared; uncontain removes both contains and part_of edges atomically. Prefer this over low-level papyrus_* tools for task work.",
70
+ description: "Task domain tool. ACTIONS: create, update, list, show, history, scope, set_scope, assign_project, graph, plan, active, focused, focus, pause, unpause, clear_focus, start, submit, complete, reject, retry, cancel, run_gates, set_checklist, depend, undepend, contain, uncontain, remove, restore. Lifecycle is todo → in-progress → review → done, with review failure → rejected and retry → in-progress; canceled is terminal. update can recover a Task accidentally created terminal by setting status=todo with a reason, but cannot rewrite legitimate lifecycle history. Active focus is independent and identifies the one task auto-drive continues. Completion runs gates and checklist-proof review, then focuses one deterministic ready successor without claiming effort. Dependency cycles are rejected. undepend/uncontain are idempotent for an already-absent relationship and never start, complete, or focus work merely because an edge disappeared; uncontain removes both contains and part_of edges atomically. remove moves a Task to a time-gated trash (restorable via restore until the purge deadline; refuses if it is the live Task Focus). Prefer this over low-level papyrus_* tools for task work.",
49
71
  parameters: Type.Object({
50
72
  action: Type.String(),
51
73
  id: Type.Optional(Type.String()),
@@ -181,6 +203,8 @@ export function registerDomainTools(pi: ExtensionAPI): void {
181
203
  }))),
182
204
  );
183
205
  }
206
+ const trashResult = await handleArtifactRemoveRestore(action, params);
207
+ if (trashResult) return trashResult;
184
208
  const operations = {
185
209
  focus: "tasks.focus",
186
210
  start: "tasks.start",
@@ -257,7 +281,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
257
281
  pi.registerTool({
258
282
  name: "docs",
259
283
  label: "Documents",
260
- description: "Document domain tool. ACTIONS: create, list, show, activate, archive, reopen, link, assign_project. project_root is optional at creation (omitted = unscoped); assign_project reassigns it later, or unscopes when project_root is omitted. Prefer this over low-level papyrus_* tools for document work.",
284
+ description: "Document domain tool. ACTIONS: create, list, show, activate, archive, reopen, link, assign_project, remove, restore. project_root is optional at creation (omitted = unscoped); assign_project reassigns it later, or unscopes when project_root is omitted. remove moves a Doc to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline. Prefer this over low-level papyrus_* tools for document work.",
261
285
  parameters: Type.Object({
262
286
  action: Type.String(),
263
287
  id: Type.Optional(Type.String()),
@@ -273,6 +297,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
273
297
  relation: Type.Optional(Type.String()),
274
298
  target_id: Type.Optional(Type.String()),
275
299
  project_root: Type.Optional(Type.String()),
300
+ reason: Type.Optional(Type.String()),
276
301
  }),
277
302
  renderCall(args, theme) { return renderPapyrusToolCall("Documents", args, theme); },
278
303
  renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
@@ -291,6 +316,8 @@ export function registerDomainTools(pi: ExtensionAPI): void {
291
316
  const artifact = await callService<Record<string, unknown>, Artifact>("docs.show", params);
292
317
  return text(`${artifactLine(artifact)}\n\n${artifact.body}`, createArtifactDetails("docs.show", artifact));
293
318
  }
319
+ const trashResult = await handleArtifactRemoveRestore(action, params);
320
+ if (trashResult) return trashResult;
294
321
  const operations = { activate: "docs.activate", archive: "docs.archive", reopen: "docs.reopen", link: "docs.link", assign_project: "docs.assign_project" } as const;
295
322
  const operation = operations[action as keyof typeof operations];
296
323
  if (!operation) throw new Error(`unknown docs action: ${action}`);
@@ -305,14 +332,14 @@ export function registerDomainTools(pi: ExtensionAPI): void {
305
332
  pi.registerTool({
306
333
  name: "rules",
307
334
  label: "Rules",
308
- description: "Rule domain tool. ACTIONS: create, list, show, preview, enable, disable, gate, assign_project. project_root is optional at creation (omitted = unscoped); assign_project reassigns it later, or unscopes when project_root is omitted. Active rules inject into the agent system prompt.",
335
+ description: "Rule domain tool. ACTIONS: create, list, show, preview, enable, disable, gate, assign_project, remove, restore. project_root is optional at creation (omitted = unscoped); assign_project reassigns it later, or unscopes when project_root is omitted. Active rules inject into the agent system prompt. remove moves a Rule to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline.",
309
336
  parameters: Type.Object({
310
337
  action: Type.String(), id: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
311
338
  body: Type.Optional(Type.String()), condition: Type.Optional(Type.String()), rule_action: Type.Optional(Type.String()),
312
339
  severity: Type.Optional(Type.String()), labels: Type.Optional(Type.Array(Type.String())),
313
340
  extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())), status: Type.Optional(Type.String()),
314
341
  text: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()), task_id: Type.Optional(Type.String()),
315
- project_root: Type.Optional(Type.String()),
342
+ project_root: Type.Optional(Type.String()), reason: Type.Optional(Type.String()),
316
343
  }),
317
344
  renderCall(args, theme) { return renderPapyrusToolCall("Rules", args, theme); },
318
345
  renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
@@ -331,6 +358,8 @@ export function registerDomainTools(pi: ExtensionAPI): void {
331
358
  const preview = await callService<Record<string, unknown>, string>("rules.preview", params);
332
359
  return text(preview, createPreviewDetails("rules.preview", "Rule preview", preview));
333
360
  }
361
+ const trashResult = await handleArtifactRemoveRestore(action, params);
362
+ if (trashResult) return trashResult;
334
363
  const operations = { show: "rules.show", enable: "rules.enable", disable: "rules.disable", gate: "rules.gate", assign_project: "rules.assign_project" } as const;
335
364
  const operation = operations[action as keyof typeof operations];
336
365
  if (!operation) throw new Error(`unknown rules action: ${action}`);
@@ -345,7 +374,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
345
374
  pi.registerTool({
346
375
  name: "skills",
347
376
  label: "Skills",
348
- description: "Papyrus Skill workflow and compatibility-template domain tool. Papyrus Skills are parameterized Task/Rule/Doc bundles, distinct from prompt-only skills. ACTIONS: create, create_template, list, show, invoke, run, enable, disable, instantiate, assign_project. run validates arguments and atomically creates one scoped workflow run. project_root is optional at creation (omitted = unscoped) for create/create_template; assign_project reassigns it later, or unscopes when project_root is omitted.",
377
+ description: "Papyrus Skill workflow and compatibility-template domain tool. Papyrus Skills are parameterized Task/Rule/Doc bundles, distinct from prompt-only skills. ACTIONS: create, create_template, list, show, invoke, run, enable, disable, instantiate, assign_project, remove, restore. run validates arguments and atomically creates one scoped workflow run. project_root is optional at creation (omitted = unscoped) for create/create_template; assign_project reassigns it later, or unscopes when project_root is omitted. remove moves a Skill to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline.",
349
378
  parameters: Type.Object({
350
379
  action: Type.String(), id: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
351
380
  body: Type.Optional(Type.String()), trigger: Type.Optional(Type.String()), steps: Type.Optional(Type.Array(Type.String())),
@@ -356,7 +385,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
356
385
  text: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()), template_id: Type.Optional(Type.String()),
357
386
  target_kind: Type.Optional(Type.String()), defaults: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
358
387
  required: Type.Optional(Type.Array(Type.String())), kind: Type.Optional(Type.String()), subtype: Type.Optional(Type.String()),
359
- project_root: Type.Optional(Type.String()),
388
+ project_root: Type.Optional(Type.String()), reason: Type.Optional(Type.String()),
360
389
  }),
361
390
  renderCall(args, theme) { return renderPapyrusToolCall("Skills", args, theme); },
362
391
  renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
@@ -393,6 +422,8 @@ export function registerDomainTools(pi: ExtensionAPI): void {
393
422
  roots: run.rootTaskIds,
394
423
  }));
395
424
  }
425
+ const trashResult = await handleArtifactRemoveRestore(action, params);
426
+ if (trashResult) return trashResult;
396
427
  const operations = { show: "skills.show", enable: "skills.enable", disable: "skills.disable", instantiate: "skills.instantiate", assign_project: "skills.assign_project" } as const;
397
428
  const operation = operations[action as keyof typeof operations];
398
429
  if (!operation) throw new Error(`unknown skills action: ${action}`);
@@ -403,4 +434,72 @@ export function registerDomainTools(pi: ExtensionAPI): void {
403
434
  }
404
435
  },
405
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
+ });
406
505
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.17.2",
3
+ "version": "0.19.0",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],
@@ -12,7 +12,23 @@ import type {
12
12
  UpdateArtifactInput,
13
13
  } from "../domain/artifact.ts";
14
14
  import type { ArtifactEventContext, ArtifactEventPage, ArtifactEventQuery } from "../domain/artifact-event.ts";
15
- import { createArtifact, getArtifact, linkArtifacts, queryArtifactEvents, queryArtifacts, unlinkArtifacts, updateArtifactContent, updateExtra, updateStatus } from "../ops.ts";
15
+ import type { ArtifactTrashRecord } from "../domain/artifact-trash.ts";
16
+ import {
17
+ createArtifact,
18
+ getArtifact,
19
+ getArtifactTrash,
20
+ linkArtifacts,
21
+ listArtifactTrash,
22
+ purgeDueArtifacts,
23
+ queryArtifactEvents,
24
+ queryArtifacts,
25
+ restoreArtifact,
26
+ trashArtifact,
27
+ unlinkArtifacts,
28
+ updateArtifactContent,
29
+ updateExtra,
30
+ updateStatus,
31
+ } from "../ops.ts";
16
32
 
17
33
  export class SQLiteArtifactStore implements AtomicArtifactStore {
18
34
  constructor(private readonly db: Db) {}
@@ -57,6 +73,26 @@ export class SQLiteArtifactStore implements AtomicArtifactStore {
57
73
  return queryArtifactEvents(this.db, query);
58
74
  }
59
75
 
76
+ trash(id: string, options?: { reason?: string; context?: ArtifactEventContext }): ArtifactTrashRecord {
77
+ return trashArtifact(this.db, id, { reason: options?.reason, context: options?.context });
78
+ }
79
+
80
+ restore(id: string, context?: ArtifactEventContext): { restored: boolean } {
81
+ return restoreArtifact(this.db, id, context);
82
+ }
83
+
84
+ trashStatus(id: string): ArtifactTrashRecord | null {
85
+ return getArtifactTrash(this.db, id);
86
+ }
87
+
88
+ listTrash(): ArtifactTrashRecord[] {
89
+ return listArtifactTrash(this.db);
90
+ }
91
+
92
+ purgeDueTrash(): number {
93
+ return purgeDueArtifacts(this.db);
94
+ }
95
+
60
96
  relationships(filter: RelationshipQuery = {}): ArtifactEdge[] {
61
97
  if (filter.artifactIds?.length === 0) return [];
62
98
  const conditions: string[] = [];
@@ -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
@@ -82,6 +82,10 @@ const USAGE = `Usage:
82
82
  papyrus artifact create --kind <kind> [--title <title>] [--status <status>] [--subtype <subtype>] [--body <body>] [--labels-json <json>] [--extra-json <json>] [--template-id <id>] [--json]
83
83
  papyrus artifact query [--kind <kind>] [--status <status>] [--text <query>] [--limit <count>] [--json]
84
84
  papyrus artifact show <id> [--depth <n>] [--max-nodes <n>] [--json]
85
+ papyrus artifact remove <id> [--reason <text>] [--json]
86
+ papyrus artifact restore <id> [--json]
87
+ papyrus artifact trash-status <id> [--json]
88
+ papyrus artifact trash-list [--json]
85
89
  papyrus docs create --title <title> [--body <body>] [--subtype <subtype>] [--labels-json <json>] [--extra-json <json>] [--template-id <id>] [--project-root <path>] [--json]
86
90
  papyrus docs list [--status <status>] [--text <query>] [--limit <count>] [--project-root <path>] [--json]
87
91
  papyrus docs show <id> [--json]
@@ -114,6 +118,16 @@ const USAGE = `Usage:
114
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]
115
119
  papyrus session register --session-id <id> [--json]
116
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]
117
131
  papyrus log query --source <id> [--since <iso>] [--level <debug|info|warning|error>] [--limit <count>] [--json]
118
132
  papyrus tasks plan [--session-id <id>] [--json]
119
133
  papyrus tasks graph [--session-id <id>] [--json]
@@ -738,6 +752,7 @@ export async function runArtifactCli(args: string[], client: TaskCliClient, proj
738
752
  let labels: string[] | undefined;
739
753
  let extra: Record<string, unknown> | undefined;
740
754
  let templateId: string | undefined;
755
+ let reason: string | undefined;
741
756
  let text: string | undefined;
742
757
  let limit: number | undefined;
743
758
  let depth: number | undefined;
@@ -753,6 +768,7 @@ export async function runArtifactCli(args: string[], client: TaskCliClient, proj
753
768
  if (argument === "--labels-json") { labels = parseJsonStringArrayFlag(args[++index], "--labels-json"); continue; }
754
769
  if (argument === "--extra-json") { extra = parseJsonObjectFlag(args[++index], "--extra-json"); continue; }
755
770
  if (argument === "--template-id") { templateId = args[++index]; if (!templateId) throw new Error("--template-id requires a value"); continue; }
771
+ if (argument === "--reason") { reason = args[++index]; if (reason === undefined) throw new Error("--reason requires a value"); continue; }
756
772
  if (argument === "--text") { text = args[++index]; if (text === undefined) throw new Error("--text requires a value"); continue; }
757
773
  if (argument === "--limit") {
758
774
  const value = args[++index];
@@ -804,8 +820,36 @@ export async function runArtifactCli(args: string[], client: TaskCliClient, proj
804
820
  human = `${artifactLabel(artifact)}\n\n${artifact.body ?? ""}`;
805
821
  break;
806
822
  }
823
+ case "remove": {
824
+ if (!id) throw new Error("artifact remove requires exactly one artifact id");
825
+ const record = await client.call<Record<string, unknown>, { artifactId: string; trashedAt: string; purgeAfter: string; reason?: string }>("artifact.remove", { id, reason });
826
+ result = record;
827
+ human = `Trashed ${record.artifactId}: eligible for purge at ${record.purgeAfter}`;
828
+ break;
829
+ }
830
+ case "restore": {
831
+ if (!id) throw new Error("artifact restore requires exactly one artifact id");
832
+ const outcome = await client.call<Record<string, unknown>, { restored: boolean }>("artifact.restore", { id });
833
+ result = outcome;
834
+ human = outcome.restored ? `Restored ${id}` : `${id} was not trashed`;
835
+ break;
836
+ }
837
+ case "trash-status": {
838
+ if (!id) throw new Error("artifact trash-status requires exactly one artifact id");
839
+ const record = await client.call<Record<string, unknown>, { artifactId: string; trashedAt: string; purgeAfter: string; reason?: string } | null>("artifact.trash_status", { id });
840
+ result = record;
841
+ human = record ? `${record.artifactId}: trashed at ${record.trashedAt}, purge eligible at ${record.purgeAfter}` : `${id} is not trashed`;
842
+ break;
843
+ }
844
+ case "trash-list": {
845
+ if (id) throw new Error("artifact trash-list accepts no positional arguments");
846
+ const rows = await client.call<Record<string, unknown>, Array<{ artifactId: string; trashedAt: string; purgeAfter: string; reason?: string }>>("artifact.trash_list", {});
847
+ result = rows;
848
+ human = rows.length === 0 ? "Trash is empty." : rows.map((row) => `${row.artifactId}: purge eligible at ${row.purgeAfter}`).join("\n");
849
+ break;
850
+ }
807
851
  default:
808
- throw new Error("artifact action must be create, query, or show");
852
+ throw new Error("artifact action must be create, query, show, remove, restore, trash-status, or trash-list");
809
853
  }
810
854
  return json ? JSON.stringify(result) : human;
811
855
  }
@@ -940,6 +984,106 @@ export async function runSessionIdentityCli(args: string[], client: TaskCliClien
940
984
  throw new Error("session action must be register or release");
941
985
  }
942
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
+
943
1087
  export async function runNoteCli(args: string[], client: TaskCliClient, projectRoot: string = process.cwd()): Promise<string> {
944
1088
  const json = args.includes("--json");
945
1089
  const positional: string[] = [];
@@ -1361,6 +1505,11 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
1361
1505
  console.log(await runSessionIdentityCli(args.slice(1), client));
1362
1506
  return;
1363
1507
  }
1508
+ if (command === "discuss") {
1509
+ const client = await connectPapyrusClient();
1510
+ console.log(await runDiscussCli(args.slice(1), client));
1511
+ return;
1512
+ }
1364
1513
  if (command === "migrate") {
1365
1514
  const client = await connectPapyrusClient();
1366
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 = 13;
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;
@@ -159,6 +180,15 @@ export const TASK_FOCUS_MAX_SCOPES = 500;
159
180
  export const TASK_FOCUS_STALE_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
160
181
  /** Hard cap on registered session_identities rows (see domain/session-identity.ts); oldest-seen identity is evicted beyond this, mirroring TASK_FOCUS_MAX_SCOPES. */
161
182
  export const SESSION_IDENTITY_MAX_ROWS = 2_000;
183
+ /**
184
+ * Grace period between artifact.remove (trash) and artifact purge eligibility -- see
185
+ * domain/artifact-trash.ts. 30 days, matching TASK_FOCUS_STALE_AFTER_MS's convention: long
186
+ * enough that a mistaken removal is still recoverable via artifact.restore, short enough to
187
+ * actually bound trash accumulation. Enforced twice: the daemon's periodic sweep only
188
+ * selects rows past this deadline, and the SQLite triggers that otherwise forbid deleting
189
+ * artifact_events/task_events independently re-check the same deadline at delete time.
190
+ */
191
+ export const ARTIFACT_TRASH_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
162
192
  /** Persisted project and focused-graph Task view bounds. */
163
193
  export const TASK_SCOPE_MAX_TASKS = 1_000;
164
194
  /** Docs/Rules/Skills project scope listing bound, mirroring TASK_SCOPE_MAX_TASKS. */
package/src/daemon.ts CHANGED
@@ -34,6 +34,14 @@ export function serveMain(): void {
34
34
  if (removed > 0) logEvent("info", "stale_focus_reaped", { removed });
35
35
  } catch (error) { logEvent("error", "reap_stale_focus_failed", { message: error instanceof Error ? error.message : String(error) }); }
36
36
  }, DB_OPTIMIZE_INTERVAL_MS);
37
+ // Same daily cadence: ARTIFACT_TRASH_RETENTION_MS is 30 days, so a daily sweep finds newly
38
+ // due artifacts promptly without needing its own tighter interval -- see domain/artifact-trash.ts.
39
+ const purgeTrashTimer = setInterval(() => {
40
+ try {
41
+ const purged = service.purgeDueTrash();
42
+ if (purged > 0) logEvent("info", "artifact_trash_purged", { purged });
43
+ } catch (error) { logEvent("error", "purge_trash_failed", { message: error instanceof Error ? error.message : String(error) }); }
44
+ }, DB_OPTIMIZE_INTERVAL_MS);
37
45
  let stopping = false;
38
46
  const shutdown = () => {
39
47
  if (stopping) return;
@@ -41,6 +49,7 @@ export function serveMain(): void {
41
49
  clearInterval(checkpointTimer);
42
50
  clearInterval(optimizeTimer);
43
51
  clearInterval(reapFocusTimer);
52
+ clearInterval(purgeTrashTimer);
44
53
  clearDaemonPort(stateDir);
45
54
  service.close();
46
55
  void server.stop(true).finally(() => process.exit(0));