@danypops/papyrus 0.4.0 → 0.6.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
@@ -37,6 +37,8 @@ papyrus tasks plan
37
37
  papyrus tasks depend <task-id> <prerequisite-id>
38
38
  papyrus tasks start <task-id>
39
39
  papyrus tasks complete <task-id>
40
+ papyrus tasks automate <task-id> <on|off>
41
+ papyrus automation status
40
42
  ```
41
43
 
42
44
  For repository work, install the versioned ownership guard once:
@@ -101,7 +103,7 @@ Internally, application services depend on the `ArtifactStore` and `GateRunner`
101
103
 
102
104
  ## Interactive frontends
103
105
 
104
- - `/tasks` — task lifecycle, gates, dependencies, and nested metadata
106
+ - `/tasks` — task lifecycle, append-only history, gates, dependencies, and nested metadata
105
107
  - `/docs` — searchable documents, lifecycle, details, and graph links
106
108
  - `/rules` — severity/condition rows, exact injection preview, enable/disable, and task gating
107
109
  - `/skills` — trigger/tools rows, invocation into the editor, and artifact templates
@@ -122,7 +124,7 @@ Run `/tasks` for the interactive task panel:
122
124
  - successors are never auto-completed; fan-in, fan-out, diamonds, and disconnected DAGs remain explicit
123
125
  - inspect deterministic execution layers, readiness, a box-drawn nested hierarchy, composition, dependencies, evidence-bearing checklists, and verification gates
124
126
  - 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
127
+ - 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
128
  - the compact persistent widget shows bounded open work in containment order and always retains the active focus
127
129
 
128
130
  Authenticated CLI parity covers the changed lifecycle and focus operations:
@@ -130,6 +132,7 @@ Authenticated CLI parity covers the changed lifecycle and focus operations:
130
132
  ```bash
131
133
  papyrus tasks graph --json
132
134
  papyrus tasks active --json
135
+ papyrus tasks history <id> --json
133
136
  papyrus tasks focus <id> --json
134
137
  papyrus tasks start <id> --json
135
138
  papyrus tasks submit <id> --json
@@ -137,8 +140,31 @@ papyrus tasks complete <id> --json
137
140
  papyrus tasks reject <id> --json
138
141
  papyrus tasks retry <id> --json
139
142
  papyrus tasks cancel <id> --json
143
+ papyrus tasks automate <id> <on|off> --json
144
+ papyrus automation status --json
145
+ papyrus automation run --json
140
146
  ```
141
147
 
148
+ ### Opt-in supervised automation
149
+
150
+ Background graph reconciliation is off by default and requires two independent opt-ins: daemon configuration and `automation.enabled` on each Task. Only opted-in Tasks already in `review` are eligible for automatic gate/checklist review; Papyrus never skips the review lifecycle. When one completes, directly dependent opted-in successors that become ready may move from `todo` to `in-progress`. Every completion, rejection, and start is written to append-only history with actor `daemon`, source `automation-reconciler`, reason, and bounded gate evidence.
151
+
152
+ Enable the daemon with a systemd user-service override and restart it:
153
+
154
+ ```ini
155
+ [Service]
156
+ Environment=PAPYRUS_AUTOMATION_ENABLED=1
157
+ ```
158
+
159
+ ```bash
160
+ systemctl --user edit papyrus.service
161
+ systemctl --user restart papyrus.service
162
+ papyrus tasks automate <task-id> on
163
+ papyrus automation status
164
+ ```
165
+
166
+ Secure defaults are a 60-second interval, 10 Task transitions per sweep, gate concurrency 1, and a 120-second sweep deadline. Optional environment settings are `PAPYRUS_AUTOMATION_INTERVAL_MS` (10 seconds–1 hour), `PAPYRUS_AUTOMATION_MAX_TASKS` (1–100), `PAPYRUS_AUTOMATION_GATE_CONCURRENCY` (1–4), and `PAPYRUS_AUTOMATION_MAX_RUNTIME_MS` (1 ms–10 minutes). Candidate scans are capped at 1,000 review Tasks, sweeps are single-flight, subprocess gates inherit the sweep deadline, result arrays are bounded by the Task limit, and logs contain counts rather than gate output. `papyrus automation run` uses the same policy and refuses to reconcile while global automation is disabled.
167
+
142
168
  Checklist criteria are an item-to-proof map. Every new item requires one or more typed references to inspectable evidence; proof presence does not imply that the evidence passed an executable gate:
143
169
 
144
170
  ```ts
