@danypops/papyrus 0.3.0 → 0.4.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
 
@@ -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
@@ -118,6 +128,7 @@ Run `/tasks` for the interactive task panel:
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
122
133
  papyrus tasks focus <id> --json
123
134
  papyrus tasks start <id> --json
@@ -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,8 @@ 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 { TaskCompletion, TaskGraph } from "../../src/task-service.ts";
8
+ import type { SkillWorkflowRunResult } from "../../src/skill-execution.ts";
8
9
  import { callService } from "./service-client.ts";
9
10
 
10
11
  function text(message: string, details: Record<string, unknown> = {}) {
@@ -29,7 +30,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
29
30
  pi.registerTool({
30
31
  name: "tasks",
31
32
  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.",
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.",
33
34
  parameters: Type.Object({
34
35
  action: Type.String(),
35
36
  id: Type.Optional(Type.String()),
@@ -67,6 +68,12 @@ export function registerDomainTools(pi: ExtensionAPI): void {
67
68
  const artifact = await callService<Record<string, unknown>, Artifact | null>("tasks.active", params);
68
69
  return text(artifact ? `Active: ${artifactLine(artifact)}` : "No active task.", { artifact });
69
70
  }
71
+ if (action === "graph") {
72
+ const graph = await callService<Record<string, unknown>, TaskGraph>("tasks.graph", params);
73
+ const dependencies = graph.nodes.reduce((count, node) => count + node.dependencyIds.length, 0);
74
+ const containment = graph.nodes.reduce((count, node) => count + node.childIds.length, 0);
75
+ return text(`Task graph: ${graph.nodes.length} nodes, ${graph.rootIds.length} roots, ${dependencies} dependencies, ${containment} containment edges.`, { graph });
76
+ }
70
77
  if (action === "plan") {
71
78
  const plan = await callService<Record<string, unknown>, TaskExecutionPlan>("tasks.plan", params);
72
79
  const byId = new Map(plan.nodes.map((node) => [node.id, node]));
@@ -203,11 +210,13 @@ export function registerDomainTools(pi: ExtensionAPI): void {
203
210
  pi.registerTool({
204
211
  name: "skills",
205
212
  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.",
213
+ 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
214
  parameters: Type.Object({
208
215
  action: Type.String(), id: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
209
216
  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())),
217
+ tools: Type.Optional(Type.Array(Type.String())), definition: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
218
+ arguments: Type.Optional(Type.Record(Type.String(), Type.Unknown())), run_id: Type.Optional(Type.String()),
219
+ labels: Type.Optional(Type.Array(Type.String())),
211
220
  extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())), status: Type.Optional(Type.String()),
212
221
  text: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()), template_id: Type.Optional(Type.String()),
213
222
  target_kind: Type.Optional(Type.String()), defaults: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
@@ -229,6 +238,17 @@ export function registerDomainTools(pi: ExtensionAPI): void {
229
238
  const invocation = await callService<Record<string, unknown>, string>("skills.invoke", params);
230
239
  return text(invocation, { invocation });
231
240
  }
241
+ if (action === "run") {
242
+ const run = await callService<Record<string, unknown>, SkillWorkflowRunResult>("skills.run", params);
243
+ const execution = run.execution.nodes.map((node) => ` [${node.state}] ${node.id} ${node.title}`).join("\n");
244
+ return text([
245
+ `Created Skill run ${run.runId}: ${run.created.tasks.length} tasks, ${run.created.rules.length} rules, ${run.created.docs.length} docs.`,
246
+ `Ready roots: ${run.rootTaskIds.join(", ") || "none"}.`,
247
+ `Context docs: ${run.created.docs.join(", ") || "none"}.`,
248
+ `Scoped rules: ${run.created.rules.join(", ") || "none"}.`,
249
+ ...(execution ? ["Execution:", execution] : []),
250
+ ].join("\n"), { run });
251
+ }
232
252
  const operations = { show: "skills.show", enable: "skills.enable", disable: "skills.disable", instantiate: "skills.instantiate" } as const;
233
253
  const operation = operations[action as keyof typeof operations];
234
254
  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");
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.3.0",
3
+ "version": "0.4.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
  }
package/src/cli.ts CHANGED
@@ -5,7 +5,7 @@ import { homedir } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { connectPapyrusClient, type PapyrusClient } from "./client.ts";
8
- import { DAEMON_UNIT_NAME } from "./constants.ts";
8
+ import { DAEMON_UNIT_NAME, TASK_EXECUTION_MAX_NODES } from "./constants.ts";
9
9
  import { serveMain } from "./daemon.ts";
10
10
  import type { GateResult } from "./domain/gate.ts";
11
11
  import type { TaskExecutionPlan } from "./task-execution.ts";
@@ -57,7 +57,9 @@ const USAGE = `Usage:
57
57
  papyrus serve
58
58
  papyrus service <install|start|stop|restart|status>
59
59
  papyrus migrate task-lifecycle [--json]
60
+ papyrus skills run <id> [--arguments-json <json>] [--run-id <id>] [--json]
60
61
  papyrus tasks plan [--json]
62
+ papyrus tasks graph [--json]
61
63
  papyrus tasks active [--json]
62
64
  papyrus tasks focus <id> [--json]
63
65
  papyrus tasks complete <id> [--json]
@@ -113,6 +115,51 @@ export async function runMigrationCli(args: string[], client: TaskCliClient): Pr
113
115
  return `Migrated schema ${result.from} → ${result.to}: ${result.applied.join(", ")}`;
114
116
  }
115
117
 
118
+ export async function runSkillCli(args: string[], client: TaskCliClient): Promise<string> {
119
+ const json = args.includes("--json");
120
+ const positional: string[] = [];
121
+ let runId: string | undefined;
122
+ let arguments_: Record<string, unknown> = {};
123
+ for (let index = 0; index < args.length; index++) {
124
+ const argument = args[index]!;
125
+ if (argument === "--json") continue;
126
+ if (argument === "--run-id") {
127
+ runId = args[++index];
128
+ if (!runId) throw new Error("--run-id requires a value");
129
+ continue;
130
+ }
131
+ if (argument === "--arguments-json") {
132
+ const source = args[++index];
133
+ if (!source) throw new Error("--arguments-json requires a JSON object");
134
+ const parsed = JSON.parse(source) as unknown;
135
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
136
+ throw new Error("--arguments-json must be a JSON object");
137
+ }
138
+ arguments_ = parsed as Record<string, unknown>;
139
+ continue;
140
+ }
141
+ if (argument.startsWith("--")) throw new Error(`unknown skills option ${argument}`);
142
+ positional.push(argument);
143
+ }
144
+ if (positional.length !== 2 || positional[0] !== "run") throw new Error("skills requires `run <id>`");
145
+ const input: Record<string, unknown> = { id: positional[1], arguments: arguments_ };
146
+ if (runId) input["run_id"] = runId;
147
+ const result = await client.call<Record<string, unknown>, {
148
+ runId: string;
149
+ created: { tasks: string[]; rules: string[]; docs: string[] };
150
+ rootTaskIds: string[];
151
+ execution: TaskExecutionPlan;
152
+ }>("skills.run", input);
153
+ if (json) return JSON.stringify(result);
154
+ return [
155
+ `Created Skill run ${result.runId}: ${result.created.tasks.length} tasks, ${result.created.rules.length} rules, ${result.created.docs.length} docs`,
156
+ `Ready roots: ${result.rootTaskIds.join(", ") || "none"}`,
157
+ `Context docs: ${result.created.docs.join(", ") || "none"}`,
158
+ `Scoped rules: ${result.created.rules.join(", ") || "none"}`,
159
+ ...result.execution.nodes.map((node) => `[${node.state}] ${node.id} ${node.title}`),
160
+ ].join("\n");
161
+ }
162
+
116
163
  export async function runTaskCli(args: string[], client: TaskCliClient): Promise<string> {
117
164
  const json = args.includes("--json");
118
165
  const positional = args.filter((arg) => arg !== "--json");
@@ -134,6 +181,18 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
134
181
  human = `Active: ${artifactLabel(active)}`;
135
182
  break;
136
183
  }
184
+ case "graph": {
185
+ if (id) throw new Error("tasks graph accepts no positional arguments");
186
+ const graph = await client.call<{ limit: number }, {
187
+ nodes: Array<{ dependencyIds: string[]; childIds: string[] }>;
188
+ rootIds: string[];
189
+ }>("tasks.graph", { limit: TASK_EXECUTION_MAX_NODES + 1 });
190
+ result = graph;
191
+ const dependencies = graph.nodes.reduce((count, node) => count + node.dependencyIds.length, 0);
192
+ const children = graph.nodes.reduce((count, node) => count + node.childIds.length, 0);
193
+ human = `Task graph: ${graph.nodes.length} nodes, ${graph.rootIds.length} roots, ${dependencies} dependencies, ${children} containment edges`;
194
+ break;
195
+ }
137
196
  case "plan": {
138
197
  if (id) throw new Error("tasks plan accepts no positional arguments");
139
198
  const plan = await client.call<Record<string, never>, TaskExecutionPlan>("tasks.plan", {});
@@ -183,7 +242,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
183
242
  break;
184
243
  }
185
244
  default:
186
- throw new Error("tasks action must be active, focus, plan, complete, start, submit, reject, retry, cancel, or depend");
245
+ throw new Error("tasks action must be active, focus, graph, plan, complete, start, submit, reject, retry, cancel, or depend");
187
246
  }
188
247
  return json ? JSON.stringify(result) : human;
189
248
  }
@@ -196,6 +255,11 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
196
255
  console.log(await runTaskCli(args.slice(1), client));
197
256
  return;
198
257
  }
258
+ if (command === "skills") {
259
+ const client = await connectPapyrusClient();
260
+ console.log(await runSkillCli(args.slice(1), client));
261
+ return;
262
+ }
199
263
  if (command === "migrate") {
200
264
  const client = await connectPapyrusClient();
201
265
  console.log(await runMigrationCli(args.slice(1), client));
package/src/constants.ts CHANGED
@@ -32,12 +32,23 @@ export const TASK_GRAPH_HORIZONTAL_PAN_COLUMNS = 4;
32
32
  export const TASK_EXECUTION_MAX_NODES = 1_000;
33
33
  export const TASK_EXECUTION_MAX_EDGES = 10_000;
34
34
  export const TASK_EXECUTION_MAX_DEGREE = 100;
35
+ /** Bounded parameterized Skill definitions and rendered workflow runs. */
36
+ export const SKILL_MAX_INPUTS = 32;
37
+ export const SKILL_MAX_ENUM_VALUES = 32;
38
+ export const SKILL_MAX_BLUEPRINTS = 100;
39
+ export const SKILL_MAX_LINKS = 500;
40
+ export const SKILL_MAX_RENDERED_BYTES = 1_048_576;
41
+ export const SKILL_RUN_ID_MAX_LENGTH = 64;
35
42
  /** Bounded automatic Pi continuations while a focused Papyrus Task remains. */
36
43
  export const TASK_DRIVER_MAX_TURNS = 20;
37
44
  export const TASK_DRIVER_MAX_UNCHANGED_TURNS = 6;
38
45
  export const GRAPH_RENDER_PADDING_X = 2;
39
46
  export const GRAPH_RENDER_PADDING_Y = 1;
40
47
  export const GRAPH_RENDER_BOX_PADDING = 0;
48
+ /** beautiful-mermaid routed layouts become unsafe on larger task graphs; use bounded line fallback. */
49
+ export const GRAPH_RENDER_MAX_ROUTED_NODES = 48;
50
+ export const GRAPH_RENDER_MAX_ROUTED_EDGES = 96;
51
+ export const GRAPH_RENDER_MAX_FALLBACK_LINES = 200;
41
52
 
42
53
  /** Safe defaults and hard ceilings for graph expansion. */
43
54
  export const DEFAULT_GRAPH_DEPTH = 4;
package/src/db.ts CHANGED
@@ -29,15 +29,38 @@ export interface Db {
29
29
  close(): void;
30
30
  }
31
31
 
32
+ const TRANSACTION_DEPTH = new WeakMap<object, number>();
33
+
32
34
  export function inTransaction<T>(db: Db, fn: () => T): T {
35
+ const depth = TRANSACTION_DEPTH.get(db as object) ?? 0;
36
+ if (depth > 0) {
37
+ const savepoint = `papyrus_nested_${depth}`;
38
+ db.exec(`SAVEPOINT ${savepoint}`);
39
+ TRANSACTION_DEPTH.set(db as object, depth + 1);
40
+ try {
41
+ const result = fn();
42
+ db.exec(`RELEASE SAVEPOINT ${savepoint}`);
43
+ return result;
44
+ } catch (error) {
45
+ db.exec(`ROLLBACK TO SAVEPOINT ${savepoint}`);
46
+ db.exec(`RELEASE SAVEPOINT ${savepoint}`);
47
+ throw error;
48
+ } finally {
49
+ TRANSACTION_DEPTH.set(db as object, depth);
50
+ }
51
+ }
52
+
33
53
  db.exec("BEGIN IMMEDIATE");
54
+ TRANSACTION_DEPTH.set(db as object, 1);
34
55
  try {
35
56
  const result = fn();
36
57
  db.exec("COMMIT");
37
58
  return result;
38
- } catch (e) {
59
+ } catch (error) {
39
60
  db.exec("ROLLBACK");
40
- throw e;
61
+ throw error;
62
+ } finally {
63
+ TRANSACTION_DEPTH.delete(db as object);
41
64
  }
42
65
  }
43
66
 
@@ -1,4 +1,10 @@
1
- import { SEED_RELATIONS } from "../constants.ts";
1
+ import {
2
+ SEED_RELATIONS,
3
+ SKILL_MAX_BLUEPRINTS,
4
+ SKILL_MAX_ENUM_VALUES,
5
+ SKILL_MAX_INPUTS,
6
+ SKILL_MAX_LINKS,
7
+ } from "../constants.ts";
2
8
 
3
9
  export type SkillArgumentValue = string | number | boolean;
4
10
  export type SkillInputType = "string" | "number" | "boolean";
@@ -59,13 +65,10 @@ export interface SkillDefinition {
59
65
  links: SkillBlueprintLink[];
60
66
  }
61
67
 
62
- const MAX_INPUTS = 32;
63
- const MAX_ENUM_VALUES = 32;
64
- const MAX_BLUEPRINTS = 100;
65
- const MAX_LINKS = 500;
66
68
  const NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
67
69
  const PLACEHOLDER_PATTERN = /{{\s*([A-Za-z][A-Za-z0-9_-]{0,63})\s*}}/g;
68
70
  const INPUT_TYPES = new Set<SkillInputType>(["string", "number", "boolean"]);
71
+ const RESERVED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
69
72
  const RELATIONS = new Set<string>(SEED_RELATIONS);
70
73
 
71
74
  function record(value: unknown, label: string): Record<string, unknown> {
@@ -93,9 +96,10 @@ function validateArgumentValue(name: string, type: SkillInputType, value: unknow
93
96
  function validateInputs(value: unknown): Record<string, SkillInputDefinition> {
94
97
  const source = record(value ?? {}, "skill inputs");
95
98
  const entries = Object.entries(source);
96
- if (entries.length > MAX_INPUTS) throw new Error(`skill inputs exceed ${MAX_INPUTS}`);
99
+ if (entries.length > SKILL_MAX_INPUTS) throw new Error(`skill inputs exceed ${SKILL_MAX_INPUTS}`);
97
100
  const result: Record<string, SkillInputDefinition> = {};
98
101
  for (const [name, raw] of entries) {
102
+ if (RESERVED_KEYS.has(name)) throw new Error(`reserved skill input name "${name}"`);
99
103
  if (!NAME_PATTERN.test(name)) throw new Error(`invalid skill input name "${name}"`);
100
104
  const input = record(raw, `skill input "${name}"`);
101
105
  if (!INPUT_TYPES.has(input["type"] as SkillInputType)) throw new Error(`skill input "${name}" has unsupported type`);
@@ -108,7 +112,7 @@ function validateInputs(value: unknown): Record<string, SkillInputDefinition> {
108
112
  if (input["default"] !== undefined) normalized.default = validateArgumentValue(name, type, input["default"]);
109
113
  if (input["enum"] !== undefined) {
110
114
  const values = array(input["enum"], `skill input "${name}" enum`);
111
- if (values.length === 0 || values.length > MAX_ENUM_VALUES) throw new Error(`skill input "${name}" enum must contain 1-${MAX_ENUM_VALUES} values`);
115
+ if (values.length === 0 || values.length > SKILL_MAX_ENUM_VALUES) throw new Error(`skill input "${name}" enum must contain 1-${SKILL_MAX_ENUM_VALUES} values`);
112
116
  normalized.enum = values.map((entry) => validateArgumentValue(name, type, entry));
113
117
  if (normalized.default !== undefined && !normalized.enum.includes(normalized.default)) {
114
118
  throw new Error(`skill input "${name}" default must be one of its enum values`);
@@ -162,7 +166,7 @@ export function validateSkillDefinition(value: unknown): SkillDefinition {
162
166
  const rules = array(rawBlueprints["rules"] ?? [], "skill rule blueprints").map((entry) => validateBlueprint<SkillRuleBlueprint>(entry, "rule"));
163
167
  const tasks = array(rawBlueprints["tasks"] ?? [], "skill task blueprints").map((entry) => validateBlueprint<SkillTaskBlueprint>(entry, "task"));
164
168
  const all = [...docs, ...rules, ...tasks];
165
- if (all.length === 0 || all.length > MAX_BLUEPRINTS) throw new Error(`skill blueprints must contain 1-${MAX_BLUEPRINTS} artifacts`);
169
+ if (all.length === 0 || all.length > SKILL_MAX_BLUEPRINTS) throw new Error(`skill blueprints must contain 1-${SKILL_MAX_BLUEPRINTS} artifacts`);
166
170
  const refs = new Set<string>();
167
171
  for (const blueprint of all) {
168
172
  if (refs.has(blueprint.ref)) throw new Error(`duplicate skill blueprint ref "${blueprint.ref}"`);
@@ -179,7 +183,7 @@ export function validateSkillDefinition(value: unknown): SkillDefinition {
179
183
  }
180
184
  assertAcyclic(tasks);
181
185
  for (const name of placeholders(all)) {
182
- if (!(name in inputs)) throw new Error(`unknown skill input placeholder "${name}"`);
186
+ if (!Object.hasOwn(inputs, name)) throw new Error(`unknown skill input placeholder "${name}"`);
183
187
  }
184
188
  const links = array(source["links"] ?? [], "skill links").map((entry) => {
185
189
  const link = record(entry, "skill link");
@@ -191,14 +195,14 @@ export function validateSkillDefinition(value: unknown): SkillDefinition {
191
195
  if (!RELATIONS.has(relation)) throw new Error(`unknown skill link relation "${relation}"`);
192
196
  return { from, relation, to };
193
197
  });
194
- if (links.length > MAX_LINKS) throw new Error(`skill links exceed ${MAX_LINKS}`);
198
+ if (links.length > SKILL_MAX_LINKS) throw new Error(`skill links exceed ${SKILL_MAX_LINKS}`);
195
199
  return { version: 1, inputs, blueprints: { docs, rules, tasks }, links };
196
200
  }
197
201
 
198
202
  export function resolveSkillArguments(definition: SkillDefinition, value: unknown): Record<string, SkillArgumentValue> {
199
203
  const source = record(value ?? {}, "skill arguments");
200
204
  for (const name of Object.keys(source)) {
201
- if (!(name in definition.inputs)) throw new Error(`unknown skill argument "${name}"`);
205
+ if (!Object.hasOwn(definition.inputs, name)) throw new Error(`unknown skill argument "${name}"`);
202
206
  }
203
207
  const result: Record<string, SkillArgumentValue> = {};
204
208
  for (const [name, input] of Object.entries(definition.inputs)) {
@@ -1,4 +1,5 @@
1
1
  import type { Artifact, CreateArtifactInput } from "./domain/artifact.ts";
2
+ import { validateSkillDefinition } from "./domain/skill-definition.ts";
2
3
  import type { ArtifactStore } from "./ports/artifact-store.ts";
3
4
 
4
5
  export interface ListFilter {
@@ -98,6 +99,18 @@ export function listRules(artifacts: ArtifactStore, filter: ListFilter): Artifac
98
99
  return artifacts.query({ kind: "rule", ...filter });
99
100
  }
100
101
 
102
+ /** Global rules always apply; scoped workflow rules apply only while their run owns active focus. */
103
+ export function listInjectableRules(artifacts: ArtifactStore, activeTaskId?: string): Artifact[] {
104
+ return artifacts.query({ kind: "rule", status: "active" }).filter((rule) => {
105
+ const scope = rule.extra["scope"];
106
+ if (scope === undefined) return true;
107
+ if (typeof scope !== "object" || scope === null || Array.isArray(scope)) return false;
108
+ const value = scope as Record<string, unknown>;
109
+ if (value["type"] !== "skill-run" || !Array.isArray(value["taskIds"])) return false;
110
+ return activeTaskId !== undefined && value["taskIds"].some((id) => id === activeTaskId);
111
+ });
112
+ }
113
+
101
114
  export function showRule(artifacts: ArtifactStore, id: string): Artifact {
102
115
  requireKind(artifacts, id, "rule");
103
116
  return artifacts.get(id, { tree: true })!;
@@ -131,6 +144,7 @@ export interface CreateSkillInput {
131
144
  trigger?: string;
132
145
  steps?: string[];
133
146
  tools?: string[];
147
+ definition?: unknown;
134
148
  labels?: string[];
135
149
  extra?: Record<string, unknown>;
136
150
  }
@@ -147,13 +161,19 @@ export interface CreateArtifactTemplateInput {
147
161
  export type SkillTransition = "enable" | "disable";
148
162
 
149
163
  export function createSkill(artifacts: ArtifactStore, input: CreateSkillInput): Artifact {
164
+ if (input.definition !== undefined && (input.trigger !== undefined || input.steps !== undefined || input.tools !== undefined)) {
165
+ throw new Error("workflow Skill definition cannot be mixed with legacy trigger, steps, or tools");
166
+ }
167
+ const definition = input.definition === undefined ? undefined : validateSkillDefinition(input.definition);
150
168
  return artifacts.create({
151
169
  kind: "skill",
170
+ subtype: definition ? "workflow" : undefined,
152
171
  title: input.title,
153
172
  body: input.body,
154
173
  labels: input.labels,
155
174
  extra: {
156
175
  ...(input.extra ?? {}),
176
+ ...(definition ? { definition } : {}),
157
177
  ...(input.trigger ? { trigger: input.trigger } : {}),
158
178
  ...(input.steps ? { steps: input.steps } : {}),
159
179
  ...(input.tools ? { tools: input.tools } : {}),
@@ -194,6 +214,17 @@ export function skillInvocation(artifacts: ArtifactStore, id: string): string {
194
214
  if (skill.subtype === "artifact-template") {
195
215
  return `Create an artifact using Papyrus template "${skill.title}".\ntemplate_id: ${skill.id}\nAsk for or infer all required template fields, then call the skills domain tool instantiate action.`;
196
216
  }
217
+ if (skill.subtype === "workflow") {
218
+ const definition = validateSkillDefinition(skill.extra["definition"]);
219
+ const required = Object.entries(definition.inputs)
220
+ .filter(([, input]) => input.required && input.default === undefined)
221
+ .map(([name]) => name);
222
+ return [
223
+ `Run Papyrus workflow Skill "${skill.title}" (${skill.id}).`,
224
+ `Required arguments: ${required.length > 0 ? required.join(", ") : "none"}.`,
225
+ "Call the skills domain tool with action=run and arguments after collecting required values.",
226
+ ].join("\n");
227
+ }
197
228
  const trigger = typeof skill.extra["trigger"] === "string" ? skill.extra["trigger"] : "manual invocation";
198
229
  const steps = Array.isArray(skill.extra["steps"]) ? skill.extra["steps"].filter((step): step is string => typeof step === "string") : [];
199
230
  const tools = Array.isArray(skill.extra["tools"]) ? skill.extra["tools"].filter((tool): tool is string => typeof tool === "string") : [];
@@ -0,0 +1,13 @@
1
+ import type { ArtifactStore } from "./artifact-store.ts";
2
+
3
+ /** Artifact store boundary for domain operations that must commit as one graph mutation. */
4
+ export interface AtomicArtifactStore extends ArtifactStore {
5
+ atomic<T>(operation: () => T): T;
6
+ }
7
+
8
+ export function requireAtomicArtifactStore(store: ArtifactStore): AtomicArtifactStore {
9
+ if (!("atomic" in store) || typeof store.atomic !== "function") {
10
+ throw new Error("artifact store does not support atomic workflow runs");
11
+ }
12
+ return store as AtomicArtifactStore;
13
+ }
package/src/service.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  instantiateTemplate,
21
21
  listDocuments,
22
22
  listRules,
23
+ listInjectableRules,
23
24
  listSkills,
24
25
  previewRule,
25
26
  showDocument,
@@ -32,6 +33,7 @@ import {
32
33
  type DocumentRelation,
33
34
  } from "./domain-services.ts";
34
35
  import { taskContext } from "./task-context.ts";
36
+ import { instantiateSkillWorkflow } from "./skill-execution.ts";
35
37
 
36
38
  export const EXPECTED_OPERATION_NAMES = [
37
39
  "system.migrate",
@@ -80,6 +82,7 @@ export const EXPECTED_OPERATION_NAMES = [
80
82
  "skills.list",
81
83
  "skills.show",
82
84
  "skills.invoke",
85
+ "skills.run",
83
86
  "skills.enable",
84
87
  "skills.disable",
85
88
  "skills.instantiate",
@@ -171,7 +174,7 @@ function handlers(
171
174
  }),
172
175
  "graph.status": (input) => artifacts.setStatus(string(input, "id"), string(input, "status")),
173
176
  "gates.run": (input) => gates.runAsync(string(input, "id")),
174
- "rules.injectable": () => artifacts.query({ kind: "rule", status: "active" })
177
+ "rules.injectable": () => listInjectableRules(artifacts, tasks.active()?.id)
175
178
  .map(({ id, title, body, extra }) => ({ id, title, body, extra })),
176
179
  "tasks.create": (input) => tasks.create({
177
180
  title: string(input, "title"),
@@ -228,6 +231,7 @@ function handlers(
228
231
  "skills.create": (input) => createSkill(artifacts, {
229
232
  title: string(input, "title"), body: optionalString(input, "body"), trigger: optionalString(input, "trigger"),
230
233
  steps: input["steps"] as string[] | undefined, tools: input["tools"] as string[] | undefined,
234
+ definition: input["definition"],
231
235
  labels: input["labels"] as string[] | undefined, extra: input["extra"] as Record<string, unknown> | undefined,
232
236
  }),
233
237
  "skills.create_template": (input) => createArtifactTemplate(artifacts, {
@@ -237,6 +241,10 @@ function handlers(
237
241
  "skills.list": (input) => listSkills(artifacts, taskFilter(input)),
238
242
  "skills.show": (input) => showSkill(artifacts, string(input, "id")),
239
243
  "skills.invoke": (input) => skillInvocation(artifacts, string(input, "id")),
244
+ "skills.run": (input) => instantiateSkillWorkflow(artifacts, string(input, "id"), {
245
+ runId: optionalString(input, "run_id") ?? optionalString(input, "runId"),
246
+ arguments: input["arguments"] as Record<string, unknown> | undefined,
247
+ }),
240
248
  "skills.enable": (input) => transitionSkill(artifacts, string(input, "id"), "enable"),
241
249
  "skills.disable": (input) => transitionSkill(artifacts, string(input, "id"), "disable"),
242
250
  "skills.instantiate": (input) => instantiateTemplate(artifacts, string(input, "template_id"), normalizeCreateInput(input)),
@@ -0,0 +1,204 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { SKILL_MAX_RENDERED_BYTES, SKILL_RUN_ID_MAX_LENGTH, TASK_EXECUTION_MAX_EDGES } from "./constants.ts";
3
+ import type { Artifact } from "./domain/artifact.ts";
4
+ import { validateChecklist } from "./domain/checklist.ts";
5
+ import {
6
+ resolveSkillArguments,
7
+ validateSkillDefinition,
8
+ type SkillArgumentValue,
9
+ type SkillDefinition,
10
+ } from "./domain/skill-definition.ts";
11
+ import type { ArtifactStore } from "./ports/artifact-store.ts";
12
+ import { requireAtomicArtifactStore } from "./ports/atomic-artifact-store.ts";
13
+ import { projectTaskExecution, type TaskExecutionPlan } from "./task-execution.ts";
14
+ import type { TaskGraph, TaskNode } from "./task-service.ts";
15
+
16
+ const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
17
+ const EXACT_PLACEHOLDER_PATTERN = /^{{\s*([A-Za-z][A-Za-z0-9_-]{0,63})\s*}}$/;
18
+ const PLACEHOLDER_PATTERN = /{{\s*([A-Za-z][A-Za-z0-9_-]{0,63})\s*}}/g;
19
+ const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
20
+
21
+ export interface InstantiateSkillWorkflowInput {
22
+ runId?: string;
23
+ arguments?: Record<string, unknown>;
24
+ }
25
+
26
+ export interface SkillWorkflowRunResult {
27
+ skillId: string;
28
+ runId: string;
29
+ arguments: Record<string, SkillArgumentValue>;
30
+ created: {
31
+ docs: string[];
32
+ rules: string[];
33
+ tasks: string[];
34
+ };
35
+ rootTaskIds: string[];
36
+ execution: TaskExecutionPlan;
37
+ }
38
+
39
+ function requireWorkflowSkill(artifacts: ArtifactStore, skillId: string): { skill: Artifact; definition: SkillDefinition } {
40
+ const skill = artifacts.get(skillId);
41
+ if (!skill) throw new Error(`skill artifact "${skillId}" not found`);
42
+ if (skill.kind !== "skill" || skill.subtype !== "workflow") {
43
+ throw new Error(`artifact "${skillId}" is not a workflow Skill`);
44
+ }
45
+ if (skill.status !== "active") throw new Error(`cannot run workflow Skill from ${skill.status}`);
46
+ return { skill, definition: validateSkillDefinition(skill.extra["definition"]) };
47
+ }
48
+
49
+ function normalizeRunId(skillId: string, requested: string | undefined): string {
50
+ const runId = requested ?? `${skillId.slice(0, 40)}-${randomUUID().replaceAll("-", "").slice(0, 12)}`;
51
+ if (runId.length > SKILL_RUN_ID_MAX_LENGTH || !RUN_ID_PATTERN.test(runId)) {
52
+ throw new Error(`skill run id must match ${RUN_ID_PATTERN} and contain at most ${SKILL_RUN_ID_MAX_LENGTH} characters`);
53
+ }
54
+ return runId;
55
+ }
56
+
57
+ function renderValue(value: unknown, arguments_: Record<string, SkillArgumentValue>): unknown {
58
+ if (typeof value === "string") {
59
+ const exact = value.match(EXACT_PLACEHOLDER_PATTERN);
60
+ if (exact) {
61
+ const name = exact[1]!;
62
+ if (!(name in arguments_)) throw new Error(`skill input placeholder "${name}" has no argument value`);
63
+ return arguments_[name]!;
64
+ }
65
+ return value.replace(PLACEHOLDER_PATTERN, (_placeholder, name: string) => {
66
+ if (!(name in arguments_)) throw new Error(`skill input placeholder "${name}" has no argument value`);
67
+ return String(arguments_[name]!);
68
+ });
69
+ }
70
+ if (Array.isArray(value)) return value.map((entry) => renderValue(entry, arguments_));
71
+ if (typeof value !== "object" || value === null) return value;
72
+ const rendered: Record<string, unknown> = {};
73
+ for (const [key, entry] of Object.entries(value)) {
74
+ if (UNSAFE_KEYS.has(key)) throw new Error(`unsafe skill blueprint key "${key}"`);
75
+ rendered[key] = renderValue(entry, arguments_);
76
+ }
77
+ return rendered;
78
+ }
79
+
80
+ function renderDefinition(definition: SkillDefinition, arguments_: Record<string, SkillArgumentValue>): SkillDefinition {
81
+ const rendered = renderValue(definition, arguments_) as SkillDefinition;
82
+ const bytes = new TextEncoder().encode(JSON.stringify(rendered)).byteLength;
83
+ if (bytes > SKILL_MAX_RENDERED_BYTES) throw new Error(`rendered skill workflow exceeds ${SKILL_MAX_RENDERED_BYTES} bytes`);
84
+ for (const task of rendered.blueprints.tasks) {
85
+ if (task.extra?.["checklist"] !== undefined) {
86
+ task.extra["checklist"] = validateChecklist(task.extra["checklist"]);
87
+ }
88
+ }
89
+ return validateSkillDefinition(rendered);
90
+ }
91
+
92
+ function withRunLabel(labels: string[] | undefined, runId: string): string[] {
93
+ return [...new Set([...(labels ?? []), `skill-run:${runId}`])];
94
+ }
95
+
96
+ function executionGraph(tasks: Artifact[], definition: SkillDefinition, ids: Map<string, string>): TaskGraph {
97
+ const byRef = new Map(definition.blueprints.tasks.map((task) => [task.ref, task]));
98
+ const nodes: TaskNode[] = tasks.map((task) => {
99
+ const ref = task.extra["skillRun"] && typeof task.extra["skillRun"] === "object"
100
+ ? (task.extra["skillRun"] as Record<string, unknown>)["ref"] as string
101
+ : "";
102
+ const blueprint = byRef.get(ref)!;
103
+ return {
104
+ task,
105
+ active: false,
106
+ parentIds: blueprint.parent ? [ids.get(blueprint.parent)!] : [],
107
+ childIds: definition.blueprints.tasks.filter((candidate) => candidate.parent === ref).map((candidate) => ids.get(candidate.ref)!),
108
+ dependencyIds: (blueprint.dependsOn ?? []).map((dependency) => ids.get(dependency)!),
109
+ };
110
+ });
111
+ return { nodes, rootIds: nodes.filter((node) => node.parentIds.length === 0).map((node) => node.task.id) };
112
+ }
113
+
114
+ export function instantiateSkillWorkflow(
115
+ artifacts: ArtifactStore,
116
+ skillId: string,
117
+ input: InstantiateSkillWorkflowInput = {},
118
+ ): SkillWorkflowRunResult {
119
+ const { definition } = requireWorkflowSkill(artifacts, skillId);
120
+ const arguments_ = resolveSkillArguments(definition, input.arguments);
121
+ const rendered = renderDefinition(definition, arguments_);
122
+ const runId = normalizeRunId(skillId, input.runId);
123
+ const refs = [
124
+ ...rendered.blueprints.docs.map(({ ref }) => ref),
125
+ ...rendered.blueprints.rules.map(({ ref }) => ref),
126
+ ...rendered.blueprints.tasks.map(({ ref }) => ref),
127
+ ];
128
+ const ids = new Map(refs.map((ref) => [ref, `${runId}-${ref}`]));
129
+ const taskIds = rendered.blueprints.tasks.map(({ ref }) => ids.get(ref)!);
130
+ const rootTaskIds = rendered.blueprints.tasks
131
+ .filter((task) => (task.dependsOn?.length ?? 0) === 0)
132
+ .map((task) => ids.get(task.ref)!);
133
+ const relationshipCount = rendered.links.length
134
+ + rendered.blueprints.tasks.reduce((count, task) => count + (task.dependsOn?.length ?? 0) + (task.parent ? 2 : 0), 0)
135
+ + rootTaskIds.length;
136
+ if (relationshipCount > TASK_EXECUTION_MAX_EDGES) {
137
+ throw new Error(`skill workflow run exceeds ${TASK_EXECUTION_MAX_EDGES} relationships`);
138
+ }
139
+
140
+ const atomic = requireAtomicArtifactStore(artifacts);
141
+ return atomic.atomic(() => {
142
+ const docs = rendered.blueprints.docs.map((blueprint) => artifacts.create({
143
+ id: ids.get(blueprint.ref),
144
+ kind: "doc",
145
+ title: blueprint.title,
146
+ body: blueprint.body,
147
+ subtype: blueprint.subtype,
148
+ labels: withRunLabel(blueprint.labels, runId),
149
+ extra: { ...(blueprint.extra ?? {}), skillRun: { id: runId, skillId, ref: blueprint.ref } },
150
+ }));
151
+ const rules = rendered.blueprints.rules.map((blueprint) => artifacts.create({
152
+ id: ids.get(blueprint.ref),
153
+ kind: "rule",
154
+ title: blueprint.title,
155
+ body: blueprint.body,
156
+ labels: withRunLabel(blueprint.labels, runId),
157
+ extra: {
158
+ ...(blueprint.extra ?? {}),
159
+ ...(blueprint.condition ? { condition: blueprint.condition } : {}),
160
+ ...(blueprint.action ? { action: blueprint.action } : {}),
161
+ ...(blueprint.severity ? { severity: blueprint.severity } : {}),
162
+ skillRun: { id: runId, skillId, ref: blueprint.ref },
163
+ scope: { type: "skill-run", runId, taskIds },
164
+ },
165
+ }));
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
+ }));
174
+
175
+ for (const blueprint of rendered.blueprints.tasks) {
176
+ const id = ids.get(blueprint.ref)!;
177
+ for (const dependency of blueprint.dependsOn ?? []) {
178
+ artifacts.link({ from: id, relation: "depends_on", to: ids.get(dependency)! });
179
+ }
180
+ if (blueprint.parent) {
181
+ const parentId = ids.get(blueprint.parent)!;
182
+ artifacts.link({ from: parentId, relation: "contains", to: id });
183
+ artifacts.link({ from: id, relation: "part_of", to: parentId });
184
+ }
185
+ }
186
+ for (const link of rendered.links) {
187
+ artifacts.link({ from: ids.get(link.from)!, relation: link.relation, to: ids.get(link.to)! });
188
+ }
189
+ for (const rootTaskId of rootTaskIds) artifacts.link({ from: skillId, relation: "triggers", to: rootTaskId });
190
+
191
+ return {
192
+ skillId,
193
+ runId,
194
+ arguments: arguments_,
195
+ created: {
196
+ docs: docs.map(({ id }) => id),
197
+ rules: rules.map(({ id }) => id),
198
+ tasks: tasks.map(({ id }) => id),
199
+ },
200
+ rootTaskIds,
201
+ execution: projectTaskExecution(executionGraph(tasks, rendered, ids)),
202
+ };
203
+ });
204
+ }