@danypops/papyrus 0.3.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
@@ -62,11 +62,20 @@ Each kind has an enforced status vocabulary. Every edge endpoint must exist, and
62
62
 
63
63
  ### Hierarchy and traversal
64
64
 
65
- Use `contains` and `part_of` for explicit parent/child structure; use `depends_on` for execution ordering. Dependency edges form an executable DAG: self-dependencies and cycles are rejected, fan-in waits for every prerequisite, and fan-out may activate several successors. Graph reads are cycle-safe and bounded by `depth` and `max_nodes` (defaults: depth 4, 100 nodes; hard ceilings: depth 20, 1,000 nodes). Executable task plans are additionally bounded to 1,000 tasks and 10,000 relationships.
65
+ Use `contains` and `part_of` for explicit parent/child structure; use `depends_on` for execution ordering. Dependency edges form an executable DAG: self-dependencies and cycles are rejected, fan-in waits for every prerequisite, and fan-out can expose several ready successors while active focus remains singular. Graph reads are cycle-safe and bounded by `depth` and `max_nodes` (defaults: depth 4, 100 nodes; hard ceilings: depth 20, 1,000 nodes). Executable task plans are additionally bounded to 1,000 tasks and 10,000 relationships.
66
66
 
67
67
  ### Skills and compatibility templates
68
68
 
69
- A Papyrus Skill is distinct from a conventional prompt-only skill: its input API and templates define a connected Task/Rule/Doc workflow. Task dependencies and gates provide deterministic execution; Rules provide scoped governance; Docs provide invocation context and provenance. The versioned workflow-instantiation API is tracked as active Papyrus work.
69
+ A Papyrus Skill is distinct from a conventional prompt-only skill: its input API and blueprints define a connected Task/Rule/Doc workflow. `skills.run` validates and normalizes all arguments, safely renders placeholders in memory, validates the complete graph, then persists artifacts and edges in one transaction. Task dependencies, containment, gates, checklists, and context survive rendering. Run Rules are injected only while active focus belongs to that run. Docs retain invocation context and provenance; missing evidence references remain unknown and no gate runs during instantiation.
70
+
71
+ A run result has a stable schema: Skill ID, run ID, normalized arguments, created IDs grouped by kind, ready root task IDs, and the bounded execution plan. Explicit run IDs produce deterministic artifact IDs (`<run-id>-<blueprint-ref>`); collisions roll back the entire run.
72
+
73
+ ```bash
74
+ papyrus skills run <skill-id> \
75
+ --arguments-json '{"project":"Papyrus"}' \
76
+ --run-id papyrus-001 \
77
+ --json
78
+ ```
70
79
 
71
80
  The existing `artifact-template` skill subtype remains a compatibility mechanism for one-artifact templates with metadata `{targetKind, defaults, required}`. Instantiate it through `papyrus_create` with `template_id`; defaults merge recursively, explicit arrays replace defaults, required paths such as `extra.owner` are validated, and target-kind mismatches are rejected.
72
81
 
@@ -84,7 +93,7 @@ Agent-facing domain tools own lifecycle invariants and sit above this store API:
84
93
  - **`tasks`** — create/list/show/plan, manage the singleton active focus, replace evidence-bearing checklists, hierarchy/dependencies, lifecycle transitions, non-blocking gates, and review completion that focuses one deterministic ready successor without claiming effort
85
94
  - **`docs`** — create/list/show, activate/archive/reopen, and document-safe graph links
86
95
  - **`rules`** — create/list/show/preview, enable/disable, and attach governance gates to tasks
87
- - **`skills`** — create/list/show/invoke, enable/disable, create templates, and instantiate templates
96
+ - **`skills`** — create/list/show/invoke/run, enable/disable, create compatibility templates, and atomically instantiate parameterized workflow runs
88
97
 
89
98
  Every tool operation is registered in the daemon’s `/api/v1/ops` registry; parity is verified in tests. The task consumer uses the `tasks.graph` operation, which returns task nodes with explicit parent, child, and dependency IDs rather than leaking SQLite rows or asking the UI to reconstruct relationships.
90
99
 
@@ -92,7 +101,7 @@ Internally, application services depend on the `ArtifactStore` and `GateRunner`
92
101
 
93
102
  ## Interactive frontends
94
103
 
