@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 +14 -3
- package/extension/src/beautiful-mermaid-renderer.ts +23 -0
- package/extension/src/domain-tools.ts +24 -4
- package/extension/src/index.ts +17 -26
- package/extension/src/skills.ts +61 -2
- package/extension/src/task-graph.ts +11 -2
- package/package.json +1 -1
- package/src/adapters/sqlite-artifact-store.ts +7 -2
- package/src/cli.ts +66 -2
- package/src/constants.ts +11 -0
- package/src/db.ts +25 -2
- package/src/domain/skill-definition.ts +15 -11
- package/src/domain-services.ts +31 -0
- package/src/ports/atomic-artifact-store.ts +13 -0
- package/src/service.ts +9 -1
- package/src/skill-execution.ts +204 -0
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
|
|
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
|
|
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
|
|
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())),
|
|
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}`);
|
package/extension/src/index.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/extension/src/skills.ts
CHANGED
|
@@ -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) => [
|
|
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 === "
|
|
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
|
-
|
|
90
|
-
|
|
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,5 +1,6 @@
|
|
|
1
1
|
import type { Db } from "../db.ts";
|
|
2
|
-
import
|
|
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
|
|
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 (
|
|
59
|
+
} catch (error) {
|
|
39
60
|
db.exec("ROLLBACK");
|
|
40
|
-
throw
|
|
61
|
+
throw error;
|
|
62
|
+
} finally {
|
|
63
|
+
TRANSACTION_DEPTH.delete(db as object);
|
|
41
64
|
}
|
|
42
65
|
}
|
|
43
66
|
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
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 >
|
|
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 >
|
|
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 >
|
|
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
|
|
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 >
|
|
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 (!(
|
|
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)) {
|
package/src/domain-services.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|