@@ -171,10 +197,10 @@ packed install npm:@danypops/papyrus
171
197
  ~/.pi/agent/npm/node_modules/.bin/papyrus service install
172
198
  ```
173
199
 
174
- Existing databases are never migrated on daemon boot. After upgrading across the task-lifecycle schema boundary, run the authenticated CLI migration explicitly:
200
+ 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
201
 
176
202
  ```bash
177
- ~/.pi/agent/npm/node_modules/.bin/papyrus migrate task-lifecycle
203
+ ~/.pi/agent/npm/node_modules/.bin/papyrus migrate task-history
178
204
  ```
179
205
 
180
206
  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, set_automation, 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,11 @@ 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()),
47
+ enabled: Type.Optional(Type.Boolean()),
42
48
  labels: Type.Optional(Type.Array(Type.String())),
43
49
  extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
44
50
  gates: Type.Optional(Type.Array(Type.Record(Type.String(), Type.Unknown()))),
@@ -52,8 +58,9 @@ export function registerDomainTools(pi: ExtensionAPI): void {
52
58
  async execute(_id, params) {
53
59
  try {
54
60
  const action = params.action;
61
+ const request = { ...params, actor: "agent", source: "pi-tool" };
55
62
  if (action === "create") {
56
- const artifact = await callService<Record<string, unknown>, Artifact>("tasks.create", params);
63
+ const artifact = await callService<Record<string, unknown>, Artifact>("tasks.create", request);
57
64
  return text(`Created task ${artifactLine(artifact)}`, { artifact });
58
65
  }
59
66
  if (action === "list") {
@@ -64,6 +71,11 @@ export function registerDomainTools(pi: ExtensionAPI): void {
64
71
  const artifact = await callService<Record<string, unknown>, Artifact>("tasks.show", params);
65
72
  return text(`${artifactLine(artifact)}\n\n${artifact.body}`, { artifact });
66
73
  }
74
+ if (action === "history") {
75
+ const page = await callService<Record<string, unknown>, TaskHistoryPage>("tasks.history", request);
76
+ const lines = page.events.map((event) => `${event.occurredAt} ${event.type} ${event.fromStatus ?? "∅"} → ${event.toStatus ?? "∅"} · ${event.actor}/${event.source}${event.reason ? ` · ${event.reason}` : ""}`);
77
+ return text(lines.join("\n") || "No recorded history for this task.", { page });
78
+ }
67
79
  if (action === "active") {
68
80
  const artifact = await callService<Record<string, unknown>, Artifact | null>("tasks.active", params);
69
81
  return text(artifact ? `Active: ${artifactLine(artifact)}` : "No active task.", { artifact });
@@ -92,7 +104,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
92
104
  return text(`Updated checklist: ${artifactLine(artifact)}`, { artifact });
93
105
  }
94
106
  if (action === "complete") {
95
- const result = await callService<Record<string, unknown>, TaskCompletion>("tasks.complete", params);
107
+ const result = await callService<Record<string, unknown>, TaskCompletion>("tasks.complete", request);
96
108
  const gates = result.gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n");
97
109
  const checklist = result.checklist.map((item) => `${item.accepted ? "✓" : "✗"} proof: ${item.item}${item.reason ? ` — ${item.reason}` : ""}`).join("\n");
98
110
  const focused = result.focused ? `\nActive: ${artifactLine(result.focused)}` : "";
@@ -102,7 +114,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
102
114
  return text(`${result.completed ? "Completed" : "Rejected"}: ${artifactLine(result.artifact)}${focused}${blocked}${checklist ? `\n${checklist}` : ""}${gates ? `\n${gates}` : ""}`, { ...result });
103
115
  }
104
116
  if (action === "run_gates") {
105
- const gates = await callService<Record<string, unknown>, GateResult[]>("tasks.run_gates", params);
117
+ const gates = await callService<Record<string, unknown>, GateResult[]>("tasks.run_gates", request);
106
118
  return text(gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n") || "No gates configured.", { gates });
107
119
  }
108
120
  const operations = {
@@ -112,12 +124,13 @@ export function registerDomainTools(pi: ExtensionAPI): void {
112
124
  reject: "tasks.reject",
113
125
  retry: "tasks.retry",
114
126
  cancel: "tasks.cancel",
127
+ set_automation: "tasks.set_automation",
115
128
  depend: "tasks.depend",
116
129
  contain: "tasks.contain",
117
130
  } as const;
118
131
  const operation = operations[action as keyof typeof operations];
119
132
  if (!operation) return text(`Unknown tasks action: ${action}`);
120
- const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
133
+ const artifact = await callService<Record<string, unknown>, Artifact>(operation, request);
121
134
  return text(artifactLine(artifact), { artifact });
122
135
  } catch (error) {
123
136
  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";
@@ -29,6 +30,12 @@ const STATUS_ACTIONS: Record<string, string[]> = {
29
30
 
30
31
  type TaskRow = Artifact;
31
32
 
33
+ function taskAutomationEnabled(task: Artifact): boolean {
34
+ const automation = task.extra["automation"];
35
+ return typeof automation === "object" && automation !== null && !Array.isArray(automation)
36
+ && (automation as Record<string, unknown>)["enabled"] === true;
37
+ }
38
+
32
39
  export interface TaskHierarchyRow {
33
40
  task: TaskRow;
34
41
  depth: number;
@@ -70,7 +77,7 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
70
77
  if (create === "Create a task") {
71
78
  const title = await ctx.ui.input("Task title:", "");
72
79
  if (title) {
73
- await callService("tasks.create", { title });
80
+ await callService("tasks.create", { title, actor: "user", source: "tasks-tui" });
74
81
  graph = await loadTaskGraph();
75
82
  }
76
83
  }
@@ -85,9 +92,11 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
85
92
  if (action.type !== "action" || !action.row) continue;
86
93
 
87
94
  const active = graph.nodes.find((node) => node.task.id === action.row!.id)?.active === true;
95
+ const automationEnabled = taskAutomationEnabled(action.row);
88
96
  const choices = [
89
97
  "Show details",
90
98
  ...(!active && action.row.status !== "done" && action.row.status !== "canceled" ? ["Make active"] : []),
99
+ ...(action.row.status !== "done" && action.row.status !== "canceled" ? [automationEnabled ? "Disable automation" : "Enable automation"] : []),
91
100
  ...(action.row.status === "review" ? ["Run gates"] : []),
92
101
  ...(STATUS_ACTIONS[action.row.status] ?? []),
93
102
  ];
@@ -97,7 +106,8 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
97
106
  if (choice === "Show details") {
98
107
  const art = await callService<Record<string, unknown>, Artifact | null>("tasks.show", { id: action.row.id });
99
108
  if (!art) { ctx.ui.notify("Not found", "error"); continue; }
100
- await showTaskDetails(ctx, art, graph);
109
+ const history = await callService<Record<string, unknown>, TaskHistoryPage>("tasks.history", { id: art.id, direction: "desc" });
110
+ await showTaskDetails(ctx, art, graph, undefined, [...history.events].reverse());
101
111
  } else if (choice === "Make active") {
102
112
  try {
103
113
  await callService<Record<string, unknown>, Artifact>("tasks.focus", { id: action.row.id });
@@ -105,9 +115,23 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
105
115
  } catch (error) {
106
116
  ctx.ui.notify(`Focus failed: ${error instanceof Error ? error.message : error}`, "error");
107
117
  }
118
+ } else if (choice === "Enable automation" || choice === "Disable automation") {
119
+ try {
120
+ const enabled = choice === "Enable automation";
121
+ const updated = await callService<Record<string, unknown>, Artifact>("tasks.set_automation", {
122
+ id: action.row.id,
123
+ enabled,
124
+ actor: "user",
125
+ source: "tasks-tui",
126
+ });
127
+ action.row.extra = updated.extra;
128
+ ctx.ui.notify(`Automation ${enabled ? "enabled" : "disabled"}: ${action.row.title}`, enabled ? "warning" : "info");
129
+ } catch (error) {
130
+ ctx.ui.notify(`Automation setting failed: ${error instanceof Error ? error.message : error}`, "error");
131
+ }
108
132
  } else if (choice === "Run gates") {
109
133
  try {
110
- const results = await callService<Record<string, unknown>, GateResult[]>("tasks.run_gates", { id: action.row.id });
134
+ const results = await callService<Record<string, unknown>, GateResult[]>("tasks.run_gates", { id: action.row.id, actor: "user", source: "tasks-tui" });
111
135
  ctx.ui.notify(`Gates:\n${results.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n")}`, "info");
112
136
  } catch (error) {
113
137
  ctx.ui.notify(`Gates failed: ${error instanceof Error ? error.message : error}`, "error");
@@ -126,7 +150,7 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
126
150
  ? "tasks.cancel"
127
151
  : "tasks.complete";
128
152
  if (operation === "tasks.complete") {
129
- const result = await callService<Record<string, unknown>, TaskCompletion>(operation, { id: action.row.id });
153
+ const result = await callService<Record<string, unknown>, TaskCompletion>(operation, { id: action.row.id, actor: "user", source: "tasks-tui" });
130
154
  action.row.status = result.artifact.status;
131
155
  const gates = result.gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target}`).join("\n");
132
156
  const checklist = result.checklist.map((item) => `${item.accepted ? "✓" : "✗"} proof: ${item.item}`).join("\n");
@@ -141,7 +165,7 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
141
165
  result.completed ? "info" : "warning",
142
166
  );
143
167
  } else {
144
- const updated = await callService<Record<string, unknown>, Artifact>(operation, { id: action.row.id });
168
+ const updated = await callService<Record<string, unknown>, Artifact>(operation, { id: action.row.id, actor: "user", source: "tasks-tui" });
145
169
  action.row.status = updated.status;
146
170
  ctx.ui.notify(`${updated.id} → [${updated.status}]`, "info");
147
171
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.4.0",
3
+ "version": "0.6.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"],
@@ -1,5 +1,5 @@
1
1
  import type { Db } from "../db.ts";
2
- import type { GateResult } from "../domain/gate.ts";
2
+ import type { GateResult, GateRunOptions } from "../domain/gate.ts";
3
3
  import type { GateRunner } from "../ports/gate-runner.ts";
4
4
  import { runGates, runGatesAsync } from "../ops.ts";
5
5
 
@@ -10,7 +10,7 @@ export class SQLiteGateRunner implements GateRunner {
10
10
  return runGates(this.db, artifactId);
11
11
  }
12
12
 
13
- runAsync(artifactId: string): Promise<GateResult[]> {
14
- return runGatesAsync(this.db, artifactId);
13
+ runAsync(artifactId: string, options?: GateRunOptions): Promise<GateResult[]> {
14
+ return runGatesAsync(this.db, artifactId, options);
15
15
  }
16
16
  }
@@ -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
@@ -10,6 +10,7 @@ import { serveMain } from "./daemon.ts";
10
10
  import type { GateResult } from "./domain/gate.ts";
11
11
  import type { TaskExecutionPlan } from "./task-execution.ts";
12
12
  import type { TaskBlockage, TaskCompletion } from "./task-service.ts";
13
+ import type { TaskAutomationResult, TaskAutomationSettings } from "./task-automation.ts";
13
14
 
14
15
  export interface SystemdUnitOptions {
15
16
  bunBin: string;
@@ -56,11 +57,13 @@ function installService(): void {
56
57
  const USAGE = `Usage:
57
58
  papyrus serve
58
59
  papyrus service <install|start|stop|restart|status>
59
- papyrus migrate task-lifecycle [--json]
60
+ papyrus migrate task-history [--json]
61
+ papyrus automation <status|run> [--json]
60
62
  papyrus skills run <id> [--arguments-json <json>] [--run-id <id>] [--json]
61
63
  papyrus tasks plan [--json]
62
64
  papyrus tasks graph [--json]
63
65
  papyrus tasks active [--json]
66
+ papyrus tasks history <id> [--json]
64
67
  papyrus tasks focus <id> [--json]
65
68
  papyrus tasks complete <id> [--json]
66
69
  papyrus tasks start <id> [--json]
@@ -68,6 +71,7 @@ const USAGE = `Usage:
68
71
  papyrus tasks reject <id> [--json]
69
72
  papyrus tasks retry <id> [--json]
70
73
  papyrus tasks cancel <id> [--json]
74
+ papyrus tasks automate <id> <on|off> [--json]
71
75
  papyrus tasks depend <id> <prerequisite-id> [--json]`;
72
76
 
73
77
  function usage(): never {
@@ -106,8 +110,8 @@ function planText(plan: TaskExecutionPlan): string {
106
110
  export async function runMigrationCli(args: string[], client: TaskCliClient): Promise<string> {
107
111
  const json = args.includes("--json");
108
112
  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`");
113
+ if (positional.length !== 1 || positional[0] !== "task-history") {
114
+ throw new Error("migrate requires exactly `task-history`");
111
115
  }
112
116
  const result = await client.call<Record<string, never>, MigrationResult>("system.migrate", {});
113
117
  if (json) return JSON.stringify(result);
@@ -115,6 +119,20 @@ export async function runMigrationCli(args: string[], client: TaskCliClient): Pr
115
119
  return `Migrated schema ${result.from} → ${result.to}: ${result.applied.join(", ")}`;
116
120
  }
117
121
 
122
+ export async function runAutomationCli(args: string[], client: TaskCliClient): Promise<string> {
123
+ const json = args.includes("--json");
124
+ const positional = args.filter((argument) => argument !== "--json");
125
+ if (positional.length !== 1 || (positional[0] !== "status" && positional[0] !== "run")) {
126
+ throw new Error("automation requires exactly `status` or `run`");
127
+ }
128
+ if (positional[0] === "status") {
129
+ const status = await client.call<Record<string, never>, TaskAutomationSettings & { inFlight: boolean }>("automation.status", {});
130
+ return json ? JSON.stringify(status) : `Automation: ${status.enabled ? "enabled" : "disabled"} · interval ${status.intervalMs}ms · max ${status.maxTasksPerSweep} tasks · concurrency ${status.gateConcurrency}`;
131
+ }
132
+ const result = await client.call<Record<string, never>, TaskAutomationResult>("automation.reconcile", {});
133
+ return json ? JSON.stringify(result) : `Automation sweep: ${result.examined} examined · ${result.completed} completed · ${result.rejected} rejected · ${result.started} started · ${result.errors.length} errors${result.skipped ? ` · skipped ${result.skipped}` : ""}`;
134
+ }
135
+
118
136
  export async function runSkillCli(args: string[], client: TaskCliClient): Promise<string> {
119
137
  const json = args.includes("--json");
120
138
  const positional: string[] = [];
@@ -174,6 +192,15 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
174
192
  human = active ? `Active: ${artifactLabel(active)}` : "No active task.";
175
193
  break;
176
194
  }
195
+ case "history": {
196
+ if (!id || dependencyId) throw new Error("tasks history requires exactly one task id");
197
+ const page = await client.call<{ id: string; direction: "desc" }, import("./domain/task-event.ts").TaskHistoryPage>("tasks.history", { id, direction: "desc" });
198
+ result = page;
199
+ human = page.events.length === 0
200
+ ? `No recorded history for ${id}.`
201
+ : [...page.events].reverse().map((event) => `${event.occurredAt} ${event.type} ${event.fromStatus ?? "∅"} → ${event.toStatus ?? "∅"} · ${event.actor}/${event.source}${event.reason ? ` · ${event.reason}` : ""}`).join("\n");
202
+ break;
203
+ }
177
204
  case "focus": {
178
205
  if (!id || dependencyId) throw new Error("tasks focus requires exactly one task id");
179
206
  const active = await client.call<{ id: string }, CliArtifact>("tasks.focus", { id });
@@ -202,7 +229,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
202
229
  }
203
230
  case "complete": {
204
231
  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 });
232
+ const completion = await client.call<Record<string, string>, CliCompletion>("tasks.complete", { id, actor: "user", source: "cli" });
206
233
  result = completion;
207
234
  const lines = [`${completion.completed ? "Completed" : "Rejected"}: ${artifactLabel(completion.artifact)}`];
208
235
  if (completion.focused) lines.push(`Active: ${artifactLabel(completion.focused)}`);
@@ -215,7 +242,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
215
242
  }
216
243
  case "start": {
217
244
  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 });
245
+ const artifact = await client.call<Record<string, string>, CliArtifact>("tasks.start", { id, actor: "user", source: "cli" });
219
246
  result = artifact;
220
247
  human = `Started: ${artifactLabel(artifact)}`;
221
248
  break;
@@ -226,11 +253,23 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
226
253
  case "cancel": {
227
254
  if (!id || dependencyId) throw new Error(`tasks ${action} requires exactly one task id`);
228
255
  const operation = `tasks.${action}` as "tasks.submit" | "tasks.reject" | "tasks.retry" | "tasks.cancel";
229
- const artifact = await client.call<{ id: string }, CliArtifact>(operation, { id });
256
+ const artifact = await client.call<Record<string, string>, CliArtifact>(operation, { id, actor: "user", source: "cli" });
230
257
  result = artifact;
231
258
  human = `${action[0]!.toUpperCase()}${action.slice(1)}: ${artifactLabel(artifact)}`;
232
259
  break;
233
260
  }
261
+ case "automate": {
262
+ if (!id || (dependencyId !== "on" && dependencyId !== "off") || positional.length !== 3) throw new Error("tasks automate requires a task id and on or off");
263
+ const artifact = await client.call<Record<string, unknown>, CliArtifact>("tasks.set_automation", {
264
+ id,
265
+ enabled: dependencyId === "on",
266
+ actor: "user",
267
+ source: "cli",
268
+ });
269
+ result = artifact;
270
+ human = `Automation ${dependencyId}: ${artifactLabel(artifact)}`;
271
+ break;
272
+ }
234
273
  case "depend": {
235
274
  if (!id || !dependencyId || positional.length !== 3) throw new Error("tasks depend requires a task id and prerequisite id");
236
275
  const artifact = await client.call<{ id: string; dependency_id: string }, CliArtifact>("tasks.depend", {
@@ -242,7 +281,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
242
281
  break;
243
282
  }
244
283
  default:
245
- throw new Error("tasks action must be active, focus, graph, plan, complete, start, submit, reject, retry, cancel, or depend");
284
+ throw new Error("tasks action must be active, focus, graph, plan, history, complete, start, submit, reject, retry, cancel, automate, or depend");
246
285
  }
247
286
  return json ? JSON.stringify(result) : human;
248
287
  }
@@ -255,6 +294,11 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
255
294
  console.log(await runTaskCli(args.slice(1), client));
256
295
  return;
257
296
  }
297
+ if (command === "automation") {
298
+ const client = await connectPapyrusClient();
299
+ console.log(await runAutomationCli(args.slice(1), client));
300
+ return;
301
+ }
258
302
  if (command === "skills") {
259
303
  const client = await connectPapyrusClient();
260
304
  console.log(await runSkillCli(args.slice(1), client));
package/src/constants.ts CHANGED
@@ -7,7 +7,7 @@ export const DAEMON_PROBE_TIMEOUT_MS = 800;
7
7
  export const DAEMON_UNIT_NAME = "papyrus.service";
8
8
  export const DAEMON_DIR_ENV = "PAPYRUS_DAEMON_DIR";
9
9
  export const SQLITE_BUSY_TIMEOUT_MS = 5_000;
10
- export const SQLITE_SCHEMA_VERSION = 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;
@@ -15,6 +15,7 @@ export const GATE_COMMAND_TIMEOUT_MS = 30_000;
15
15
  export const GATE_TEST_TIMEOUT_MS = 60_000;
16
16
  export const GATE_OUTPUT_LIMIT = 200;
17
17
  export const GATE_MAX_BUFFER_BYTES = 1_048_576;
18
+ export const GATE_FILE_MAX_BYTES = 1_048_576;
18
19
 
19
20
  /** Compact task-context limits keep recurring prompt injection bounded. */
20
21
  export const TASK_CONTEXT_CURRENT_LIMIT = 3;
@@ -42,6 +43,25 @@ export const SKILL_RUN_ID_MAX_LENGTH = 64;
42
43
  /** Bounded automatic Pi continuations while a focused Papyrus Task remains. */
43
44
  export const TASK_DRIVER_MAX_TURNS = 20;
44
45
  export const TASK_DRIVER_MAX_UNCHANGED_TURNS = 6;
46
+ /** Append-only Task chronology query and evidence bounds. */
47
+ export const TASK_HISTORY_DEFAULT_LIMIT = 25;
48
+ export const TASK_HISTORY_MAX_LIMIT = 100;
49
+ export const TASK_EVENT_MAX_EVIDENCE_BYTES = 65_536;
50
+ export const TASK_EVENT_ACTOR_MAX_LENGTH = 128;
51
+ export const TASK_EVENT_REASON_MAX_LENGTH = 2_000;
52
+ /** Explicitly opt-in supervised Task graph reconciliation bounds. */
53
+ export const TASK_AUTOMATION_INTERVAL_MS = 60_000;
54
+ export const TASK_AUTOMATION_MIN_INTERVAL_MS = 10_000;
55
+ export const TASK_AUTOMATION_MAX_INTERVAL_MS = 3_600_000;
56
+ export const TASK_AUTOMATION_MAX_TASKS_PER_SWEEP = 10;
57
+ export const TASK_AUTOMATION_HARD_MAX_TASKS_PER_SWEEP = 100;
58
+ export const TASK_AUTOMATION_GATE_CONCURRENCY = 1;
59
+ export const TASK_AUTOMATION_MAX_GATE_CONCURRENCY = 4;
60
+ export const TASK_AUTOMATION_MAX_RUNTIME_MS = 120_000;
61
+ export const TASK_AUTOMATION_HARD_MAX_RUNTIME_MS = 600_000;
62
+ export const TASK_AUTOMATION_MAX_CANDIDATE_SCAN = 1_000;
63
+ export const TASK_AUTOMATION_ERROR_ID_MAX_LENGTH = 128;
64
+ export const TASK_AUTOMATION_ERROR_MESSAGE_MAX_LENGTH = 500;
45
65
  export const GRAPH_RENDER_PADDING_X = 2;
46
66
  export const GRAPH_RENDER_PADDING_Y = 1;
47
67
  export const GRAPH_RENDER_BOX_PADDING = 0;