95
- - `/tasks` — task lifecycle, gates, dependencies, and nested metadata
104
+ - `/tasks` — task lifecycle, append-only history, gates, dependencies, and nested metadata
96
105
  - `/docs` — searchable documents, lifecycle, details, and graph links
97
106
  - `/rules` — severity/condition rows, exact injection preview, enable/disable, and task gating
98
107
  - `/skills` — trigger/tools rows, invocation into the editor, and artifact templates
@@ -105,6 +114,7 @@ Run `/tasks` for the interactive task panel:
105
114
 
106
115
  - `/` filters; arrow keys navigate; Enter opens task actions
107
116
  - `g` opens the programmatic Unicode graph; Tab switches dependency/composition views and arrow keys pan
117
+ - routed graph layouts are bounded to 48 nodes/96 edges; larger graphs use a deterministic, box-drawn line fallback, and renderer failures are contained inside the viewport rather than escaping Pi
108
118
  - advance the `todo → in-progress → review → done` lifecycle; failed review becomes `rejected`, retry returns to `in-progress`, and `canceled` is terminal
109
119
  - use **active** only as the independent singleton focus that auto-drive continues; focusing a task never changes its lifecycle
110
120
  - starting nested effort moves todo ancestors to in-progress; submitting enters review; completing review checks both typed checklist proofs and executable gates
@@ -112,13 +122,15 @@ Run `/tasks` for the interactive task panel:
112
122
  - successors are never auto-completed; fan-in, fan-out, diamonds, and disconnected DAGs remain explicit
113
123
  - inspect deterministic execution layers, readiness, a box-drawn nested hierarchy, composition, dependencies, evidence-bearing checklists, and verification gates
114
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
115
- - 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
116
126
  - the compact persistent widget shows bounded open work in containment order and always retains the active focus
117
127
 
118
128
  Authenticated CLI parity covers the changed lifecycle and focus operations:
119
129
 
120
130
  ```bash
131
+ papyrus tasks graph --json
121
132
  papyrus tasks active --json
133
+ papyrus tasks history <id> --json
122
134
  papyrus tasks focus <id> --json
123
135
  papyrus tasks start <id> --json
124
136
  papyrus tasks submit <id> --json
@@ -160,10 +172,10 @@ packed install npm:@danypops/papyrus
160
172
  ~/.pi/agent/npm/node_modules/.bin/papyrus service install
161
173
  ```
162
174
 
163
- 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:
164
176
 
165
177
  ```bash
166
- ~/.pi/agent/npm/node_modules/.bin/papyrus migrate task-lifecycle
178
+ ~/.pi/agent/npm/node_modules/.bin/papyrus migrate task-history
167
179
  ```
168
180
 
169
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.
@@ -1,6 +1,9 @@
1
1
  import { renderMermaidASCII } from "beautiful-mermaid";
2
2
  import {
3
3
  GRAPH_RENDER_BOX_PADDING,
4
+ GRAPH_RENDER_MAX_FALLBACK_LINES,
5
+ GRAPH_RENDER_MAX_ROUTED_EDGES,
6
+ GRAPH_RENDER_MAX_ROUTED_NODES,
4
7
  GRAPH_RENDER_PADDING_X,
5
8
  GRAPH_RENDER_PADDING_Y,
6
9
  } from "../../src/constants.ts";
@@ -30,9 +33,29 @@ export function mermaidSource(graph: DisplayGraph): string {
30
33
  return lines.join("\n");
31
34
  }
32
35
 
