@danypops/papyrus 0.4.0 → 0.5.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
@@ -101,7 +101,7 @@ Internally, application services depend on the `ArtifactStore` and `GateRunner`
101
101
 
102
102
  ## Interactive frontends
103
103
 
104
- - `/tasks` — task lifecycle, gates, dependencies, and nested metadata
104
+ - `/tasks` — task lifecycle, append-only history, gates, dependencies, and nested metadata
105
105
  - `/docs` — searchable documents, lifecycle, details, and graph links
106
106
  - `/rules` — severity/condition rows, exact injection preview, enable/disable, and task gating
107
107
  - `/skills` — trigger/tools rows, invocation into the editor, and artifact templates
@@ -122,7 +122,7 @@ Run `/tasks` for the interactive task panel:
122
122
  - successors are never auto-completed; fan-in, fan-out, diamonds, and disconnected DAGs remain explicit
123
123
  - inspect deterministic execution layers, readiness, a box-drawn nested hierarchy, composition, dependencies, evidence-bearing checklists, and verification gates
124
124
  - lifecycle colors are semantic and redundant with text/glyphs: To-Do grey, in-progress yellow, review blue, rejected orange, done green, and canceled red; `▶` marks active focus
125
- - Show details keeps Checklist and Validation gates separate from incidental Metadata, then renders relationships as a Unicode box-drawing graph footer; `↑/↓` scrolls and `←/→` pans wide graphs
125
+ - Show details keeps Checklist and Validation gates separate from incidental Metadata, renders bounded post-migration lifecycle history with actor/source/reason and gate evidence, then renders relationships as a Unicode box-drawing graph footer; `↑/↓` scrolls and `←/→` pans wide graphs
126
126
  - the compact persistent widget shows bounded open work in containment order and always retains the active focus
127
127
 
128
128
  Authenticated CLI parity covers the changed lifecycle and focus operations:
@@ -130,6 +130,7 @@ Authenticated CLI parity covers the changed lifecycle and focus operations:
130
130
  ```bash
131
131
  papyrus tasks graph --json
132
132
  papyrus tasks active --json
133
+ papyrus tasks history <id> --json
133
134
  papyrus tasks focus <id> --json
134
135
  papyrus tasks start <id> --json
135
136
  papyrus tasks submit <id> --json
@@ -171,10 +172,10 @@ packed install npm:@danypops/papyrus
171
172
  ~/.pi/agent/npm/node_modules/.bin/papyrus service install
172
173
  ```
173
174
 
174
- Existing databases are never migrated on daemon boot. After upgrading across the task-lifecycle schema boundary, run the authenticated CLI migration explicitly:
175
+ Existing databases are never migrated on daemon boot. After upgrading to append-only task history, run the authenticated CLI migration explicitly. A v1 database receives the lifecycle prerequisite and history schema in one transaction; existing tasks receive no fabricated events:
175
176
 
176
177
  ```bash
177
- ~/.pi/agent/npm/node_modules/.bin/papyrus migrate task-lifecycle
178
+ ~/.pi/agent/npm/node_modules/.bin/papyrus migrate task-history
178
179
  ```
179
180
 
180
181
  Until that command succeeds, health reports `migrationRequired` and normal domain operations are rejected with actionable guidance. Migration is not exposed as a Pi tool or MCP action. New empty databases bootstrap directly at the current schema.
@@ -4,6 +4,7 @@ import type { Artifact } from "../../src/domain/artifact.ts";
4
4
  import { PROOF_TYPES } from "../../src/domain/checklist.ts";
5
5
  import type { GateResult } from "../../src/domain/gate.ts";
6
6
  import type { TaskExecutionPlan } from "../../src/task-execution.ts";
7
+ import type { TaskHistoryPage } from "../../src/domain/task-event.ts";
7
8
  import type { TaskCompletion, TaskGraph } from "../../src/task-service.ts";
8
9
  import type { SkillWorkflowRunResult } from "../../src/skill-execution.ts";
9
10
  import { callService } from "./service-client.ts";
