@danypops/papyrus 0.17.2 → 0.18.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:
@@ -31,6 +31,26 @@ function artifactLine(artifact: Artifact): string {
31
31
  return `${artifact.id} [${artifact.status}] ${artifact.title}`;
32
32
  }
33
33
 
34
+ /**
35
+ * Shared "remove"/"restore" dispatch for every domain tool (tasks/docs/rules/skills) --
36
+ * artifact.remove/restore are kind-agnostic composition-root operations (see service.ts),
37
+ * not owned by any one domain module, so every domain tool exposes the same two actions
38
+ * over the same two operations rather than reinventing trash semantics four times.
39
+ * Returns null when action is neither, so callers fall through to their own dispatch.
40
+ */
41
+ async function handleArtifactRemoveRestore(action: unknown, params: Record<string, unknown>): Promise<ReturnType<typeof text> | null> {
42
+ if (action === "remove") {
43
+ const record = await callService<Record<string, unknown>, { artifactId: string; trashedAt: string; purgeAfter: string; reason?: string }>("artifact.remove", params);
44
+ return text(`Trashed ${record.artifactId}, eligible for purge at ${record.purgeAfter}.`, createPreviewDetails("artifact.remove", "Trashed", record.artifactId));
45
+ }
46
+ if (action === "restore") {
47
+ const outcome = await callService<Record<string, unknown>, { restored: boolean }>("artifact.restore", params);
48
+ const output = outcome.restored ? `Restored ${params["id"]}.` : `${params["id"]} was not trashed.`;
49
+ return text(output, createPreviewDetails("artifact.restore", "Restored", output));
50
+ }
51
+ return null;
52
+ }
53
+
34
54
  const proofReferenceSchema = Type.Object({
35
55
  type: Type.Union(PROOF_TYPES.map((type) => Type.Literal(type))),
36
56
  target: Type.String(),
@@ -45,7 +65,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
45
65
  pi.registerTool({
46
66
  name: "tasks",
47
67
  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.",
68
+ 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
69
  parameters: Type.Object({
50
70
  action: Type.String(),
51
71
  id: Type.Optional(Type.String()),
@@ -181,6 +201,8 @@ export function registerDomainTools(pi: ExtensionAPI): void {
181
201
  }))),
182
202
  );
183
203
  }
204
+ const trashResult = await handleArtifactRemoveRestore(action, params);
205
+ if (trashResult) return trashResult;
184
206
  const operations = {
185
207
  focus: "tasks.focus",
186
208
  start: "tasks.start",
@@ -257,7 +279,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
257
279
  pi.registerTool({
258
280
  name: "docs",
259
281
  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.",
282
+ 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
283
  parameters: Type.Object({
262
284
  action: Type.String(),
263
285
  id: Type.Optional(Type.String()),
@@ -273,6 +295,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
273
295
  relation: Type.Optional(Type.String()),
274
296
  target_id: Type.Optional(Type.String()),
275
297
  project_root: Type.Optional(Type.String()),
298
+ reason: Type.Optional(Type.String()),
276
299
  }),
277
300
  renderCall(args, theme) { return renderPapyrusToolCall("Documents", args, theme); },
278
301
  renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
@@ -291,6 +314,8 @@ export function registerDomainTools(pi: ExtensionAPI): void {
291
314
  const artifact = await callService<Record<string, unknown>, Artifact>("docs.show", params);
292
315
  return text(`${artifactLine(artifact)}\n\n${artifact.body}`, createArtifactDetails("docs.show", artifact));
293
316
  }
317
+ const trashResult = await handleArtifactRemoveRestore(action, params);
318
+ if (trashResult) return trashResult;
294
319
  const operations = { activate: "docs.activate", archive: "docs.archive", reopen: "docs.reopen", link: "docs.link", assign_project: "docs.assign_project" } as const;
295
320
  const operation = operations[action as keyof typeof operations];
296
321
  if (!operation) throw new Error(`unknown docs action: ${action}`);
@@ -305,14 +330,14 @@ export function registerDomainTools(pi: ExtensionAPI): void {
305
330
  pi.registerTool({
306
331
  name: "rules",
307
332
  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.",
333
+ 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
334
  parameters: Type.Object({
310
335
  action: Type.String(), id: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
311
336
  body: Type.Optional(Type.String()), condition: Type.Optional(Type.String()), rule_action: Type.Optional(Type.String()),
312
337
  severity: Type.Optional(Type.String()), labels: Type.Optional(Type.Array(Type.String())),
313
338
  extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())), status: Type.Optional(Type.String()),
314
339
  text: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()), task_id: Type.Optional(Type.String()),
315
- project_root: Type.Optional(Type.String()),
340
+ project_root: Type.Optional(Type.String()), reason: Type.Optional(Type.String()),
316
341
  }),
317
342
  renderCall(args, theme) { return renderPapyrusToolCall("Rules", args, theme); },
318
343
  renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
@@ -331,6 +356,8 @@ export function registerDomainTools(pi: ExtensionAPI): void {
331
356
  const preview = await callService<Record<string, unknown>, string>("rules.preview", params);
332
357
  return text(preview, createPreviewDetails("rules.preview", "Rule preview", preview));
333
358
  }
359
+ const trashResult = await handleArtifactRemoveRestore(action, params);
360
+ if (trashResult) return trashResult;
334
361
  const operations = { show: "rules.show", enable: "rules.enable", disable: "rules.disable", gate: "rules.gate", assign_project: "rules.assign_project" } as const;
335
362
  const operation = operations[action as keyof typeof operations];
336
363
  if (!operation) throw new Error(`unknown rules action: ${action}`);
@@ -345,7 +372,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
345
372
  pi.registerTool({
346
373
  name: "skills",
347
374
  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.",
375
+ 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
376
  parameters: Type.Object({
350
377
  action: Type.String(), id: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
351
378
  body: Type.Optional(Type.String()), trigger: Type.Optional(Type.String()), steps: Type.Optional(Type.Array(Type.String())),
@@ -356,7 +383,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
356
383
  text: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()), template_id: Type.Optional(Type.String()),
357
384
  target_kind: Type.Optional(Type.String()), defaults: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
358
385
  required: Type.Optional(Type.Array(Type.String())), kind: Type.Optional(Type.String()), subtype: Type.Optional(Type.String()),
359
- project_root: Type.Optional(Type.String()),
386
+ project_root: Type.Optional(Type.String()), reason: Type.Optional(Type.String()),
360
387
  }),
361
388
  renderCall(args, theme) { return renderPapyrusToolCall("Skills", args, theme); },
362
389
  renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
@@ -393,6 +420,8 @@ export function registerDomainTools(pi: ExtensionAPI): void {
393
420
  roots: run.rootTaskIds,
394
421
  }));
395
422
  }
423
+ const trashResult = await handleArtifactRemoveRestore(action, params);
424
+ if (trashResult) return trashResult;
396
425
  const operations = { show: "skills.show", enable: "skills.enable", disable: "skills.disable", instantiate: "skills.instantiate", assign_project: "skills.assign_project" } as const;
397
426
  const operation = operations[action as keyof typeof operations];
398
427
  if (!operation) throw new Error(`unknown skills action: ${action}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.17.2",
3
+ "version": "0.18.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[] = [];
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]
@@ -738,6 +742,7 @@ export async function runArtifactCli(args: string[], client: TaskCliClient, proj
738
742
  let labels: string[] | undefined;
739
743
  let extra: Record<string, unknown> | undefined;
740
744
  let templateId: string | undefined;
745
+ let reason: string | undefined;
741
746
  let text: string | undefined;
742
747
  let limit: number | undefined;
743
748
  let depth: number | undefined;
@@ -753,6 +758,7 @@ export async function runArtifactCli(args: string[], client: TaskCliClient, proj
753
758
  if (argument === "--labels-json") { labels = parseJsonStringArrayFlag(args[++index], "--labels-json"); continue; }
754
759
  if (argument === "--extra-json") { extra = parseJsonObjectFlag(args[++index], "--extra-json"); continue; }
755
760
  if (argument === "--template-id") { templateId = args[++index]; if (!templateId) throw new Error("--template-id requires a value"); continue; }
761
+ if (argument === "--reason") { reason = args[++index]; if (reason === undefined) throw new Error("--reason requires a value"); continue; }
756
762
  if (argument === "--text") { text = args[++index]; if (text === undefined) throw new Error("--text requires a value"); continue; }
757
763
  if (argument === "--limit") {
758
764
  const value = args[++index];
@@ -804,8 +810,36 @@ export async function runArtifactCli(args: string[], client: TaskCliClient, proj
804
810
  human = `${artifactLabel(artifact)}\n\n${artifact.body ?? ""}`;
805
811
  break;
806
812
  }
813
+ case "remove": {
814
+ if (!id) throw new Error("artifact remove requires exactly one artifact id");
815
+ const record = await client.call<Record<string, unknown>, { artifactId: string; trashedAt: string; purgeAfter: string; reason?: string }>("artifact.remove", { id, reason });
816
+ result = record;
817
+ human = `Trashed ${record.artifactId}: eligible for purge at ${record.purgeAfter}`;
818
+ break;
819
+ }
820
+ case "restore": {
821
+ if (!id) throw new Error("artifact restore requires exactly one artifact id");
822
+ const outcome = await client.call<Record<string, unknown>, { restored: boolean }>("artifact.restore", { id });
823
+ result = outcome;
824
+ human = outcome.restored ? `Restored ${id}` : `${id} was not trashed`;
825
+ break;
826
+ }
827
+ case "trash-status": {
828
+ if (!id) throw new Error("artifact trash-status requires exactly one artifact id");
829
+ const record = await client.call<Record<string, unknown>, { artifactId: string; trashedAt: string; purgeAfter: string; reason?: string } | null>("artifact.trash_status", { id });
830
+ result = record;
831
+ human = record ? `${record.artifactId}: trashed at ${record.trashedAt}, purge eligible at ${record.purgeAfter}` : `${id} is not trashed`;
832
+ break;
833
+ }
834
+ case "trash-list": {
835
+ if (id) throw new Error("artifact trash-list accepts no positional arguments");
836
+ const rows = await client.call<Record<string, unknown>, Array<{ artifactId: string; trashedAt: string; purgeAfter: string; reason?: string }>>("artifact.trash_list", {});
837
+ result = rows;
838
+ human = rows.length === 0 ? "Trash is empty." : rows.map((row) => `${row.artifactId}: purge eligible at ${row.purgeAfter}`).join("\n");
839
+ break;
840
+ }
807
841
  default:
808
- throw new Error("artifact action must be create, query, or show");
842
+ throw new Error("artifact action must be create, query, show, remove, restore, trash-status, or trash-list");
809
843
  }
810
844
  return json ? JSON.stringify(result) : human;
811
845
  }
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 = 14;
11
11
  export const SERVICE_MAX_BODY_BYTES = 1_048_576;
12
12
 
13
13
  export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
@@ -159,6 +159,15 @@ export const TASK_FOCUS_MAX_SCOPES = 500;
159
159
  export const TASK_FOCUS_STALE_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
160
160
  /** 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
161
  export const SESSION_IDENTITY_MAX_ROWS = 2_000;
162
+ /**
163
+ * Grace period between artifact.remove (trash) and artifact purge eligibility -- see
164
+ * domain/artifact-trash.ts. 30 days, matching TASK_FOCUS_STALE_AFTER_MS's convention: long
165
+ * enough that a mistaken removal is still recoverable via artifact.restore, short enough to
166
+ * actually bound trash accumulation. Enforced twice: the daemon's periodic sweep only
167
+ * selects rows past this deadline, and the SQLite triggers that otherwise forbid deleting
168
+ * artifact_events/task_events independently re-check the same deadline at delete time.
169
+ */
170
+ export const ARTIFACT_TRASH_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
162
171
  /** Persisted project and focused-graph Task view bounds. */
163
172
  export const TASK_SCOPE_MAX_TASKS = 1_000;
164
173
  /** 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));
package/src/db.ts CHANGED
@@ -134,7 +134,8 @@ CREATE INDEX IF NOT EXISTS task_events_history_idx ON task_events(task_id, occur
134
134
  CREATE TRIGGER IF NOT EXISTS task_events_no_update BEFORE UPDATE ON task_events
135
135
  BEGIN SELECT RAISE(ABORT, 'task_events are append-only'); END;
136
136
  CREATE TRIGGER IF NOT EXISTS task_events_no_delete BEFORE DELETE ON task_events
137
- BEGIN SELECT RAISE(ABORT, 'task_events are append-only'); END;
137
+ WHEN NOT EXISTS (SELECT 1 FROM artifact_trash WHERE artifact_id = OLD.task_id AND purge_after <= strftime('%Y-%m-%dT%H:%M:%fZ','now'))
138
+ BEGIN SELECT RAISE(ABORT, 'task_events are append-only except during an explicit, elapsed-grace-period artifact trash purge'); END;
138
139
  CREATE TABLE IF NOT EXISTS task_scopes (
139
140
  task_id TEXT PRIMARY KEY REFERENCES artifacts(id),
140
141
  project_root TEXT,
@@ -170,7 +171,8 @@ CREATE INDEX IF NOT EXISTS artifact_events_session_idx ON artifact_events(sessio
170
171
  CREATE TRIGGER IF NOT EXISTS artifact_events_no_update BEFORE UPDATE ON artifact_events
171
172
  BEGIN SELECT RAISE(ABORT, 'artifact_events are append-only'); END;
172
173
  CREATE TRIGGER IF NOT EXISTS artifact_events_no_delete BEFORE DELETE ON artifact_events
173
- BEGIN SELECT RAISE(ABORT, 'artifact_events are append-only'); END;
174
+ WHEN NOT EXISTS (SELECT 1 FROM artifact_trash WHERE artifact_id = OLD.artifact_id AND purge_after <= strftime('%Y-%m-%dT%H:%M:%fZ','now'))
175
+ BEGIN SELECT RAISE(ABORT, 'artifact_events are append-only except during an explicit, elapsed-grace-period artifact trash purge'); END;
174
176
  CREATE TABLE IF NOT EXISTS graph_projection_checkpoints (
175
177
  producer_id TEXT PRIMARY KEY,
176
178
  last_sequence INTEGER NOT NULL,
@@ -218,6 +220,13 @@ CREATE TABLE IF NOT EXISTS session_identities (
218
220
  registered_at TEXT NOT NULL,
219
221
  last_seen_at TEXT NOT NULL
220
222
  );
223
+ CREATE TABLE IF NOT EXISTS artifact_trash (
224
+ artifact_id TEXT PRIMARY KEY REFERENCES artifacts(id),
225
+ trashed_at TEXT NOT NULL,
226
+ purge_after TEXT NOT NULL,
227
+ reason TEXT
228
+ );
229
+ CREATE INDEX IF NOT EXISTS artifact_trash_purge_idx ON artifact_trash(purge_after);
221
230
  `;
222
231
 
223
232
  const SEED_SQL = `
@@ -296,6 +305,7 @@ const CORE_LEDGER_VERSIONS: ReadonlyArray<{ version: number; name: string; check
296
305
  { version: 3, name: "log-domain", checksum: "c87f43c22b2608619ada9a529d7899ae74b7f38cd554135c8034116fc96e1eff" },
297
306
  { version: 4, name: "remove-discourse", checksum: "b923f41c44460f0aaeb2f4e60e28f8b8e1425d03f527955bd991434b46de4c82" },
298
307
  { version: 5, name: "session-identity", checksum: "1c6a165bbe37f82a100fd34762db70c3f8ab15ff20c3a53c2e60448edc815a5e" },
308
+ { version: 6, name: "artifact-trash", checksum: "4a75dbec2892deb54bcc1afdf0d51d81f03a8d10861787d083784a29e5c7e8f9" },
299
309
  ];
300
310
 
301
311
  export function migrationLedger(db: Db): ModuleMigrationRow[] {
@@ -388,8 +398,34 @@ export interface PapyrusMigration {
388
398
  up: (db: Db) => void;
389
399
  }
390
400
 
391
- /** Currently empty -- no schema version beyond LEGACY_MIGRATION_CHAIN_TARGET_VERSION exists yet. The next migration is appended here, never as a new branch in the legacy chain above. */
392
- const FUTURE_MIGRATIONS: ReadonlyArray<PapyrusMigration> = [];
401
+ const FUTURE_MIGRATIONS: ReadonlyArray<PapyrusMigration> = [
402
+ {
403
+ version: 14,
404
+ name: "artifact-trash",
405
+ // See domain/artifact-trash.ts for the full design rationale. The two trigger bodies here
406
+ // must match SCHEMA's fresh-bootstrap versions of the same triggers byte-for-byte -- DROP
407
+ // then CREATE is required since SQLite has no ALTER TRIGGER.
408
+ up: (db) => {
409
+ db.exec(`
410
+ CREATE TABLE IF NOT EXISTS artifact_trash (
411
+ artifact_id TEXT PRIMARY KEY REFERENCES artifacts(id),
412
+ trashed_at TEXT NOT NULL,
413
+ purge_after TEXT NOT NULL,
414
+ reason TEXT
415
+ );
416
+ CREATE INDEX IF NOT EXISTS artifact_trash_purge_idx ON artifact_trash(purge_after);
417
+ DROP TRIGGER IF EXISTS task_events_no_delete;
418
+ CREATE TRIGGER task_events_no_delete BEFORE DELETE ON task_events
419
+ WHEN NOT EXISTS (SELECT 1 FROM artifact_trash WHERE artifact_id = OLD.task_id AND purge_after <= strftime('%Y-%m-%dT%H:%M:%fZ','now'))
420
+ BEGIN SELECT RAISE(ABORT, 'task_events are append-only except during an explicit, elapsed-grace-period artifact trash purge'); END;
421
+ DROP TRIGGER IF EXISTS artifact_events_no_delete;
422
+ CREATE TRIGGER artifact_events_no_delete BEFORE DELETE ON artifact_events
423
+ WHEN NOT EXISTS (SELECT 1 FROM artifact_trash WHERE artifact_id = OLD.artifact_id AND purge_after <= strftime('%Y-%m-%dT%H:%M:%fZ','now'))
424
+ BEGIN SELECT RAISE(ABORT, 'artifact_events are append-only except during an explicit, elapsed-grace-period artifact trash purge'); END;
425
+ `);
426
+ },
427
+ },
428
+ ];
393
429
 
394
430
  /**
395
431
  * Adapts Papyrus's own Db/inTransaction to daemon-kit's storage-agnostic
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { ARTIFACT_EVENT_ACTOR_MAX_LENGTH, ARTIFACT_EVENT_HISTORY_DEFAULT_LIMIT, ARTIFACT_EVENT_HISTORY_MAX_LIMIT } from "../constants.ts";
12
12
 
13
- export const ARTIFACT_EVENT_TYPES = ["created", "updated", "status_changed", "extra_set", "linked", "unlinked"] as const;
13
+ export const ARTIFACT_EVENT_TYPES = ["created", "updated", "status_changed", "extra_set", "linked", "unlinked", "trashed", "restored"] as const;
14
14
  export type ArtifactEventType = typeof ARTIFACT_EVENT_TYPES[number];
15
15
  export type ArtifactEventDirection = "asc" | "desc";
16
16
 
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Artifact trash: Option B from the design discussion (Doc-worthy decision, recorded here
3
+ * since there is no other durable home for it yet) -- `artifact.remove` is a real, narrow,
4
+ * time-gated exception to Papyrus's otherwise-absolute append-only invariant, not a mere
5
+ * status flip.
6
+ *
7
+ * The constraint that shaped this design: every artifact gets a mandatory "created" row in
8
+ * artifact_events at creation time (see ops.ts's createArtifact), and artifact_events is
9
+ * DB-trigger-enforced immutable (no UPDATE, no DELETE, ever). That means a literal
10
+ * `DELETE FROM artifacts` can never succeed for ANY artifact while that FK and that trigger
11
+ * both hold unconditionally -- there is no such thing as an artifact with "no history to
12
+ * protect". A real purge therefore requires the DB's own append-only triggers to carry an
13
+ * explicit, narrow carve-out (see db.ts's artifact_events_no_delete / task_events_no_delete),
14
+ * gated on the exact same elapsed-time deadline recorded here -- enforced by the database
15
+ * itself, not merely by application-code discipline, so a bug in the purge sweep cannot
16
+ * delete history before its own stated deadline.
17
+ *
18
+ * Removing an artifact does not touch it immediately: it inserts one row here recording when
19
+ * it becomes eligible, and from that moment the artifact is excluded from ordinary listings
20
+ * (see ops.ts's queryArtifacts) but still directly reachable by id (get/show) and fully
21
+ * restorable via artifact.restore, until purgeAfter passes and the daemon's periodic sweep
22
+ * (see daemon.ts) performs the real, cascading, irreversible deletion.
23
+ */
24
+ export interface ArtifactTrashRecord {
25
+ artifactId: string;
26
+ trashedAt: string;
27
+ purgeAfter: string;
28
+ reason?: string;
29
+ }
@@ -46,6 +46,8 @@ export interface ArtifactQuery {
46
46
  labels?: string[];
47
47
  extraEquals?: Record<string, string | number | boolean>;
48
48
  limit?: number;
49
+ /** Trashed artifacts (see artifact-trash.ts) are excluded from every query by default; set true to include them, e.g. for a trash-listing view. */
50
+ includeTrashed?: boolean;
49
51
  }
50
52
 
51
53
  export interface ArtifactGraphOptions {
package/src/ops.ts CHANGED
@@ -6,8 +6,10 @@ import { createRequire } from "node:module";
6
6
  import { exec } from "node:child_process";
7
7
  import type { Db } from "./db.ts";
8
8
  import { inTransaction } from "./db.ts";
9
- import { DEFAULT_STATUS_BY_KIND } from "./constants.ts";
9
+ import { ARTIFACT_TRASH_RETENTION_MS, DEFAULT_STATUS_BY_KIND } from "./constants.ts";
10
10
  import type { Artifact, ArtifactQuery, CreateArtifactInput, UpdateArtifactInput } from "./domain/artifact.ts";
11
+ import type { ArtifactTrashRecord } from "./domain/artifact-trash.ts";
12
+ export type { ArtifactTrashRecord } from "./domain/artifact-trash.ts";
11
13
  import type { Gate, GateResult, GateRunOptions } from "./domain/gate.ts";
12
14
  import {
13
15
  normalizeArtifactEventQuery,
@@ -288,6 +290,7 @@ export function queryArtifacts(db: Db, filter: ArtifactQuery): Artifact[] {
288
290
  let sql = "SELECT * FROM artifacts";
289
291
  const conditions: string[] = [];
290
292
  const params: unknown[] = [];
293
+ if (!filter.includeTrashed) conditions.push("id NOT IN (SELECT artifact_id FROM artifact_trash)");
291
294
  if (filter.kind) { conditions.push("kind = ?"); params.push(filter.kind); }
292
295
  if (filter.status) { conditions.push("status = ?"); params.push(filter.status); }
293
296
  if (filter.statuses) {
@@ -318,6 +321,108 @@ export function queryArtifacts(db: Db, filter: ArtifactQuery): Artifact[] {
318
321
  return rows.map(rowToArtifact);
319
322
  }
320
323
 
324
+ function rowToTrashRecord(row: Record<string, unknown>): ArtifactTrashRecord {
325
+ return {
326
+ artifactId: row["artifact_id"] as string,
327
+ trashedAt: row["trashed_at"] as string,
328
+ purgeAfter: row["purge_after"] as string,
329
+ ...(row["reason"] == null ? {} : { reason: row["reason"] as string }),
330
+ };
331
+ }
332
+
333
+ export function getArtifactTrash(db: Db, id: string): ArtifactTrashRecord | null {
334
+ const row = db.prepare("SELECT artifact_id, trashed_at, purge_after, reason FROM artifact_trash WHERE artifact_id = ?").get(id) as Record<string, unknown> | null;
335
+ return row ? rowToTrashRecord(row) : null;
336
+ }
337
+
338
+ export function listArtifactTrash(db: Db): ArtifactTrashRecord[] {
339
+ const rows = db.prepare("SELECT artifact_id, trashed_at, purge_after, reason FROM artifact_trash ORDER BY purge_after ASC").all() as Record<string, unknown>[];
340
+ return rows.map(rowToTrashRecord);
341
+ }
342
+
343
+ /**
344
+ * Moves an artifact to the trash: it becomes ineligible for purge_after ms (see
345
+ * ARTIFACT_TRASH_RETENTION_MS), immediately excluded from queryArtifacts by default, still
346
+ * directly reachable via getArtifact, and fully restorable via restoreArtifact until the
347
+ * daemon's periodic sweep (purgeDueArtifacts) actually deletes it. Re-removing an
348
+ * already-trashed artifact resets its clock rather than erroring -- the same "most recent
349
+ * intent wins" semantics as registerSessionIdentity's rotation.
350
+ *
351
+ * Refuses to trash a Task that is the live Task Focus in any scope: Focus is active,
352
+ * behavior-affecting state, and trashing out from under it would silently discard work a
353
+ * caller is not necessarily looking at right now. No other kind has an analogous "currently
354
+ * in use" signal to check.
355
+ */
356
+ export function trashArtifact(db: Db, id: string, options?: { reason?: string; now?: () => string; context?: ArtifactEventContext }): ArtifactTrashRecord {
357
+ const artifact = getArtifact(db, id);
358
+ if (!artifact) throw new Error(`artifact "${id}" not found`);
359
+ const focusedScope = db.prepare("SELECT scope FROM task_focus WHERE task_id = ? LIMIT 1").get(id) as { scope: string } | null;
360
+ if (focusedScope) throw new Error(`artifact "${id}" is the active Task Focus in scope "${focusedScope.scope}"; clear focus before removing it`);
361
+ const now = options?.now ?? (() => new Date().toISOString());
362
+ const trashedAt = now();
363
+ const purgeAfter = new Date(new Date(trashedAt).getTime() + ARTIFACT_TRASH_RETENTION_MS).toISOString();
364
+ const record: ArtifactTrashRecord = { artifactId: id, trashedAt, purgeAfter, ...(options?.reason ? { reason: options.reason } : {}) };
365
+ inTransaction(db, () => {
366
+ db.prepare(`
367
+ INSERT INTO artifact_trash (artifact_id, trashed_at, purge_after, reason) VALUES (?, ?, ?, ?)
368
+ ON CONFLICT (artifact_id) DO UPDATE SET trashed_at = excluded.trashed_at, purge_after = excluded.purge_after, reason = excluded.reason
369
+ `).run(record.artifactId, record.trashedAt, record.purgeAfter, record.reason ?? null);
370
+ appendArtifactEvent(db, { artifactId: id, type: "trashed", ...(options?.context ?? {}) });
371
+ });
372
+ return record;
373
+ }
374
+
375
+ /** Idempotent: restoring an artifact that is not currently trashed is a real no-op, not an error -- mirrors releaseSessionIdentity's idempotence. */
376
+ export function restoreArtifact(db: Db, id: string, context?: ArtifactEventContext): { restored: boolean } {
377
+ const wasTrashed = getArtifactTrash(db, id) !== null;
378
+ if (!wasTrashed) return { restored: false };
379
+ inTransaction(db, () => {
380
+ db.prepare("DELETE FROM artifact_trash WHERE artifact_id = ?").run(id);
381
+ appendArtifactEvent(db, { artifactId: id, type: "restored", ...(context ?? {}) });
382
+ });
383
+ return { restored: true };
384
+ }
385
+
386
+ /**
387
+ * Real, cascading, irreversible deletion of every artifact whose purge_after has passed.
388
+ * Never called with anything but the real current time in production -- see daemon.ts's
389
+ * periodic sweep; a directly-injected `now` exists only so tests can exercise this without
390
+ * waiting out ARTIFACT_TRASH_RETENTION_MS for real.
391
+ *
392
+ * Deletes, in FK-safe order, every row across every table that can reference artifacts(id)
393
+ * (see the grep-verified list in domain/artifact-trash.ts's design comment): edges (both
394
+ * directions), task_focus, task_scopes, task_views (by root_task_id), graph_projection_
395
+ * identities, artifact_scopes, then task_events and artifact_events -- the latter two
396
+ * succeed only because the artifact_trash row placed here by trashArtifact still exists
397
+ * with an elapsed purge_after, which is exactly what db.ts's task_events_no_delete /
398
+ * artifact_events_no_delete trigger carve-outs check themselves. Only THEN artifact_trash's
399
+ * own row (it is itself a child of artifacts via a real FK, so it must go before artifacts,
400
+ * but only after the event tables that depend on its continued presence), and artifacts
401
+ * itself last of all. One artifact at a time in its own transaction, so one failure never
402
+ * blocks any other due artifact.
403
+ */
404
+ export function purgeDueArtifacts(db: Db, now: () => string = () => new Date().toISOString()): number {
405
+ const nowIso = now();
406
+ const due = (db.prepare("SELECT artifact_id FROM artifact_trash WHERE purge_after <= ?").all(nowIso) as Array<{ artifact_id: string }>).map((row) => row.artifact_id);
407
+ let purged = 0;
408
+ for (const id of due) {
409
+ inTransaction(db, () => {
410
+ db.prepare("DELETE FROM edges WHERE from_id = ? OR to_id = ?").run(id, id);
411
+ db.prepare("DELETE FROM task_focus WHERE task_id = ?").run(id);
412
+ db.prepare("DELETE FROM task_scopes WHERE task_id = ?").run(id);
413
+ db.prepare("DELETE FROM task_views WHERE root_task_id = ?").run(id);
414
+ db.prepare("DELETE FROM graph_projection_identities WHERE artifact_id = ?").run(id);
415
+ db.prepare("DELETE FROM artifact_scopes WHERE artifact_id = ?").run(id);
416
+ db.prepare("DELETE FROM task_events WHERE task_id = ?").run(id);
417
+ db.prepare("DELETE FROM artifact_events WHERE artifact_id = ?").run(id);
418
+ db.prepare("DELETE FROM artifact_trash WHERE artifact_id = ?").run(id);
419
+ db.prepare("DELETE FROM artifacts WHERE id = ?").run(id);
420
+ });
421
+ purged += 1;
422
+ }
423
+ return purged;
424
+ }
425
+
321
426
  export function linkArtifacts(db: Db, fromId: string, relation: string, toId: string, context?: ArtifactEventContext): void {
322
427
  const fromArt = getArtifact(db, fromId);
323
428
  const toArt = getArtifact(db, toId);
@@ -9,6 +9,7 @@ import type {
9
9
  UpdateArtifactInput,
10
10
  } from "../domain/artifact.ts";
11
11
  import type { ArtifactEventContext, ArtifactEventPage, ArtifactEventQuery } from "../domain/artifact-event.ts";
12
+ import type { ArtifactTrashRecord } from "../domain/artifact-trash.ts";
12
13
 
13
14
  export interface ArtifactStore {
14
15
  create(input: CreateArtifactInput, context?: ArtifactEventContext): Artifact;
@@ -23,4 +24,12 @@ export interface ArtifactStore {
23
24
  relationships(filter?: RelationshipQuery): ArtifactEdge[];
24
25
  /** Bounded query over the generic mutation event log shared by every kind. */
25
26
  events(query: ArtifactEventQuery): ArtifactEventPage;
27
+ /** See domain/artifact-trash.ts. Moves an artifact to the trash; throws if it does not exist or is the live Task Focus in any scope. */
28
+ trash(id: string, options?: { reason?: string; context?: ArtifactEventContext }): ArtifactTrashRecord;
29
+ /** Idempotent: restoring an artifact that is not currently trashed is a real no-op. */
30
+ restore(id: string, context?: ArtifactEventContext): { restored: boolean };
31
+ trashStatus(id: string): ArtifactTrashRecord | null;
32
+ listTrash(): ArtifactTrashRecord[];
33
+ /** Real, cascading, irreversible deletion of every artifact past its purge deadline; returns how many were purged. */
34
+ purgeDueTrash(): number;
26
35
  }
package/src/service.ts CHANGED
@@ -49,6 +49,7 @@ import { tasksOperations, TASKS_OPERATION_NAMES } from "./modules/tasks.ts";
49
49
  */
50
50
  const COMPOSITION_ROOT_OPERATION_NAMES = [
51
51
  "system.migrate", "artifact.create", "artifact.query", "artifact.show",
52
+ "artifact.remove", "artifact.restore", "artifact.trash_status", "artifact.trash_list",
52
53
  "graph.link", "graph.unlink", "graph.tree", "graph.status", "graph.history", "gates.run",
53
54
  "rules.injectable", "skills.instantiate",
54
55
  ] as const;
@@ -173,6 +174,8 @@ export interface PapyrusService {
173
174
  optimize(): void;
174
175
  /** Time-based Task Focus reclamation (see Tasks.reapStaleFocus); returns how many rows were removed, for daemon logging. */
175
176
  reapStaleFocus(): number;
177
+ /** Real, cascading deletion of every artifact past its trash purge deadline (see domain/artifact-trash.ts); returns how many were purged, for daemon logging. */
178
+ purgeDueTrash(): number;
176
179
  close(): void;
177
180
  }
178
181
 
@@ -241,6 +244,10 @@ function handlers(
241
244
  depth: optionalNumber(input, "depth"),
242
245
  maxNodes: optionalNumber(input, "max_nodes") ?? optionalNumber(input, "maxNodes"),
243
246
  }),
247
+ "artifact.remove": (input) => artifacts.trash(string(input, "id"), { reason: optionalString(input, "reason"), context: eventContext(input) }),
248
+ "artifact.restore": (input) => artifacts.restore(string(input, "id"), eventContext(input)),
249
+ "artifact.trash_status": (input) => artifacts.trashStatus(string(input, "id")),
250
+ "artifact.trash_list": () => artifacts.listTrash(),
244
251
  "graph.link": (input) => {
245
252
  const from = string(input, "from");
246
253
  const relation = string(input, "relation");
@@ -421,6 +428,7 @@ export function createPapyrusService(path: string): PapyrusService {
421
428
  checkpoint: () => { db.exec("PRAGMA wal_checkpoint(PASSIVE)"); },
422
429
  optimize: () => { db.exec("PRAGMA optimize"); },
423
430
  reapStaleFocus: () => tasks.reapStaleFocus(),
431
+ purgeDueTrash: () => artifacts.purgeDueTrash(),
424
432
  close: () => {
425
433
  db.exec("PRAGMA optimize");
426
434
  db.close();