36
+ function boundedLineFallback(graph: DisplayGraph): RenderedGraph {
37
+ const candidates = [
38
+ "┌─ Task graph ─",
39
+ `│ ${graph.nodes.length} nodes · ${graph.edges.length} edges · routed layout skipped above ${GRAPH_RENDER_MAX_ROUTED_NODES} nodes`,
40
+ "├─ Nodes",
41
+ ...graph.nodes.map((node) => `│ ${node.label}`),
42
+ "├─ Edges",
43
+ ...graph.edges.map((edge) => `│ ${edge.from} ─${edge.label ? `${edge.label}─` : ""}→ ${edge.to}`),
44
+ ];
45
+ const contentLimit = Math.max(1, GRAPH_RENDER_MAX_FALLBACK_LINES - 1);
46
+ const lines = candidates.slice(0, contentLimit);
47
+ const omitted = candidates.length - lines.length;
48
+ if (omitted > 0) lines[lines.length - 1] = `│ … ${omitted + 1} lines omitted`;
49
+ lines.push("└─");
50
+ return { lines };
51
+ }
52
+
33
53
  export class BeautifulMermaidRenderer implements GraphRenderer {
34
54
  render(graph: DisplayGraph): RenderedGraph {
35
55
  if (graph.nodes.length === 0) return { lines: [] };
56
+ if (graph.nodes.length > GRAPH_RENDER_MAX_ROUTED_NODES || graph.edges.length > GRAPH_RENDER_MAX_ROUTED_EDGES) {
57
+ return boundedLineFallback(graph);
58
+ }
36
59
  const output = renderMermaidASCII(mermaidSource(graph), {
37
60
  useAscii: false,
38
61
  paddingX: GRAPH_RENDER_PADDING_X,
@@ -4,7 +4,9 @@ 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 { TaskCompletion } from "../../src/task-service.ts";
7
+ import type { TaskHistoryPage } from "../../src/domain/task-event.ts";
8
+ import type { TaskCompletion, TaskGraph } from "../../src/task-service.ts";
9
+ import type { SkillWorkflowRunResult } from "../../src/skill-execution.ts";
8
10
  import { callService } from "./service-client.ts";
9
11
 
10
12
  function text(message: string, details: Record<string, unknown> = {}) {
@@ -29,7 +31,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
29
31
  pi.registerTool({
30
32
  name: "tasks",
31
33
  label: "Tasks",
32
- description: "Task domain tool. ACTIONS: create, list, show, 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.",
33
35
  parameters: Type.Object({
34
36
  action: Type.String(),
35
37
  id: Type.Optional(Type.String()),
@@ -38,6 +40,10 @@ export function registerDomainTools(pi: ExtensionAPI): void {
38
40
  status: Type.Optional(Type.String()),
39
41
  text: Type.Optional(Type.String()),
40
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()),
41
47
  labels: Type.Optional(Type.Array(Type.String())),
42
48
  extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
43
49
  gates: Type.Optional(Type.Array(Type.Record(Type.String(), Type.Unknown()))),
@@ -51,8 +57,9 @@ export function registerDomainTools(pi: ExtensionAPI): void {
51
57
  async execute(_id, params) {
52
58
  try {
53
59
  const action = params.action;
60
+ const request = { ...params, actor: "agent", source: "pi-tool" };
54
61
  if (action === "create") {
55
- const artifact = await callService<Record<string, unknown>, Artifact>("tasks.create", params);
62
+ const artifact = await callService<Record<string, unknown>, Artifact>("tasks.create", request);
56
63
  return text(`Created task ${artifactLine(artifact)}`, { artifact });
57
64
  }
58
65
  if (action === "list") {
@@ -63,10 +70,21 @@ export function registerDomainTools(pi: ExtensionAPI): void {
63
70
  const artifact = await callService<Record<string, unknown>, Artifact>("tasks.show", params);
64
71
  return text(`${artifactLine(artifact)}\n\n${artifact.body}`, { artifact });
65
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
+ }
66
78
  if (action === "active") {
67
79
  const artifact = await callService<Record<string, unknown>, Artifact | null>("tasks.active", params);
68
80
  return text(artifact ? `Active: ${artifactLine(artifact)}` : "No active task.", { artifact });
69
81
  }
82
+ if (action === "graph") {
83
+ const graph = await callService<Record<string, unknown>, TaskGraph>("tasks.graph", params);
84
+ const dependencies = graph.nodes.reduce((count, node) => count + node.dependencyIds.length, 0);
85
+ const containment = graph.nodes.reduce((count, node) => count + node.childIds.length, 0);
86
+ return text(`Task graph: ${graph.nodes.length} nodes, ${graph.rootIds.length} roots, ${dependencies} dependencies, ${containment} containment edges.`, { graph });
87
+ }
70
88
  if (action === "plan") {
71
89
  const plan = await callService<Record<string, unknown>, TaskExecutionPlan>("tasks.plan", params);
72
90
  const byId = new Map(plan.nodes.map((node) => [node.id, node]));
@@ -85,7 +103,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
85
103
  return text(`Updated checklist: ${artifactLine(artifact)}`, { artifact });
86
104
  }
87
105
  if (action === "complete") {
88
- const result = await callService<Record<string, unknown>, TaskCompletion>("tasks.complete", params);
106
+ const result = await callService<Record<string, unknown>, TaskCompletion>("tasks.complete", request);
89
107
  const gates = result.gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n");
90
108
  const checklist = result.checklist.map((item) => `${item.accepted ? "✓" : "✗"} proof: ${item.item}${item.reason ? ` — ${item.reason}` : ""}`).join("\n");
91
109
  const focused = result.focused ? `\nActive: ${artifactLine(result.focused)}` : "";
@@ -95,7 +113,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
95
113
  return text(`${result.completed ? "Completed" : "Rejected"}: ${artifactLine(result.artifact)}${focused}${blocked}${checklist ? `\n${checklist}` : ""}${gates ? `\n${gates}` : ""}`, { ...result });
96
114
  }
97
115
  if (action === "run_gates") {
98
- 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);
99
117
  return text(gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n") || "No gates configured.", { gates });
100
118
  }
101
119
  const operations = {
@@ -110,7 +128,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
110
128
  } as const;
111
129
  const operation = operations[action as keyof typeof operations];
112
130
  if (!operation) return text(`Unknown tasks action: ${action}`);
113
- const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
131
+ const artifact = await callService<Record<string, unknown>, Artifact>(operation, request);
114
132
  return text(artifactLine(artifact), { artifact });
115
133
  } catch (error) {
116
134
  return text(`tasks failed: ${error instanceof Error ? error.message : error}`);
@@ -203,11 +221,13 @@ export function registerDomainTools(pi: ExtensionAPI): void {
203
221
  pi.registerTool({
204
222
  name: "skills",
205
223
  label: "Skills",
206
- description: "Papyrus Skill workflow and compatibility-template domain tool. Papyrus Skills are parameterized Task/Rule/Doc bundles, distinct from prompt-only skills. ACTIONS: create, create_template, list, show, invoke, enable, disable, instantiate.",
224
+ description: "Papyrus Skill workflow and compatibility-template domain tool. Papyrus Skills are parameterized Task/Rule/Doc bundles, distinct from prompt-only skills. ACTIONS: create, create_template, list, show, invoke, run, enable, disable, instantiate. run validates arguments and atomically creates one scoped workflow run.",
207
225
  parameters: Type.Object({
208
226
  action: Type.String(), id: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
209
227
  body: Type.Optional(Type.String()), trigger: Type.Optional(Type.String()), steps: Type.Optional(Type.Array(Type.String())),
210
- tools: Type.Optional(Type.Array(Type.String())), labels: Type.Optional(Type.Array(Type.String())),
228
+ tools: Type.Optional(Type.Array(Type.String())), definition: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
229
+ arguments: Type.Optional(Type.Record(Type.String(), Type.Unknown())), run_id: Type.Optional(Type.String()),
230
+ labels: Type.Optional(Type.Array(Type.String())),
211
231
  extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())), status: Type.Optional(Type.String()),
212
232
  text: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()), template_id: Type.Optional(Type.String()),
213
233
  target_kind: Type.Optional(Type.String()), defaults: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
@@ -229,6 +249,17 @@ export function registerDomainTools(pi: ExtensionAPI): void {
229
249
  const invocation = await callService<Record<string, unknown>, string>("skills.invoke", params);
230
250
  return text(invocation, { invocation });
231
251
  }
252
+ if (action === "run") {
253
+ const run = await callService<Record<string, unknown>, SkillWorkflowRunResult>("skills.run", params);
254
+ const execution = run.execution.nodes.map((node) => ` [${node.state}] ${node.id} ${node.title}`).join("\n");
255
+ return text([
256
+ `Created Skill run ${run.runId}: ${run.created.tasks.length} tasks, ${run.created.rules.length} rules, ${run.created.docs.length} docs.`,
257
+ `Ready roots: ${run.rootTaskIds.join(", ") || "none"}.`,
258
+ `Context docs: ${run.created.docs.join(", ") || "none"}.`,
259
+ `Scoped rules: ${run.created.rules.join(", ") || "none"}.`,
260
+ ...(execution ? ["Execution:", execution] : []),
261
+ ].join("\n"), { run });
262
+ }
232
263
  const operations = { show: "skills.show", enable: "skills.enable", disable: "skills.disable", instantiate: "skills.instantiate" } as const;
233
264
  const operation = operations[action as keyof typeof operations];
234
265
  if (!operation) return text(`Unknown skills action: ${action}`);
@@ -21,7 +21,7 @@ import { callService } from "./service-client.ts";
21
21
  import { registerDomainTools } from "./domain-tools.ts";
22
22
  import type { TaskGraph, TaskStatus } from "../../src/task-service.ts";
23
23
  import { ActiveTaskContinuation, type ActiveTaskMarker } from "./active-task-continuation.ts";
24
- import { buildTaskWidgetProjection } from "./task-widget.ts";
24
+ import { buildTaskWidgetProjection, type TaskWidgetProjection } from "./task-widget.ts";
25
25
  import { TASK_STATUS_PRESENTATION, taskTreeConnector } from "./task-presentation.ts";
26
26
 
27
27
  function text(t: string, details: Record<string, unknown> = {}) {
@@ -34,6 +34,21 @@ function text(t: string, details: Record<string, unknown> = {}) {
34
34
 
35
35
  const WIDGET_KEY = "pi-papyrus";
36
36
 
37
+ export function renderTaskWidgetLines(theme: Theme, projection: TaskWidgetProjection, width: number): string[] {
38
+ if (projection.openTotal === 0) return [];
39
+ const lines: string[] = [];
40
+ for (let index = 0; index < projection.rows.length; index++) {
41
+ const row = projection.rows[index]!;
42
+ const laterSibling = projection.rows.slice(index + 1).some((candidate) => candidate.depth === row.depth);
43
+ const hierarchy = taskTreeConnector({ depth: row.depth, hasChildren: row.hasOpenChildren, hasLaterSibling: laterSibling });
44
+ const focus = row.active ? theme.fg("accent", "▶") : " ";
45
+ const presentation = TASK_STATUS_PRESENTATION[row.task.status as TaskStatus];
46
+ const glyph = presentation ? theme.fg(presentation.color, presentation.glyph) : theme.fg("muted", "?");
47
+ lines.push(truncateToWidth(`${focus} ${hierarchy} ${glyph} ${row.task.title}`, width, "…"));
48
+ }
49
+ return lines;
50
+ }
51
+
37
52
  class TaskOverlay {
38
53
  private uiCtx: ExtensionUIContext | undefined;
39
54
  private registered = false;
@@ -93,31 +108,7 @@ class TaskOverlay {
93
108
  }
94
109
 
95
110
  private renderLines(theme: Theme, width: number): string[] {
96
- const projection = buildTaskWidgetProjection(this.snapshot);
97
- if (projection.total === 0) return [];
98
-
99
- if (projection.openTotal === 0) {
100
- return [truncateToWidth(theme.bold("Tasks · no open tasks · /tasks"), width, "…")];
101
- }
102
-
103
- const active = projection.rows.find((row) => row.active);
104
- const lines = [
105
- truncateToWidth(
106
- theme.bold(`Tasks · ${active ? theme.fg("accent", "▶ active") : "no active focus"} · ${projection.openTotal} open`),
107
- width,
108
- "…",
109
- ),
110
- ];
111
- for (let index = 0; index < projection.rows.length; index++) {
112
- const row = projection.rows[index]!;
113
- const laterSibling = projection.rows.slice(index + 1).some((candidate) => candidate.depth === row.depth);
114
- const hierarchy = taskTreeConnector({ depth: row.depth, hasChildren: row.hasOpenChildren, hasLaterSibling: laterSibling });
115
- const focus = row.active ? theme.fg("accent", "▶") : " ";
116
- const presentation = TASK_STATUS_PRESENTATION[row.task.status as TaskStatus];
117
- const glyph = presentation ? theme.fg(presentation.color, presentation.glyph) : theme.fg("muted", "?");
118
- lines.push(truncateToWidth(`${focus} ${hierarchy} ${glyph} ${row.task.title}`, width, "…"));
119
- }
120
- return lines;
111
+ return renderTaskWidgetLines(theme, buildTaskWidgetProjection(this.snapshot), width);
121
112
  }
122
113
 
123
114
  dispose(): void {
@@ -1,7 +1,10 @@
1
1
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { Artifact } from "../../src/domain/artifact.ts";
3
+ import type { SkillWorkflowRunResult } from "../../src/skill-execution.ts";
4
+ import type { TaskGraph } from "../../src/task-service.ts";
3
5
  import { showArtifactBrowser, showArtifactDetails } from "./artifact-browser.ts";
4
6
  import { callService } from "./service-client.ts";
7
+ import { showTaskGraph } from "./task-graph.ts";
5
8
 
6
9
  const SKILL_GLYPHS: Record<string, string> = { active: "●", deprecated: "○" };
7
10
 
@@ -14,6 +17,15 @@ export function skillRowMeta(skill: Artifact): string {
14
17
  const target = typeof skill.extra["targetKind"] === "string" ? skill.extra["targetKind"] : "artifact";
15
18
  return `template → ${target}`;
16
19
  }
20
+ if (skill.subtype === "workflow") {
21
+ const definition = skill.extra["definition"] as Record<string, unknown> | undefined;
22
+ const inputs = definition?.["inputs"] && typeof definition["inputs"] === "object"
23
+ ? Object.keys(definition["inputs"] as Record<string, unknown>).length
24
+ : 0;
25
+ const blueprints = definition?.["blueprints"] as Record<string, unknown> | undefined;
26
+ const tasks = Array.isArray(blueprints?.["tasks"]) ? blueprints["tasks"].length : 0;
27
+ return `workflow · ${inputs} inputs · ${tasks} tasks`;
28
+ }
17
29
  const trigger = typeof skill.extra["trigger"] === "string" ? `when ${skill.extra["trigger"]}` : "manual";
18
30
  const tools = strings(skill.extra["tools"]);
19
31
  return [trigger, tools.join(", ")].filter(Boolean).join(" · ");
@@ -23,6 +35,12 @@ export function skillInvocationPrompt(skill: Artifact): string {
23
35
  if (skill.subtype === "artifact-template") {
24
36
  return [`Create an artifact using Papyrus template \"${skill.title}\".`, `template_id: ${skill.id}`, "Ask for or infer the title and all required template fields, then call papyrus_create."].join("\n");
25
37
  }
38
+ if (skill.subtype === "workflow") {
39
+ return [
40
+ `Run Papyrus workflow Skill \"${skill.title}\" (${skill.id}).`,
41
+ "Collect its required arguments, then call the skills domain tool with action=run.",
42
+ ].join("\n");
43
+ }
26
44
  const trigger = typeof skill.extra["trigger"] === "string" ? skill.extra["trigger"] : "manual invocation";
27
45
  const steps = strings(skill.extra["steps"]);
28
46
  const tools = strings(skill.extra["tools"]);
@@ -35,6 +53,20 @@ export function skillInvocationPrompt(skill: Artifact): string {
35
53
  ].join("\n");
36
54
  }
37
55
 
56
+ export function skillRunTaskGraph(run: SkillWorkflowRunResult, taskArtifacts: Artifact[]): TaskGraph {
57
+ const executionById = new Map(run.execution.nodes.map((node) => [node.id, node]));
58
+ return {
59
+ nodes: taskArtifacts.map((task) => ({
60
+ task,
61
+ active: executionById.get(task.id)?.active === true,
62
+ parentIds: [],
63
+ childIds: [],
64
+ dependencyIds: executionById.get(task.id)?.prerequisiteIds ?? [],
65
+ })),
66
+ rootIds: run.rootTaskIds,
67
+ };
68
+ }
69
+
38
70
  export async function showSkills(ctx: ExtensionCommandContext): Promise<void> {
39
71
  await showArtifactBrowser(ctx, {
40
72
  kind: "skill",
@@ -43,10 +75,37 @@ export async function showSkills(ctx: ExtensionCommandContext): Promise<void> {
43
75
  statusOrder: ["active", "deprecated"],
44
76
  glyphs: SKILL_GLYPHS,
45
77
  rowMeta: skillRowMeta,
46
- actions: (skill) => ["Show details", skill.subtype === "artifact-template" ? "Use template" : "Invoke skill", skill.status === "active" ? "Disable" : "Enable"],
78
+ actions: (skill) => [
79
+ "Show details",
80
+ skill.subtype === "artifact-template" ? "Use template" : skill.subtype === "workflow" ? "Run workflow" : "Invoke skill",
81
+ skill.status === "active" ? "Disable" : "Enable",
82
+ ],
47
83
  handleAction: async (choice, skill, commandCtx) => {
48
84
  if (choice === "Show details") await showArtifactDetails(commandCtx, skill.id, "skills.show");
49
- else if (choice === "Invoke skill" || choice === "Use template") {
85
+ else if (choice === "Run workflow") {
86
+ const source = await commandCtx.ui.input("Workflow arguments JSON:", "{}");
87
+ if (source === undefined) return;
88
+ try {
89
+ const arguments_ = JSON.parse(source) as unknown;
90
+ if (typeof arguments_ !== "object" || arguments_ === null || Array.isArray(arguments_)) {
91
+ throw new Error("arguments must be a JSON object");
92
+ }
93
+ const run = await callService<Record<string, unknown>, SkillWorkflowRunResult>("skills.run", {
94
+ id: skill.id,
95
+ arguments: arguments_ as Record<string, unknown>,
96
+ });
97
+ commandCtx.ui.notify([
98
+ `Created ${run.runId} · ${run.created.tasks.length} tasks · ${run.rootTaskIds.length} ready roots`,
99
+ `Context docs: ${run.created.docs.join(", ") || "none"}`,
100
+ `Scoped rules: ${run.created.rules.join(", ") || "none"}`,
101
+ ].join("\n"), "info");
102
+ const taskArtifacts = await Promise.all(run.execution.nodes.map((node) =>
103
+ callService<Record<string, unknown>, Artifact>("tasks.show", { id: node.id })));
104
+ await showTaskGraph(commandCtx, skillRunTaskGraph(run, taskArtifacts));
105
+ } catch (error) {
106
+ commandCtx.ui.notify(`Workflow run failed: ${error instanceof Error ? error.message : error}`, "error");
107
+ }
108
+ } else if (choice === "Invoke skill" || choice === "Use template") {
50
109
  const invocation = await callService<Record<string, unknown>, string>("skills.invoke", { id: skill.id });
51
110
  commandCtx.ui.setEditorText(invocation);
52
111
  commandCtx.ui.notify("Invocation placed in the editor", "info");
@@ -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
  }
@@ -86,8 +86,17 @@ export class TaskGraphViewport {
86
86
 
87
87
  private rebuild(): void {
88
88
  const view = GRAPH_VIEWS[this.viewIndex]!;
89
- this.graphLines = this.renderer.render(projectTaskGraph(this.graph, view)).lines;
90
- if (this.graphLines.length === 0) this.graphLines = [`No task ${view} relationships`];
89
+ try {
90
+ this.graphLines = this.renderer.render(projectTaskGraph(this.graph, view)).lines;
91
+ if (this.graphLines.length === 0) this.graphLines = [`No task ${view} relationships`];
92
+ } catch {
93
+ this.graphLines = [
94
+ "┌─ Task graph ─",
95
+ `│ Graph rendering failed for ${view} view.`,
96
+ "│ Press Tab for another view or Esc to close.",
97
+ "└─",
98
+ ];
99
+ }
91
100
  this.offsetY = Math.min(this.offsetY, Math.max(0, this.graphLines.length - this.viewportHeight));
92
101
  }
93
102
  }
@@ -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.3.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"],
@@ -1,5 +1,6 @@
1
1
  import type { Db } from "../db.ts";
2
- import type { ArtifactStore } from "../ports/artifact-store.ts";
2
+ import { inTransaction } from "../db.ts";
3
+ import type { AtomicArtifactStore } from "../ports/atomic-artifact-store.ts";
3
4
  import type {
4
5
  Artifact,
5
6
  ArtifactEdge,
@@ -11,9 +12,13 @@ import type {
11
12
  } from "../domain/artifact.ts";
12
13
  import { createArtifact, getArtifact, linkArtifacts, queryArtifacts, updateExtra, updateStatus } from "../ops.ts";
13
14
 
14
- export class SQLiteArtifactStore implements ArtifactStore {
15
+ export class SQLiteArtifactStore implements AtomicArtifactStore {
15
16
  constructor(private readonly db: Db) {}
16
17
 
18
+ atomic<T>(operation: () => T): T {
19
+ return inTransaction(this.db, operation);
20
+ }
21
+
17
22
  create(input: CreateArtifactInput): Artifact {
18
23
  return createArtifact(this.db, input);
19
24
  }