@@ -30,7 +31,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
30
31
  pi.registerTool({
31
32
  name: "tasks",
32
33
  label: "Tasks",
33
- description: "Task domain tool. ACTIONS: create, list, show, graph, plan, active, focus, start, submit, complete, reject, retry, cancel, run_gates, set_checklist, depend, contain. Lifecycle is todo → in-progress → review → done, with review failure → rejected and retry → in-progress; canceled is terminal. 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. Prefer this over low-level papyrus_* tools for task work.",
34
+ description: "Task domain tool. ACTIONS: create, list, show, history, graph, plan, active, focus, start, submit, complete, reject, retry, cancel, run_gates, set_checklist, depend, contain. Lifecycle is todo → in-progress → review → done, with review failure → rejected and retry → in-progress; canceled is terminal. 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. Prefer this over low-level papyrus_* tools for task work.",
34
35
  parameters: Type.Object({
35
36
  action: Type.String(),
36
37
  id: Type.Optional(Type.String()),
@@ -39,6 +40,10 @@ export function registerDomainTools(pi: ExtensionAPI): void {
39
40
  status: Type.Optional(Type.String()),
40
41
  text: Type.Optional(Type.String()),
41
42
  limit: Type.Optional(Type.Number()),
43
+ cursor: Type.Optional(Type.Number()),
44
+ direction: Type.Optional(Type.Union([Type.Literal("asc"), Type.Literal("desc")])),
45
+ reason: Type.Optional(Type.String()),
46
+ session_id: Type.Optional(Type.String()),
42
47
  labels: Type.Optional(Type.Array(Type.String())),
43
48
  extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
44
49
  gates: Type.Optional(Type.Array(Type.Record(Type.String(), Type.Unknown()))),
@@ -52,8 +57,9 @@ export function registerDomainTools(pi: ExtensionAPI): void {
52
57
  async execute(_id, params) {
53
58
  try {
54
59
  const action = params.action;
60
+ const request = { ...params, actor: "agent", source: "pi-tool" };
55
61
  if (action === "create") {
56
- const artifact = await callService<Record<string, unknown>, Artifact>("tasks.create", params);
62
+ const artifact = await callService<Record<string, unknown>, Artifact>("tasks.create", request);
57
63
  return text(`Created task ${artifactLine(artifact)}`, { artifact });
58
64
  }
59
65
  if (action === "list") {
@@ -64,6 +70,11 @@ export function registerDomainTools(pi: ExtensionAPI): void {
64
70
  const artifact = await callService<Record<string, unknown>, Artifact>("tasks.show", params);
65
71
  return text(`${artifactLine(artifact)}\n\n${artifact.body}`, { artifact });
66
72
  }
73
+ if (action === "history") {
74
+ const page = await callService<Record<string, unknown>, TaskHistoryPage>("tasks.history", request);
75
+ const lines = page.events.map((event) => `${event.occurredAt} ${event.type} ${event.fromStatus ?? "∅"} → ${event.toStatus ?? "∅"} · ${event.actor}/${event.source}${event.reason ? ` · ${event.reason}` : ""}`);
76
+ return text(lines.join("\n") || "No recorded history for this task.", { page });
77
+ }
67
78
  if (action === "active") {
68
79
  const artifact = await callService<Record<string, unknown>, Artifact | null>("tasks.active", params);
69
80
  return text(artifact ? `Active: ${artifactLine(artifact)}` : "No active task.", { artifact });
@@ -92,7 +103,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
92
103
  return text(`Updated checklist: ${artifactLine(artifact)}`, { artifact });
93
104
  }
94
105
  if (action === "complete") {
95
- const result = await callService<Record<string, unknown>, TaskCompletion>("tasks.complete", params);
106
+ const result = await callService<Record<string, unknown>, TaskCompletion>("tasks.complete", request);
96
107
  const gates = result.gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n");
97
108
  const checklist = result.checklist.map((item) => `${item.accepted ? "✓" : "✗"} proof: ${item.item}${item.reason ? ` — ${item.reason}` : ""}`).join("\n");
98
109
  const focused = result.focused ? `\nActive: ${artifactLine(result.focused)}` : "";
@@ -102,7 +113,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
102
113
  return text(`${result.completed ? "Completed" : "Rejected"}: ${artifactLine(result.artifact)}${focused}${blocked}${checklist ? `\n${checklist}` : ""}${gates ? `\n${gates}` : ""}`, { ...result });
103
114
  }
104
115
  if (action === "run_gates") {
105
- const gates = await callService<Record<string, unknown>, GateResult[]>("tasks.run_gates", params);
116
+ const gates = await callService<Record<string, unknown>, GateResult[]>("tasks.run_gates", request);
106
117
  return text(gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n") || "No gates configured.", { gates });
107
118
  }
108
119
  const operations = {
@@ -117,7 +128,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
117
128
  } as const;
118
129
  const operation = operations[action as keyof typeof operations];
119
130
  if (!operation) return text(`Unknown tasks action: ${action}`);
120
- const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
131
+ const artifact = await callService<Record<string, unknown>, Artifact>(operation, request);
121
132
  return text(artifactLine(artifact), { artifact });
122
133
  } catch (error) {
123
134
  return text(`tasks failed: ${error instanceof Error ? error.message : error}`);
@@ -1,4 +1,5 @@
1
1
  import type { Artifact } from "../../src/domain/artifact.ts";
2
+ import type { TaskEvent } from "../../src/domain/task-event.ts";
2
3
  import { checklistEntries, type ProofReference } from "../../src/domain/checklist.ts";
3
4
  import { formatMetadata } from "./artifact-format.ts";
4
5
 
@@ -48,7 +49,28 @@ function gateLines(value: unknown): string[] {
48
49
  return lines;
49
50
  }
50
51
 
51
- export function taskDetailsText(task: Artifact, relationshipGraphLines: string[] = []): string {
52
+ function historyLines(history: TaskEvent[]): string[] {
53
+ if (history.length === 0) return ["History:", " (no post-migration events recorded)"];
54
+ const lines = ["History:"];
55
+ for (const event of history) {
56
+ const transition = event.fromStatus || event.toStatus ? ` · ${event.fromStatus ?? "∅"} → ${event.toStatus ?? "∅"}` : "";
57
+ const reason = event.reason ? ` · ${event.reason}` : "";
58
+ lines.push(` ${event.occurredAt} · ${event.type}${transition} · ${event.actor}/${event.source}${reason}`);
59
+ if (event.evidence?.result) lines.push(` result: ${event.evidence.result}`);
60
+ if (Array.isArray(event.evidence?.gates)) {
61
+ for (const value of event.evidence.gates) {
62
+ if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
63
+ const result = value as Record<string, unknown>;
64
+ const gate = typeof result["gate"] === "object" && result["gate"] !== null ? result["gate"] as Record<string, unknown> : {};
65
+ const passed = result["passed"] === true;
66
+ lines.push(` ${passed ? "✓" : "✗"} ${String(gate["type"] ?? "gate")} · ${String(gate["target"] ?? "unknown")}`);
67
+ }
68
+ }
69
+ }
70
+ return lines;
71
+ }
72
+
73
+ export function taskDetailsText(task: Artifact, relationshipGraphLines: string[] = [], history: TaskEvent[] = []): string {
52
74
  let output = `${TASK_STATUS_GLYPHS[task.status] ?? "?"} ${task.title}\n${task.id} [task|${task.status}]`;
53
75
  if (task.labels.length > 0) output += `\nLabels: ${task.labels.join(", ")}`;
54
76
  output += `\n\n${task.body || "(no body)"}`;
@@ -60,6 +82,7 @@ export function taskDetailsText(task: Artifact, relationshipGraphLines: string[]
60
82
  if (Object.keys(metadata).length > 0) {
61
83
  output += `\n\nMetadata:\n${formatMetadata(metadata).map((line) => ` ${line}`).join("\n")}`;
62
84
  }
85
+ output += `\n\n${historyLines(history).join("\n")}`;
63
86
  if (task.edges?.length) {
64
87
  const graph = relationshipGraphLines.length > 0 ? relationshipGraphLines.join("\n") : " (graph unavailable)";
65
88
  output += `\n\nRelationships:\n Dependencies point prerequisite → dependent.\n${graph}`;
@@ -7,6 +7,7 @@ import {
7
7
  TASK_DETAIL_RESERVED_ROWS,
8
8
  } from "../../src/constants.ts";
9
9
  import type { Artifact } from "../../src/domain/artifact.ts";
10
+ import type { TaskEvent } from "../../src/domain/task-event.ts";
10
11
  import type { GraphRenderer } from "../../src/ports/graph-renderer.ts";
11
12
  import { projectTaskRelationships } from "../../src/task-relationship-view.ts";
12
13
  import type { TaskGraph } from "../../src/task-service.ts";
@@ -31,13 +32,14 @@ class TaskDetailViewport {
31
32
  private readonly theme: Theme,
32
33
  task: Artifact,
33
34
  private readonly graphLines: string[],
35
+ history: TaskEvent[],
34
36
  private readonly close: () => void,
35
37
  ) {
36
38
  this.visibleLines = Math.max(
37
39
  TASK_DETAIL_MIN_VISIBLE_LINES,
38
40
  Math.min(TASK_DETAIL_MAX_VISIBLE_LINES, tui.terminal.rows - TASK_DETAIL_RESERVED_ROWS),
39
41
  );
40
- this.narrative = taskDetailsText({ ...task, edges: undefined });
42
+ this.narrative = taskDetailsText({ ...task, edges: undefined }, [], history);
41
43
  }
42
44
 
43
45
  invalidate(): void { this.renderedWidth = 0; }
@@ -99,13 +101,14 @@ export async function showTaskDetails(
99
101
  task: Artifact,
100
102
  graph?: TaskGraph,
101
103
  renderer: GraphRenderer = new BeautifulMermaidRenderer(),
104
+ history: TaskEvent[] = [],
102
105
  ): Promise<void> {
103
106
  const relationshipGraph = renderer.render(projectTaskRelationships(task, graph)).lines;
104
- const content = taskDetailsText(task, relationshipGraph);
107
+ const content = taskDetailsText(task, relationshipGraph, history);
105
108
  if (ctx.mode !== "tui") {
106
109
  ctx.ui.notify(content, "info");
107
110
  return;
108
111
  }
109
112
  await ctx.ui.custom<void>((tui, theme, _keybindings, done) =>
110
- new TaskDetailViewport(tui, theme, task, relationshipGraph, done));
113
+ new TaskDetailViewport(tui, theme, task, relationshipGraph, history, done));
111
114
  }
@@ -14,6 +14,7 @@ export { taskDetailsText } from "./task-detail-format.ts";
14
14
  export { showTaskDetails } from "./task-detail-view.ts";
15
15
  import type { Artifact } from "../../src/domain/artifact.ts";
16
16
  import type { GateResult } from "../../src/domain/gate.ts";
17
+ import type { TaskHistoryPage } from "../../src/domain/task-event.ts";
17
18
  import { projectTaskExecution } from "../../src/task-execution.ts";
18
19
  import type { TaskCompletion, TaskGraph, TaskStatus } from "../../src/task-service.ts";
19
20
  import { TASK_STATUS_PRESENTATION, taskTreeConnector } from "./task-presentation.ts";
@@ -70,7 +71,7 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
70
71
  if (create === "Create a task") {
71
72
  const title = await ctx.ui.input("Task title:", "");
72
73
  if (title) {
73
- await callService("tasks.create", { title });
74
+ await callService("tasks.create", { title, actor: "user", source: "tasks-tui" });
74
75
  graph = await loadTaskGraph();
75
76
  }
76
77
  }
@@ -97,7 +98,8 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
97
98
  if (choice === "Show details") {
98
99
  const art = await callService<Record<string, unknown>, Artifact | null>("tasks.show", { id: action.row.id });
99
100
  if (!art) { ctx.ui.notify("Not found", "error"); continue; }
100
- await showTaskDetails(ctx, art, graph);
101
+ const history = await callService<Record<string, unknown>, TaskHistoryPage>("tasks.history", { id: art.id, direction: "desc" });
102
+ await showTaskDetails(ctx, art, graph, undefined, [...history.events].reverse());
101
103
  } else if (choice === "Make active") {
102
104
  try {
103
105
  await callService<Record<string, unknown>, Artifact>("tasks.focus", { id: action.row.id });
@@ -107,7 +109,7 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
107
109
  }
108
110
  } else if (choice === "Run gates") {
109
111
  try {
110
- const results = await callService<Record<string, unknown>, GateResult[]>("tasks.run_gates", { id: action.row.id });
112
+ const results = await callService<Record<string, unknown>, GateResult[]>("tasks.run_gates", { id: action.row.id, actor: "user", source: "tasks-tui" });
111
113
  ctx.ui.notify(`Gates:\n${results.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n")}`, "info");
112
114
  } catch (error) {
113
115
  ctx.ui.notify(`Gates failed: ${error instanceof Error ? error.message : error}`, "error");
@@ -126,7 +128,7 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
126
128
  ? "tasks.cancel"
127
129
  : "tasks.complete";
128
130
  if (operation === "tasks.complete") {
129
- const result = await callService<Record<string, unknown>, TaskCompletion>(operation, { id: action.row.id });
131
+ const result = await callService<Record<string, unknown>, TaskCompletion>(operation, { id: action.row.id, actor: "user", source: "tasks-tui" });
130
132
  action.row.status = result.artifact.status;
131
133
  const gates = result.gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target}`).join("\n");
132
134
  const checklist = result.checklist.map((item) => `${item.accepted ? "✓" : "✗"} proof: ${item.item}`).join("\n");
@@ -141,7 +143,7 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
141
143
  result.completed ? "info" : "warning",
142
144
  );
143
145
  } else {
144
- const updated = await callService<Record<string, unknown>, Artifact>(operation, { id: action.row.id });
146
+ const updated = await callService<Record<string, unknown>, Artifact>(operation, { id: action.row.id, actor: "user", source: "tasks-tui" });
145
147
  action.row.status = updated.status;
146
148
  ctx.ui.notify(`${updated.id} → [${updated.status}]`, "info");
147
149
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.4.0",
3
+ "version": "0.5.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"],
@@ -0,0 +1,92 @@
1
+ import type { Db } from "../db.ts";
2
+ import { inTransaction } from "../db.ts";
3
+ import {
4
+ normalizeTaskHistoryQuery,
5
+ validateTaskEvent,
6
+ type AppendTaskEvent,
7
+ type TaskEvent,
8
+ type TaskEventEvidence,
9
+ type TaskEventType,
10
+ type TaskHistoryPage,
11
+ type TaskHistoryQuery,
12
+ type TaskLifecycleStatus,
13
+ } from "../domain/task-event.ts";
14
+ import type { TaskEventStore } from "../ports/task-event-store.ts";
15
+
16
+ interface TaskEventRow {
17
+ id: number;
18
+ task_id: string;
19
+ occurred_at: string;
20
+ event_type: TaskEventType;
21
+ actor: string;
22
+ source: string;
23
+ session_id: string | null;
24
+ reason: string | null;
25
+ from_status: TaskLifecycleStatus | null;
26
+ to_status: TaskLifecycleStatus | null;
27
+ attempt_id: string | null;
28
+ evidence_json: string | null;
29
+ event_schema_version: 1;
30
+ }
31
+
32
+ function mapRow(row: TaskEventRow): TaskEvent {
33
+ return {
34
+ id: row.id,
35
+ taskId: row.task_id,
36
+ occurredAt: row.occurred_at,
37
+ type: row.event_type,
38
+ actor: row.actor,
39
+ source: row.source,
40
+ ...(row.session_id === null ? {} : { sessionId: row.session_id }),
41
+ ...(row.reason === null ? {} : { reason: row.reason }),
42
+ ...(row.from_status === null ? {} : { fromStatus: row.from_status }),
43
+ ...(row.to_status === null ? {} : { toStatus: row.to_status }),
44
+ ...(row.attempt_id === null ? {} : { attemptId: row.attempt_id }),
45
+ ...(row.evidence_json === null ? {} : { evidence: JSON.parse(row.evidence_json) as TaskEventEvidence }),
46
+ schemaVersion: row.event_schema_version,
47
+ };
48
+ }
49
+
50
+ export class SQLiteTaskEventStore implements TaskEventStore {
51
+ constructor(private readonly db: Db) {}
52
+
53
+ atomic<T>(operation: () => T): T { return inTransaction(this.db, operation); }
54
+
55
+ append(input: AppendTaskEvent): TaskEvent {
56
+ const event = validateTaskEvent(input);
57
+ const result = this.db.prepare(`
58
+ INSERT INTO task_events (
59
+ task_id, occurred_at, event_type, actor, source, session_id, reason,
60
+ from_status, to_status, attempt_id, evidence_json, event_schema_version
61
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
62
+ `).run(
63
+ event.taskId,
64
+ new Date().toISOString(),
65
+ event.type,
66
+ event.actor,
67
+ event.source,
68
+ event.sessionId ?? null,
69
+ event.reason ?? null,
70
+ event.fromStatus ?? null,
71
+ event.toStatus ?? null,
72
+ event.attemptId ?? null,
73
+ event.evidence === undefined ? null : JSON.stringify(event.evidence),
74
+ );
75
+ return mapRow(this.db.prepare("SELECT * FROM task_events WHERE id = ?").get(result.lastInsertRowid) as TaskEventRow);
76
+ }
77
+
78
+ history(taskId: string, query: TaskHistoryQuery = {}): TaskHistoryPage {
79
+ const { limit, direction, cursor } = normalizeTaskHistoryQuery(query);
80
+ const comparator = direction === "desc" ? "<" : ">";
81
+ const order = direction === "desc" ? "DESC" : "ASC";
82
+ const rows = this.db.prepare(`
83
+ SELECT * FROM task_events
84
+ WHERE task_id = ? ${cursor === undefined ? "" : `AND id ${comparator} ?`}
85
+ ORDER BY occurred_at ${order}, id ${order}
86
+ LIMIT ?
87
+ `).all(...(cursor === undefined ? [taskId, limit + 1] : [taskId, cursor, limit + 1])) as TaskEventRow[];
88
+ const hasMore = rows.length > limit;
89
+ const events = rows.slice(0, limit).map(mapRow);
90
+ return { events, ...(hasMore ? { nextCursor: events.at(-1)!.id } : {}) };
91
+ }
92
+ }
package/src/cli.ts CHANGED
@@ -56,11 +56,12 @@ function installService(): void {
56
56
  const USAGE = `Usage:
57
57
  papyrus serve
58
58
  papyrus service <install|start|stop|restart|status>
59
- papyrus migrate task-lifecycle [--json]
59
+ papyrus migrate task-history [--json]
60
60
  papyrus skills run <id> [--arguments-json <json>] [--run-id <id>] [--json]
61
61
  papyrus tasks plan [--json]
62
62
  papyrus tasks graph [--json]
63
63
  papyrus tasks active [--json]
64
+ papyrus tasks history <id> [--json]
64
65
  papyrus tasks focus <id> [--json]
65
66
  papyrus tasks complete <id> [--json]
66
67
  papyrus tasks start <id> [--json]
@@ -106,8 +107,8 @@ function planText(plan: TaskExecutionPlan): string {
106
107
  export async function runMigrationCli(args: string[], client: TaskCliClient): Promise<string> {
107
108
  const json = args.includes("--json");
108
109
  const positional = args.filter((arg) => arg !== "--json");
109
- if (positional.length !== 1 || positional[0] !== "task-lifecycle") {
110
- throw new Error("migrate requires exactly `task-lifecycle`");
110
+ if (positional.length !== 1 || positional[0] !== "task-history") {
111
+ throw new Error("migrate requires exactly `task-history`");
111
112
  }
112
113
  const result = await client.call<Record<string, never>, MigrationResult>("system.migrate", {});
113
114
  if (json) return JSON.stringify(result);
@@ -174,6 +175,15 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
174
175
  human = active ? `Active: ${artifactLabel(active)}` : "No active task.";
175
176
  break;
176
177
  }
178
+ case "history": {
179
+ if (!id || dependencyId) throw new Error("tasks history requires exactly one task id");
180
+ const page = await client.call<{ id: string; direction: "desc" }, import("./domain/task-event.ts").TaskHistoryPage>("tasks.history", { id, direction: "desc" });
181
+ result = page;
182
+ human = page.events.length === 0
183
+ ? `No recorded history for ${id}.`
184
+ : [...page.events].reverse().map((event) => `${event.occurredAt} ${event.type} ${event.fromStatus ?? "∅"} → ${event.toStatus ?? "∅"} · ${event.actor}/${event.source}${event.reason ? ` · ${event.reason}` : ""}`).join("\n");
185
+ break;
186
+ }
177
187
  case "focus": {
178
188
  if (!id || dependencyId) throw new Error("tasks focus requires exactly one task id");
179
189
  const active = await client.call<{ id: string }, CliArtifact>("tasks.focus", { id });
@@ -202,7 +212,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
202
212
  }
203
213
  case "complete": {
204
214
  if (!id || dependencyId) throw new Error("tasks complete requires exactly one task id");
205
- const completion = await client.call<{ id: string }, CliCompletion>("tasks.complete", { id });
215
+ const completion = await client.call<Record<string, string>, CliCompletion>("tasks.complete", { id, actor: "user", source: "cli" });
206
216
  result = completion;
207
217
  const lines = [`${completion.completed ? "Completed" : "Rejected"}: ${artifactLabel(completion.artifact)}`];
208
218
  if (completion.focused) lines.push(`Active: ${artifactLabel(completion.focused)}`);
@@ -215,7 +225,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
215
225
  }
216
226
  case "start": {
217
227
  if (!id || dependencyId) throw new Error("tasks start requires exactly one task id");
218
- const artifact = await client.call<{ id: string }, CliArtifact>("tasks.start", { id });
228
+ const artifact = await client.call<Record<string, string>, CliArtifact>("tasks.start", { id, actor: "user", source: "cli" });
219
229
  result = artifact;
220
230
  human = `Started: ${artifactLabel(artifact)}`;
221
231
  break;
@@ -226,7 +236,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
226
236
  case "cancel": {
227
237
  if (!id || dependencyId) throw new Error(`tasks ${action} requires exactly one task id`);
228
238
  const operation = `tasks.${action}` as "tasks.submit" | "tasks.reject" | "tasks.retry" | "tasks.cancel";
229
- const artifact = await client.call<{ id: string }, CliArtifact>(operation, { id });
239
+ const artifact = await client.call<Record<string, string>, CliArtifact>(operation, { id, actor: "user", source: "cli" });
230
240
  result = artifact;
231
241
  human = `${action[0]!.toUpperCase()}${action.slice(1)}: ${artifactLabel(artifact)}`;
232
242
  break;
@@ -242,7 +252,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
242
252
  break;
243
253
  }
244
254
  default:
245
- throw new Error("tasks action must be active, focus, graph, plan, complete, start, submit, reject, retry, cancel, or depend");
255
+ throw new Error("tasks action must be active, focus, graph, plan, history, complete, start, submit, reject, retry, cancel, or depend");
246
256
  }
247
257
  return json ? JSON.stringify(result) : human;
248
258
  }
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 = 2;
10
+ export const SQLITE_SCHEMA_VERSION = 3;
11
11
  export const SERVICE_MAX_BODY_BYTES = 1_048_576;
12
12
  export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
13
13
  export const DB_OPTIMIZE_INTERVAL_MS = 24 * 60 * 60_000;
@@ -42,6 +42,12 @@ export const SKILL_RUN_ID_MAX_LENGTH = 64;
42
42
  /** Bounded automatic Pi continuations while a focused Papyrus Task remains. */
43
43
  export const TASK_DRIVER_MAX_TURNS = 20;
44
44
  export const TASK_DRIVER_MAX_UNCHANGED_TURNS = 6;
45
+ /** Append-only Task chronology query and evidence bounds. */
46
+ export const TASK_HISTORY_DEFAULT_LIMIT = 25;
47
+ export const TASK_HISTORY_MAX_LIMIT = 100;
48
+ export const TASK_EVENT_MAX_EVIDENCE_BYTES = 65_536;
49
+ export const TASK_EVENT_ACTOR_MAX_LENGTH = 128;
50
+ export const TASK_EVENT_REASON_MAX_LENGTH = 2_000;
45
51
  export const GRAPH_RENDER_PADDING_X = 2;
46
52
  export const GRAPH_RENDER_PADDING_Y = 1;
47
53
  export const GRAPH_RENDER_BOX_PADDING = 0;
package/src/db.ts CHANGED
@@ -102,6 +102,26 @@ CREATE TABLE IF NOT EXISTS task_focus (
102
102
  task_id TEXT NOT NULL UNIQUE REFERENCES artifacts(id),
103
103
  updated_at TEXT NOT NULL
104
104
  );
105
+ CREATE TABLE IF NOT EXISTS task_events (
106
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
107
+ task_id TEXT NOT NULL REFERENCES artifacts(id),
108
+ occurred_at TEXT NOT NULL,
109
+ event_type TEXT NOT NULL,
110
+ actor TEXT NOT NULL,
111
+ source TEXT NOT NULL,
112
+ session_id TEXT,
113
+ reason TEXT,
114
+ from_status TEXT,
115
+ to_status TEXT,
116
+ attempt_id TEXT,
117
+ evidence_json TEXT,
118
+ event_schema_version INTEGER NOT NULL DEFAULT 1
119
+ );
120
+ CREATE INDEX IF NOT EXISTS task_events_history_idx ON task_events(task_id, occurred_at, id);
121
+ CREATE TRIGGER IF NOT EXISTS task_events_no_update BEFORE UPDATE ON task_events
122
+ BEGIN SELECT RAISE(ABORT, 'task_events are append-only'); END;
123
+ CREATE TRIGGER IF NOT EXISTS task_events_no_delete BEFORE DELETE ON task_events
124
+ BEGIN SELECT RAISE(ABORT, 'task_events are append-only'); END;
105
125
  `;
106
126
 
107
127
  const SEED_SQL = `
@@ -165,36 +185,66 @@ export function migrateDb(db: Db): MigrationResult {
165
185
  throw new Error(`database schema ${from} is newer than supported ${SQLITE_SCHEMA_VERSION}`);
166
186
  }
167
187
  if (from === SQLITE_SCHEMA_VERSION) return { from, to: from, applied: [] };
168
- if (from !== 1) throw new Error(`no explicit migration path from database schema ${from}`);
188
+ if (from !== 1 && from !== 2) throw new Error(`no explicit migration path from database schema ${from}`);
189
+ const applied: string[] = [];
169
190
 
170
191
  inTransaction(db, () => {
171
- db.exec(`
172
- INSERT OR IGNORE INTO statuses VALUES ('todo','task');
173
- INSERT OR IGNORE INTO statuses VALUES ('in-progress','task');
174
- INSERT OR IGNORE INTO statuses VALUES ('review','task');
175
- INSERT OR IGNORE INTO statuses VALUES ('rejected','task');
176
- INSERT OR IGNORE INTO statuses VALUES ('done','task');
177
- INSERT OR IGNORE INTO statuses VALUES ('canceled','task');
178
- CREATE TABLE task_focus (
179
- scope TEXT PRIMARY KEY CHECK (scope = 'global'),
180
- task_id TEXT NOT NULL UNIQUE REFERENCES artifacts(id),
181
- updated_at TEXT NOT NULL
182
- );
183
- INSERT INTO task_focus (scope, task_id, updated_at)
184
- SELECT 'global', id, strftime('%Y-%m-%dT%H:%M:%fZ','now')
185
- FROM artifacts WHERE kind = 'task' AND status = 'active'
186
- ORDER BY updated_at DESC, id ASC LIMIT 1;
187
- UPDATE artifacts SET status = CASE status
188
- WHEN 'pending' THEN 'todo'
189
- WHEN 'active' THEN 'in-progress'
190
- WHEN 'failed' THEN 'rejected'
191
- ELSE status END
192
- WHERE kind = 'task';
193
- DELETE FROM statuses WHERE kind = 'task' AND name IN ('pending', 'active', 'failed');
194
- PRAGMA user_version = 2;
195
- `);
192
+ if (schemaVersion(db) === 1) {
193
+ db.exec(`
194
+ INSERT OR IGNORE INTO statuses VALUES ('todo','task');
195
+ INSERT OR IGNORE INTO statuses VALUES ('in-progress','task');
196
+ INSERT OR IGNORE INTO statuses VALUES ('review','task');
197
+ INSERT OR IGNORE INTO statuses VALUES ('rejected','task');
198
+ INSERT OR IGNORE INTO statuses VALUES ('done','task');
199
+ INSERT OR IGNORE INTO statuses VALUES ('canceled','task');
200
+ CREATE TABLE task_focus (
201
+ scope TEXT PRIMARY KEY CHECK (scope = 'global'),
202
+ task_id TEXT NOT NULL UNIQUE REFERENCES artifacts(id),
203
+ updated_at TEXT NOT NULL
204
+ );
205
+ INSERT INTO task_focus (scope, task_id, updated_at)
206
+ SELECT 'global', id, strftime('%Y-%m-%dT%H:%M:%fZ','now')
207
+ FROM artifacts WHERE kind = 'task' AND status = 'active'
208
+ ORDER BY updated_at DESC, id ASC LIMIT 1;
209
+ UPDATE artifacts SET status = CASE status
210
+ WHEN 'pending' THEN 'todo'
211
+ WHEN 'active' THEN 'in-progress'
212
+ WHEN 'failed' THEN 'rejected'
213
+ ELSE status END
214
+ WHERE kind = 'task';
215
+ DELETE FROM statuses WHERE kind = 'task' AND name IN ('pending', 'active', 'failed');
216
+ PRAGMA user_version = 2;
217
+ `);
218
+ applied.push("task-lifecycle-and-focus");
219
+ }
220
+ if (schemaVersion(db) === 2) {
221
+ db.exec(`
222
+ CREATE TABLE task_events (
223
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
224
+ task_id TEXT NOT NULL REFERENCES artifacts(id),
225
+ occurred_at TEXT NOT NULL,
226
+ event_type TEXT NOT NULL,
227
+ actor TEXT NOT NULL,
228
+ source TEXT NOT NULL,
229
+ session_id TEXT,
230
+ reason TEXT,
231
+ from_status TEXT,
232
+ to_status TEXT,
233
+ attempt_id TEXT,
234
+ evidence_json TEXT,
235
+ event_schema_version INTEGER NOT NULL DEFAULT 1
236
+ );
237
+ CREATE INDEX task_events_history_idx ON task_events(task_id, occurred_at, id);
238
+ CREATE TRIGGER task_events_no_update BEFORE UPDATE ON task_events
239
+ BEGIN SELECT RAISE(ABORT, 'task_events are append-only'); END;
240
+ CREATE TRIGGER task_events_no_delete BEFORE DELETE ON task_events
241
+ BEGIN SELECT RAISE(ABORT, 'task_events are append-only'); END;
242
+ PRAGMA user_version = 3;
243
+ `);
244
+ applied.push("task-history");
245
+ }
196
246
  });
197
- return { from, to: SQLITE_SCHEMA_VERSION, applied: ["task-lifecycle-and-focus"] };
247
+ return { from, to: schemaVersion(db), applied };
198
248
  }
199
249
 
200
250
  export function openDb(path: string): Db {
@@ -0,0 +1,102 @@
1
+ import {
2
+ TASK_EVENT_ACTOR_MAX_LENGTH,
3
+ TASK_EVENT_MAX_EVIDENCE_BYTES,
4
+ TASK_EVENT_REASON_MAX_LENGTH,
5
+ TASK_HISTORY_DEFAULT_LIMIT,
6
+ TASK_HISTORY_MAX_LIMIT,
7
+ } from "../constants.ts";
8
+ export type TaskLifecycleStatus = "todo" | "in-progress" | "review" | "rejected" | "done" | "canceled";
9
+
10
+ export const TASK_EVENT_TYPES = [
11
+ "created",
12
+ "started",
13
+ "submitted",
14
+ "completion_attempted",
15
+ "gates_evaluated",
16
+ "review_rejected",
17
+ "retried",
18
+ "completed",
19
+ "canceled",
20
+ ] as const;
21
+
22
+ export type TaskEventType = typeof TASK_EVENT_TYPES[number];
23
+ export type TaskEventDirection = "asc" | "desc";
24
+
25
+ export interface TaskEventContext {
26
+ actor?: string;
27
+ source?: string;
28
+ sessionId?: string;
29
+ reason?: string;
30
+ }
31
+
32
+ export interface TaskEventEvidence {
33
+ gates?: unknown;
34
+ checklist?: unknown;
35
+ result?: string;
36
+ }
37
+
38
+ export interface TaskEvent {
39
+ id: number;
40
+ taskId: string;
41
+ occurredAt: string;
42
+ type: TaskEventType;
43
+ actor: string;
44
+ source: string;
45
+ sessionId?: string;
46
+ reason?: string;
47
+ fromStatus?: TaskLifecycleStatus;
48
+ toStatus?: TaskLifecycleStatus;
49
+ attemptId?: string;
50
+ evidence?: TaskEventEvidence;
51
+ schemaVersion: 1;
52
+ }
53
+
54
+ export interface AppendTaskEvent {
55
+ taskId: string;
56
+ type: TaskEventType;
57
+ actor: string;
58
+ source: string;
59
+ sessionId?: string;
60
+ reason?: string;
61
+ fromStatus?: TaskLifecycleStatus;
62
+ toStatus?: TaskLifecycleStatus;
63
+ attemptId?: string;
64
+ evidence?: TaskEventEvidence;
65
+ }
66
+
67
+ export interface TaskHistoryQuery {
68
+ limit?: number;
69
+ cursor?: number;
70
+ direction?: TaskEventDirection;
71
+ }
72
+
73
+ export interface TaskHistoryPage {
74
+ events: TaskEvent[];
75
+ nextCursor?: number;
76
+ }
77
+
78
+ export function normalizeTaskHistoryQuery(query: TaskHistoryQuery = {}): Required<Pick<TaskHistoryQuery, "limit" | "direction">> & Pick<TaskHistoryQuery, "cursor"> {
79
+ const limit = query.limit ?? TASK_HISTORY_DEFAULT_LIMIT;
80
+ if (!Number.isInteger(limit) || limit < 1 || limit > TASK_HISTORY_MAX_LIMIT) {
81
+ throw new Error(`task history limit must be between 1 and ${TASK_HISTORY_MAX_LIMIT}`);
82
+ }
83
+ if (query.cursor !== undefined && (!Number.isInteger(query.cursor) || query.cursor < 1)) {
84
+ throw new Error("task history cursor must be a positive integer");
85
+ }
86
+ if (query.direction !== undefined && query.direction !== "asc" && query.direction !== "desc") {
87
+ throw new Error("task history direction must be asc or desc");
88
+ }
89
+ return { limit, direction: query.direction ?? "desc", ...(query.cursor === undefined ? {} : { cursor: query.cursor }) };
90
+ }
91
+
92
+ export function validateTaskEvent(event: AppendTaskEvent): AppendTaskEvent {
93
+ for (const [field, value] of [["actor", event.actor], ["source", event.source]] as const) {
94
+ if (!value || value.length > TASK_EVENT_ACTOR_MAX_LENGTH) throw new Error(`${field} must be between 1 and ${TASK_EVENT_ACTOR_MAX_LENGTH} characters`);
95
+ }
96
+ if (event.sessionId !== undefined && event.sessionId.length > TASK_EVENT_ACTOR_MAX_LENGTH) throw new Error(`sessionId cannot exceed ${TASK_EVENT_ACTOR_MAX_LENGTH} characters`);
97
+ if (event.reason !== undefined && event.reason.length > TASK_EVENT_REASON_MAX_LENGTH) throw new Error(`reason cannot exceed ${TASK_EVENT_REASON_MAX_LENGTH} characters`);
98
+ if (event.evidence !== undefined && new TextEncoder().encode(JSON.stringify(event.evidence)).byteLength > TASK_EVENT_MAX_EVIDENCE_BYTES) {
99
+ throw new Error(`task event evidence cannot exceed ${TASK_EVENT_MAX_EVIDENCE_BYTES} bytes`);
100
+ }
101
+ return event;
102
+ }
@@ -0,0 +1,43 @@
1
+ import { normalizeTaskHistoryQuery, validateTaskEvent, type AppendTaskEvent, type TaskEvent, type TaskHistoryPage, type TaskHistoryQuery } from "../domain/task-event.ts";
2
+
3
+ export interface TaskEventStore {
4
+ atomic<T>(operation: () => T): T;
5
+ append(event: AppendTaskEvent): TaskEvent;
6
+ history(taskId: string, query?: TaskHistoryQuery): TaskHistoryPage;
7
+ }
8
+
9
+ export class InMemoryTaskEventStore implements TaskEventStore {
10
+ private events: TaskEvent[] = [];
11
+ private nextId = 1;
12
+
13
+ atomic<T>(operation: () => T): T {
14
+ const length = this.events.length;
15
+ const nextId = this.nextId;
16
+ try { return operation(); }
17
+ catch (error) {
18
+ this.events.length = length;
19
+ this.nextId = nextId;
20
+ throw error;
21
+ }
22
+ }
23
+
24
+ append(event: AppendTaskEvent): TaskEvent {
25
+ const stored: TaskEvent = {
26
+ ...validateTaskEvent(event),
27
+ id: this.nextId++,
28
+ occurredAt: new Date().toISOString(),
29
+ schemaVersion: 1,
30
+ };
31
+ this.events.push(stored);
32
+ return stored;
33
+ }
34
+
35
+ history(taskId: string, query: TaskHistoryQuery = {}): TaskHistoryPage {
36
+ const { direction, limit, cursor } = normalizeTaskHistoryQuery(query);
37
+ const ordered = this.events
38
+ .filter((event) => event.taskId === taskId && (cursor === undefined || (direction === "desc" ? event.id < cursor : event.id > cursor)))
39
+ .sort((left, right) => direction === "desc" ? right.id - left.id : left.id - right.id);
40
+ const events = ordered.slice(0, limit);
41
+ return { events, ...(ordered.length > limit ? { nextCursor: events.at(-1)!.id } : {}) };
42
+ }
43
+ }
package/src/service.ts CHANGED
@@ -4,10 +4,13 @@ import { migrateDb, openDb, schemaVersion } from "./db.ts";
4
4
  import { SQLiteArtifactStore } from "./adapters/sqlite-artifact-store.ts";
5
5
  import { SQLiteGateRunner } from "./adapters/sqlite-gate-runner.ts";
6
6
  import { SQLiteTaskFocusStore } from "./adapters/sqlite-task-focus-store.ts";
7
+ import { SQLiteTaskEventStore } from "./adapters/sqlite-task-event-store.ts";
7
8
  import type { CreateArtifactInput } from "./domain/artifact.ts";
8
9
  import type { Checklist } from "./domain/checklist.ts";
10
+ import type { TaskEventContext, TaskEventDirection } from "./domain/task-event.ts";
9
11
  import type { ArtifactStore } from "./ports/artifact-store.ts";
10
12
  import type { GateRunner } from "./ports/gate-runner.ts";
13
+ import type { TaskEventStore } from "./ports/task-event-store.ts";
11
14
  import { projectTaskExecution } from "./task-execution.ts";
12
15
  import { Tasks, type TaskStatus } from "./task-service.ts";
13
16
  import {
@@ -50,6 +53,7 @@ export const EXPECTED_OPERATION_NAMES = [
50
53
  "tasks.graph",
51
54
  "tasks.plan",
52
55
  "tasks.show",
56
+ "tasks.history",
53
57
  "tasks.active",
54
58
  "tasks.focus",
55
59
  "tasks.start",
@@ -140,8 +144,19 @@ function handlers(
140
144
  artifacts: ArtifactStore,
141
145
  gates: GateRunner,
142
146
  tasks: Tasks,
147
+ events: TaskEventStore,
143
148
  migrate: () => unknown,
144
149
  ): Record<OperationName, OperationHandler> {
150
+ const eventContext = (input: OperationInput): TaskEventContext => ({
151
+ actor: optionalString(input, "actor"),
152
+ source: optionalString(input, "source"),
153
+ sessionId: optionalString(input, "session_id") ?? optionalString(input, "sessionId"),
154
+ reason: optionalString(input, "reason"),
155
+ });
156
+ const eventContextFor = (input: OperationInput, source: string): TaskEventContext => {
157
+ const context = eventContext(input);
158
+ return { ...context, source: context.source ?? source };
159
+ };
145
160
  const taskFilter = (input: OperationInput) => ({
146
161
  status: optionalString(input, "status"),
147
162
  text: optionalString(input, "text"),
@@ -149,7 +164,20 @@ function handlers(
149
164
  });
150
165
  return {
151
166
  "system.migrate": () => migrate(),
152
- "artifact.create": (input) => artifacts.create(normalizeCreateInput(input)),
167
+ "artifact.create": (input) => {
168
+ const normalized = normalizeCreateInput(input);
169
+ if (normalized.kind !== "task") return artifacts.create(normalized);
170
+ return tasks.create({
171
+ id: normalized.id,
172
+ title: string(input, "title"),
173
+ body: normalized.body,
174
+ subtype: normalized.subtype,
175
+ status: normalized.status as TaskStatus | undefined,
176
+ labels: normalized.labels,
177
+ extra: normalized.extra,
178
+ templateId: normalized.templateId,
179
+ }, eventContextFor(input, "artifact-api"));
180
+ },
153
181
  "artifact.query": (input) => artifacts.query(input),
154
182
  "artifact.show": (input) => artifacts.get(string(input, "id"), {
155
183
  tree: input["tree"] === true,
@@ -172,8 +200,17 @@ function handlers(
172
200
  depth: optionalNumber(input, "depth"),
173
201
  maxNodes: optionalNumber(input, "max_nodes") ?? optionalNumber(input, "maxNodes"),
174
202
  }),
175
- "graph.status": (input) => artifacts.setStatus(string(input, "id"), string(input, "status")),
176
- "gates.run": (input) => gates.runAsync(string(input, "id")),
203
+ "graph.status": (input) => {
204
+ const id = string(input, "id");
205
+ if (artifacts.get(id)?.kind === "task") throw new Error("task lifecycle changes require a tasks.* operation so history and review invariants are preserved");
206
+ return artifacts.setStatus(id, string(input, "status"));
207
+ },
208
+ "gates.run": (input) => {
209
+ const id = string(input, "id");
210
+ return artifacts.get(id)?.kind === "task"
211
+ ? tasks.runGates(id, eventContextFor(input, "gates-api"))
212
+ : gates.runAsync(id);
213
+ },
177
214
  "rules.injectable": () => listInjectableRules(artifacts, tasks.active()?.id)
178
215
  .map(({ id, title, body, extra }) => ({ id, title, body, extra })),
179
216
  "tasks.create": (input) => tasks.create({
@@ -187,22 +224,27 @@ function handlers(
187
224
  templateId: optionalString(input, "template_id") ?? optionalString(input, "templateId"),
188
225
  parentId: optionalString(input, "parent_id") ?? optionalString(input, "parentId"),
189
226
  dependsOn: (input["depends_on"] ?? input["dependsOn"]) as string[] | undefined,
190
- }),
227
+ }, eventContext(input)),
191
228
  "tasks.list": (input) => tasks.list(taskFilter(input)),
192
229
  "tasks.graph": (input) => tasks.graph(taskFilter(input)),
193
230
  "tasks.plan": (input) => projectTaskExecution(tasks.graph(taskFilter(input))),
194
231
  "tasks.show": (input) => tasks.show(string(input, "id")),
232
+ "tasks.history": (input) => tasks.history(string(input, "id"), {
233
+ limit: optionalNumber(input, "limit"),
234
+ cursor: optionalNumber(input, "cursor"),
235
+ direction: optionalString(input, "direction") as TaskEventDirection | undefined,
236
+ }),
195
237
  "tasks.active": () => tasks.active(),
196
238
  "tasks.focus": (input) => tasks.focus(string(input, "id")),
197
- "tasks.start": (input) => tasks.transition(string(input, "id"), "start"),
198
- "tasks.submit": (input) => tasks.transition(string(input, "id"), "submit"),
199
- "tasks.complete": (input) => tasks.completeAsync(string(input, "id")),
200
- "tasks.run_gates": (input) => tasks.runGates(string(input, "id")),
239
+ "tasks.start": (input) => tasks.transition(string(input, "id"), "start", eventContext(input)),
240
+ "tasks.submit": (input) => tasks.transition(string(input, "id"), "submit", eventContext(input)),
241
+ "tasks.complete": (input) => tasks.completeAsync(string(input, "id"), eventContext(input)),
242
+ "tasks.run_gates": (input) => tasks.runGates(string(input, "id"), eventContext(input)),
201
243
  "tasks.set_checklist": (input) => tasks.setChecklist(string(input, "id"), input["checklist"] as Checklist),
202
244
  "tasks.context": () => taskContext(artifacts, tasks.active()?.id),
203
- "tasks.reject": (input) => tasks.transition(string(input, "id"), "reject"),
204
- "tasks.retry": (input) => tasks.transition(string(input, "id"), "retry"),
205
- "tasks.cancel": (input) => tasks.transition(string(input, "id"), "cancel"),
245
+ "tasks.reject": (input) => tasks.transition(string(input, "id"), "reject", eventContext(input)),
246
+ "tasks.retry": (input) => tasks.transition(string(input, "id"), "retry", eventContext(input)),
247
+ "tasks.cancel": (input) => tasks.transition(string(input, "id"), "cancel", eventContext(input)),
206
248
  "tasks.depend": (input) => tasks.depend(string(input, "id"), string(input, "dependency_id")),
207
249
  "tasks.contain": (input) => tasks.contain(string(input, "parent_id"), string(input, "child_id")),
208
250
  "docs.create": (input) => createDocument(artifacts, {
@@ -244,7 +286,7 @@ function handlers(
244
286
  "skills.run": (input) => instantiateSkillWorkflow(artifacts, string(input, "id"), {
245
287
  runId: optionalString(input, "run_id") ?? optionalString(input, "runId"),
246
288
  arguments: input["arguments"] as Record<string, unknown> | undefined,
247
- }),
289
+ }, { events, context: eventContextFor(input, "skill-run") }),
248
290
  "skills.enable": (input) => transitionSkill(artifacts, string(input, "id"), "enable"),
249
291
  "skills.disable": (input) => transitionSkill(artifacts, string(input, "id"), "disable"),
250
292
  "skills.instantiate": (input) => instantiateTemplate(artifacts, string(input, "template_id"), normalizeCreateInput(input)),
@@ -256,8 +298,9 @@ export function createPapyrusService(path: string): PapyrusService {
256
298
  const artifacts = new SQLiteArtifactStore(db);
257
299
  const gates = new SQLiteGateRunner(db);
258
300
  const focus = new SQLiteTaskFocusStore(db);
259
- const tasks = new Tasks(artifacts, gates, focus);
260
- const registry = handlers(artifacts, gates, tasks, () => migrateDb(db));
301
+ const events = new SQLiteTaskEventStore(db);
302
+ const tasks = new Tasks(artifacts, gates, focus, events);
303
+ const registry = handlers(artifacts, gates, tasks, events, () => migrateDb(db));
261
304
  const state = (): SchemaState => {
262
305
  const current = schemaVersion(db);
263
306
  return { current, required: SQLITE_SCHEMA_VERSION, migrationRequired: current !== SQLITE_SCHEMA_VERSION };
@@ -269,7 +312,7 @@ export function createPapyrusService(path: string): PapyrusService {
269
312
  const handler = registry[operation as OperationName];
270
313
  if (!handler) throw new UnknownOperationError(`unknown operation "${operation}"`);
271
314
  if (operation !== "system.migrate" && state().migrationRequired) {
272
- throw new MigrationRequiredError("database migration required; run `papyrus migrate task-lifecycle`");
315
+ throw new MigrationRequiredError("database migration required; run `papyrus migrate task-history`");
273
316
  }
274
317
  return handler(input);
275
318
  },
@@ -9,9 +9,11 @@ import {
9
9
  type SkillDefinition,
10
10
  } from "./domain/skill-definition.ts";
11
11
  import type { ArtifactStore } from "./ports/artifact-store.ts";
12
+ import type { TaskEventContext } from "./domain/task-event.ts";
13
+ import type { TaskEventStore } from "./ports/task-event-store.ts";
12
14
  import { requireAtomicArtifactStore } from "./ports/atomic-artifact-store.ts";
13
15
  import { projectTaskExecution, type TaskExecutionPlan } from "./task-execution.ts";
14
- import type { TaskGraph, TaskNode } from "./task-service.ts";
16
+ import type { TaskGraph, TaskNode, TaskStatus } from "./task-service.ts";
15
17
 
16
18
  const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
17
19
  const EXACT_PLACEHOLDER_PATTERN = /^{{\s*([A-Za-z][A-Za-z0-9_-]{0,63})\s*}}$/;
@@ -115,6 +117,7 @@ export function instantiateSkillWorkflow(
115
117
  artifacts: ArtifactStore,
116
118
  skillId: string,
117
119
  input: InstantiateSkillWorkflowInput = {},
120
+ history?: { events: TaskEventStore; context?: TaskEventContext },
118
121
  ): SkillWorkflowRunResult {
119
122
  const { definition } = requireWorkflowSkill(artifacts, skillId);
120
123
  const arguments_ = resolveSkillArguments(definition, input.arguments);
@@ -138,7 +141,7 @@ export function instantiateSkillWorkflow(
138
141
  }
139
142
 
140
143
  const atomic = requireAtomicArtifactStore(artifacts);
141
- return atomic.atomic(() => {
144
+ const persist = () => atomic.atomic(() => {
142
145
  const docs = rendered.blueprints.docs.map((blueprint) => artifacts.create({
143
146
  id: ids.get(blueprint.ref),
144
147
  kind: "doc",
@@ -163,14 +166,26 @@ export function instantiateSkillWorkflow(
163
166
  scope: { type: "skill-run", runId, taskIds },
164
167
  },
165
168
  }));
166
- const tasks = rendered.blueprints.tasks.map((blueprint) => artifacts.create({
167
- id: ids.get(blueprint.ref),
168
- kind: "task",
169
- title: blueprint.title,
170
- body: blueprint.body,
171
- labels: withRunLabel(blueprint.labels, runId),
172
- extra: { ...(blueprint.extra ?? {}), skillRun: { id: runId, skillId, ref: blueprint.ref } },
173
- }));
169
+ const tasks = rendered.blueprints.tasks.map((blueprint) => {
170
+ const task = artifacts.create({
171
+ id: ids.get(blueprint.ref),
172
+ kind: "task",
173
+ title: blueprint.title,
174
+ body: blueprint.body,
175
+ labels: withRunLabel(blueprint.labels, runId),
176
+ extra: { ...(blueprint.extra ?? {}), skillRun: { id: runId, skillId, ref: blueprint.ref } },
177
+ });
178
+ if (history) history.events.append({
179
+ taskId: task.id,
180
+ type: "created",
181
+ actor: history.context?.actor ?? "system",
182
+ source: history.context?.source ?? "skill-run",
183
+ toStatus: task.status as TaskStatus,
184
+ ...(history.context?.sessionId === undefined ? {} : { sessionId: history.context.sessionId }),
185
+ ...(history.context?.reason === undefined ? {} : { reason: history.context.reason }),
186
+ });
187
+ return task;
188
+ });
174
189
 
175
190
  for (const blueprint of rendered.blueprints.tasks) {
176
191
  const id = ids.get(blueprint.ref)!;
@@ -201,4 +216,5 @@ export function instantiateSkillWorkflow(
201
216
  execution: projectTaskExecution(executionGraph(tasks, rendered, ids)),
202
217
  };
203
218
  });
219
+ return history ? history.events.atomic(persist) : persist();
204
220
  }
@@ -2,9 +2,11 @@ import { TASK_EXECUTION_MAX_DEGREE, TASK_EXECUTION_MAX_EDGES, TASK_EXECUTION_MAX
2
2
  import type { Artifact } from "./domain/artifact.ts";
3
3
  import { checklistEntries, validateChecklist, type Checklist, type ProofReference } from "./domain/checklist.ts";
4
4
  import type { Gate, GateResult } from "./domain/gate.ts";
5
+ import type { AppendTaskEvent, TaskEventContext, TaskHistoryPage, TaskHistoryQuery, TaskLifecycleStatus } from "./domain/task-event.ts";
5
6
  import type { ArtifactStore } from "./ports/artifact-store.ts";
6
7
  import type { GateRunner } from "./ports/gate-runner.ts";
7
8
  import { InMemoryTaskFocusStore, type TaskFocusStore } from "./ports/task-focus-store.ts";
9
+ import { InMemoryTaskEventStore, type TaskEventStore } from "./ports/task-event-store.ts";
8
10
  import { assertDependencyEdgeAllowed } from "./task-execution.ts";
9
11
 
10
12
  export interface TaskFilter {
@@ -13,11 +15,13 @@ export interface TaskFilter {
13
15
  limit?: number;
14
16
  }
15
17
 
16
- export type TaskStatus = "todo" | "in-progress" | "review" | "rejected" | "done" | "canceled";
18
+ export type TaskStatus = TaskLifecycleStatus;
17
19
 
18
20
  export interface CreateTaskInput {
21
+ id?: string;
19
22
  title: string;
20
23
  body?: string;
24
+ subtype?: string;
21
25
  status?: TaskStatus;
22
26
  labels?: string[];
23
27
  extra?: Record<string, unknown>;
@@ -77,6 +81,7 @@ export class Tasks {
77
81
  private readonly artifacts: ArtifactStore,
78
82
  private readonly gates: GateRunner,
79
83
  private readonly focusStore: TaskFocusStore = new InMemoryTaskFocusStore(),
84
+ private readonly events: TaskEventStore = new InMemoryTaskEventStore(),
80
85
  ) {}
81
86
 
82
87
  private require(id: string): Artifact {
@@ -86,27 +91,32 @@ export class Tasks {
86
91
  return artifact;
87
92
  }
88
93
 
89
- create(input: CreateTaskInput): Artifact {
90
- if ((input.dependsOn?.length ?? 0) > TASK_EXECUTION_MAX_DEGREE) {
91
- throw new Error(`task cannot exceed ${TASK_EXECUTION_MAX_DEGREE} prerequisites`);
92
- }
93
- if (input.parentId) this.require(input.parentId);
94
- for (const dependency of input.dependsOn ?? []) this.require(dependency);
95
- const extra: Record<string, unknown> = { ...(input.extra ?? {}) };
96
- if (input.gates !== undefined) extra["gates"] = input.gates;
97
- if (input.checklist !== undefined) extra["checklist"] = validateChecklist(input.checklist);
98
- const task = this.artifacts.create({
99
- kind: "task",
100
- title: input.title,
101
- body: input.body,
102
- status: input.status,
103
- labels: input.labels,
104
- extra,
105
- templateId: input.templateId,
94
+ create(input: CreateTaskInput, context: TaskEventContext = {}): Artifact {
95
+ return this.events.atomic(() => {
96
+ if ((input.dependsOn?.length ?? 0) > TASK_EXECUTION_MAX_DEGREE) {
97
+ throw new Error(`task cannot exceed ${TASK_EXECUTION_MAX_DEGREE} prerequisites`);
98
+ }
99
+ if (input.parentId) this.require(input.parentId);
100
+ for (const dependency of input.dependsOn ?? []) this.require(dependency);
101
+ const extra: Record<string, unknown> = { ...(input.extra ?? {}) };
102
+ if (input.gates !== undefined) extra["gates"] = input.gates;
103
+ if (input.checklist !== undefined) extra["checklist"] = validateChecklist(input.checklist);
104
+ const task = this.artifacts.create({
105
+ id: input.id,
106
+ kind: "task",
107
+ title: input.title,
108
+ body: input.body,
109
+ subtype: input.subtype,
110
+ status: input.status,
111
+ labels: input.labels,
112
+ extra,
113
+ templateId: input.templateId,
114
+ });
115
+ if (input.parentId) this.contain(input.parentId, task.id);
116
+ for (const dependency of input.dependsOn ?? []) this.depend(task.id, dependency);
117
+ this.appendEvent({ taskId: task.id, type: "created", toStatus: task.status as TaskStatus }, context);
118
+ return this.show(task.id);
106
119
  });
107
- if (input.parentId) this.contain(input.parentId, task.id);
108
- for (const dependency of input.dependsOn ?? []) this.depend(task.id, dependency);
109
- return this.show(task.id);
110
120
  }
111
121
 
112
122
  list(filter: TaskFilter = {}): Artifact[] {
@@ -185,48 +195,55 @@ export class Tasks {
185
195
  return task;
186
196
  }
187
197
 
188
- transition(id: string, action: TaskTransition): Artifact {
189
- const task = this.require(id);
190
- const transition = TASK_TRANSITIONS[action];
191
- if (!transition.from.includes(task.status as TaskStatus)) throw new Error(`cannot ${action} task from ${task.status}`);
192
- if (action === "start") {
193
- const blocking = this.dependencyIds(id).filter((dependencyId) => this.require(dependencyId).status !== "done");
194
- if (blocking.length > 0) throw new Error(`task "${id}" is blocked by dependencies: ${blocking.join(", ")}`);
195
- this.focusStore.set(id);
196
- }
197
- const updated = this.artifacts.setStatus(id, transition.to)!;
198
- if (action === "start" || action === "retry") this.propagateProgressToAncestors(id);
199
- if (action === "retry") this.focusStore.set(id);
200
- if (action === "cancel") this.focusStore.clear(id);
201
- return updated;
198
+ transition(id: string, action: TaskTransition, context: TaskEventContext = {}): Artifact {
199
+ return this.events.atomic(() => {
200
+ const task = this.require(id);
201
+ const transition = TASK_TRANSITIONS[action];
202
+ if (!transition.from.includes(task.status as TaskStatus)) throw new Error(`cannot ${action} task from ${task.status}`);
203
+ if (action === "start") {
204
+ const blocking = this.dependencyIds(id).filter((dependencyId) => this.require(dependencyId).status !== "done");
205
+ if (blocking.length > 0) throw new Error(`task "${id}" is blocked by dependencies: ${blocking.join(", ")}`);
206
+ this.focusStore.set(id);
207
+ }
208
+ const updated = this.artifacts.setStatus(id, transition.to)!;
209
+ const eventType = { start: "started", submit: "submitted", reject: "review_rejected", retry: "retried", cancel: "canceled" }[action] as AppendTaskEvent["type"];
210
+ this.appendEvent({ taskId: id, type: eventType, fromStatus: task.status as TaskStatus, toStatus: transition.to }, context);
211
+ if (action === "start" || action === "retry") this.propagateProgressToAncestors(id, context);
212
+ if (action === "retry") this.focusStore.set(id);
213
+ if (action === "cancel") this.focusStore.clear(id);
214
+ return updated;
215
+ });
202
216
  }
203
217
 
204
- complete(id: string): TaskCompletion {
218
+ complete(id: string, context: TaskEventContext = {}): TaskCompletion {
205
219
  const task = this.requireReview(id);
220
+ const attemptId = crypto.randomUUID();
221
+ this.events.atomic(() => this.appendEvent({ taskId: id, type: "completion_attempted", fromStatus: "review", toStatus: "review", attemptId }, context));
206
222
  const checklist = this.reviewChecklist(task);
207
223
  const results = this.gates.run(id);
208
- if (results.some((gate) => !gate.passed) || checklist.some((item) => !item.accepted)) {
209
- const artifact = this.artifacts.setStatus(id, "rejected")!;
210
- return { artifact, gates: results, checklist, completed: false, focused: this.active(), blocked: [] };
211
- }
212
- return this.finish(id, results, checklist);
224
+ return this.resolveCompletion(id, attemptId, results, checklist, context);
213
225
  }
214
226
 
215
- async completeAsync(id: string): Promise<TaskCompletion> {
227
+ async completeAsync(id: string, context: TaskEventContext = {}): Promise<TaskCompletion> {
216
228
  const task = this.requireReview(id);
229
+ const attemptId = crypto.randomUUID();
230
+ this.events.atomic(() => this.appendEvent({ taskId: id, type: "completion_attempted", fromStatus: "review", toStatus: "review", attemptId }, context));
217
231
  const checklist = this.reviewChecklist(task);
218
232
  const results = await this.gates.runAsync(id);
219
- if (results.some((gate) => !gate.passed) || checklist.some((item) => !item.accepted)) {
220
- const artifact = this.artifacts.setStatus(id, "rejected")!;
221
- return { artifact, gates: results, checklist, completed: false, focused: this.active(), blocked: [] };
222
- }
223
- const current = this.requireReview(id);
224
- return this.finish(current.id, results, checklist);
233
+ this.requireReview(id);
234
+ return this.resolveCompletion(id, attemptId, results, checklist, context);
235
+ }
236
+
237
+ async runGates(id: string, context: TaskEventContext = {}): Promise<GateResult[]> {
238
+ this.require(id);
239
+ const results = await this.gates.runAsync(id);
240
+ this.events.atomic(() => this.appendEvent({ taskId: id, type: "gates_evaluated", evidence: { gates: results, result: results.every((gate) => gate.passed) ? "passed" : "failed" } }, context));
241
+ return results;
225
242
  }
226
243
 
227
- runGates(id: string): Promise<GateResult[]> {
244
+ history(id: string, query: TaskHistoryQuery = {}): TaskHistoryPage {
228
245
  this.require(id);
229
- return this.gates.runAsync(id);
246
+ return this.events.history(id, query);
230
247
  }
231
248
 
232
249
  setChecklist(id: string, checklist: Checklist): Artifact {
@@ -282,7 +299,7 @@ export class Tasks {
282
299
  .filter((parentId, index, ids) => ids.indexOf(parentId) === index);
283
300
  }
284
301
 
285
- private propagateProgressToAncestors(id: string): void {
302
+ private propagateProgressToAncestors(id: string, context: TaskEventContext): void {
286
303
  const pending = this.parentIds(id);
287
304
  const visited = new Set<string>();
288
305
  while (pending.length > 0) {
@@ -291,7 +308,14 @@ export class Tasks {
291
308
  if (visited.size >= TASK_EXECUTION_MAX_NODES) throw new Error("task ancestry exceeds execution node bound");
292
309
  visited.add(parentId);
293
310
  const parent = this.require(parentId);
294
- if (parent.status === "todo") this.artifacts.setStatus(parentId, "in-progress");
311
+ if (parent.status === "todo") {
312
+ this.artifacts.setStatus(parentId, "in-progress");
313
+ this.appendEvent({ taskId: parentId, type: "started", fromStatus: "todo", toStatus: "in-progress" }, {
314
+ ...context,
315
+ source: "task-ancestry",
316
+ reason: `nested task ${id} entered progress`,
317
+ });
318
+ }
295
319
  pending.push(...this.parentIds(parentId));
296
320
  }
297
321
  }
@@ -315,7 +339,32 @@ export class Tasks {
315
339
  return ids;
316
340
  }
317
341
 
318
- private finish(id: string, gates: GateResult[], checklist: ChecklistReview[]): TaskCompletion {
342
+ private resolveCompletion(
343
+ id: string,
344
+ attemptId: string,
345
+ gates: GateResult[],
346
+ checklist: ChecklistReview[],
347
+ context: TaskEventContext,
348
+ ): TaskCompletion {
349
+ const failed = gates.some((gate) => !gate.passed) || checklist.some((item) => !item.accepted);
350
+ if (failed) {
351
+ return this.events.atomic(() => {
352
+ const artifact = this.artifacts.setStatus(id, "rejected")!;
353
+ this.appendEvent({
354
+ taskId: id,
355
+ type: "review_rejected",
356
+ fromStatus: "review",
357
+ toStatus: "rejected",
358
+ attemptId,
359
+ evidence: { gates, checklist, result: "rejected" },
360
+ }, context);
361
+ return { artifact, gates, checklist, completed: false, focused: this.active(), blocked: [] };
362
+ });
363
+ }
364
+ return this.events.atomic(() => this.finish(id, attemptId, gates, checklist, context));
365
+ }
366
+
367
+ private finish(id: string, attemptId: string, gates: GateResult[], checklist: ChecklistReview[], context: TaskEventContext): TaskCompletion {
319
368
  const successorIds = this.relationships(id)
320
369
  .filter((edge) => edge.relation === "depends_on" && edge.to === id)
321
370
  .map((edge) => edge.from);
@@ -323,6 +372,14 @@ export class Tasks {
323
372
  throw new Error(`task "${id}" exceeds ${TASK_EXECUTION_MAX_DEGREE} successors`);
324
373
  }
325
374
  const artifact = this.artifacts.setStatus(id, "done")!;
375
+ this.appendEvent({
376
+ taskId: id,
377
+ type: "completed",
378
+ fromStatus: "review",
379
+ toStatus: "done",
380
+ attemptId,
381
+ evidence: { gates, checklist, result: "completed" },
382
+ }, context);
326
383
  this.focusStore.clear(id);
327
384
  const blocked: TaskBlockage[] = [];
328
385
  let focused: Artifact | null = null;
@@ -343,6 +400,16 @@ export class Tasks {
343
400
  return { artifact, gates, checklist, completed: true, focused, blocked };
344
401
  }
345
402
 
403
+ private appendEvent(event: Omit<AppendTaskEvent, "actor" | "source">, context: TaskEventContext): void {
404
+ this.events.append({
405
+ ...event,
406
+ actor: context.actor ?? "system",
407
+ source: context.source ?? "task-domain",
408
+ ...(context.sessionId === undefined ? {} : { sessionId: context.sessionId }),
409
+ ...(context.reason === undefined ? {} : { reason: context.reason }),
410
+ });
411
+ }
412
+
346
413
  private requireReview(id: string): Artifact {
347
414
  const task = this.require(id);
348
415
  if (task.status !== "review") throw new Error(`cannot complete task from ${task.status}`);