@danypops/papyrus 0.2.1 → 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 +49 -14
- package/extension/src/active-task-continuation.ts +12 -18
- package/extension/src/artifact-format.ts +5 -3
- package/extension/src/beautiful-mermaid-renderer.ts +23 -0
- package/extension/src/domain-tools.ts +41 -7
- package/extension/src/index.ts +21 -36
- package/extension/src/skills.ts +61 -2
- package/extension/src/task-detail-format.ts +5 -3
- package/extension/src/task-graph.ts +22 -3
- package/extension/src/task-presentation.ts +26 -0
- package/extension/src/task-widget.ts +27 -20
- package/extension/src/tasks.ts +68 -31
- package/package.json +1 -1
- package/src/adapters/sqlite-artifact-store.ts +7 -2
- package/src/adapters/sqlite-task-focus-store.ts +31 -0
- package/src/cli.ts +119 -6
- package/src/client.ts +2 -2
- package/src/constants.ts +22 -10
- package/src/db.ts +98 -22
- 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/ports/task-focus-store.ts +21 -0
- package/src/service.ts +53 -12
- package/src/skill-execution.ts +204 -0
- package/src/task-context.ts +10 -9
- package/src/task-execution.ts +16 -3
- package/src/task-graph-view.ts +6 -3
- package/src/task-service.ts +110 -27
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
|
|
|
@@ -81,10 +90,10 @@ The `papyrus_*` tools are the low-level graph-store API:
|
|
|
81
90
|
|
|
82
91
|
Agent-facing domain tools own lifecycle invariants and sit above this store API:
|
|
83
92
|
|
|
84
|
-
- **`tasks`** — create/list/show/plan, replace evidence-bearing checklists, hierarchy/dependencies,
|
|
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,12 +114,30 @@ 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
|
|
108
|
-
-
|
|
109
|
-
-
|
|
110
|
-
-
|
|
111
|
-
-
|
|
112
|
-
-
|
|
113
|
-
-
|
|
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
|
|
118
|
+
- advance the `todo → in-progress → review → done` lifecycle; failed review becomes `rejected`, retry returns to `in-progress`, and `canceled` is terminal
|
|
119
|
+
- use **active** only as the independent singleton focus that auto-drive continues; focusing a task never changes its lifecycle
|
|
120
|
+
- starting nested effort moves todo ancestors to in-progress; submitting enters review; completing review checks both typed checklist proofs and executable gates
|
|
121
|
+
- passing review marks only that task done and focuses one deterministic ready successor while leaving the successor todo until effort starts
|
|
122
|
+
- successors are never auto-completed; fan-in, fan-out, diamonds, and disconnected DAGs remain explicit
|
|
123
|
+
- inspect deterministic execution layers, readiness, a box-drawn nested hierarchy, composition, dependencies, evidence-bearing checklists, and verification gates
|
|
124
|
+
- lifecycle colors are semantic and redundant with text/glyphs: To-Do grey, in-progress yellow, review blue, rejected orange, done green, and canceled red; `▶` marks active focus
|
|
125
|
+
- Show details keeps Checklist and Validation gates separate from incidental Metadata, then renders relationships as a Unicode box-drawing graph footer; `↑/↓` scrolls and `←/→` pans wide graphs
|
|
126
|
+
- the compact persistent widget shows bounded open work in containment order and always retains the active focus
|
|
127
|
+
|
|
128
|
+
Authenticated CLI parity covers the changed lifecycle and focus operations:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
papyrus tasks graph --json
|
|
132
|
+
papyrus tasks active --json
|
|
133
|
+
papyrus tasks focus <id> --json
|
|
134
|
+
papyrus tasks start <id> --json
|
|
135
|
+
papyrus tasks submit <id> --json
|
|
136
|
+
papyrus tasks complete <id> --json
|
|
137
|
+
papyrus tasks reject <id> --json
|
|
138
|
+
papyrus tasks retry <id> --json
|
|
139
|
+
papyrus tasks cancel <id> --json
|
|
140
|
+
```
|
|
114
141
|
|
|
115
142
|
Checklist criteria are an item-to-proof map. Every new item requires one or more typed references to inspectable evidence; proof presence does not imply that the evidence passed an executable gate:
|
|
116
143
|
|
|
@@ -127,9 +154,9 @@ checklist: {
|
|
|
127
154
|
|
|
128
155
|
Proof types are `file`, `symbol`, `code`, `test`, `command`, `artifact`, and `url`. Existing array checklists remain readable as legacy items with `proof: missing`; Papyrus does not invent evidence.
|
|
129
156
|
|
|
130
|
-
Papyrus also injects an Alef-style reconciliation block on every agent turn while work remains: `Current`, `Desired`, `Verify`, and `Next`. The agent is explicitly instructed to ask **“Did we accomplish this task?”** and run
|
|
157
|
+
Papyrus also injects an Alef-style reconciliation block on every agent turn while work remains: `Current`, `Desired`, `Verify`, and `Next`. The agent is explicitly instructed to ask **“Did we accomplish this task?”** and run review before marking it done. The injection disappears when every task is done or canceled.
|
|
131
158
|
|
|
132
|
-
In TUI and RPC modes, the extension checks
|
|
159
|
+
In TUI and RPC modes, the extension checks the singleton active focus at Pi’s public `agent_settled` lifecycle boundary. If a focused task remains and no continuation is already pending, it queues one hidden next turn. No manual driving command is required. Driving is single-flight and pauses after 20 automatic turns or 6 unchanged task snapshots; human input and task progress reset the bounded counters automatically.
|
|
133
160
|
|
|
134
161
|
## Why
|
|
135
162
|
|
|
@@ -140,12 +167,20 @@ Papyrus keeps SQLite’s local simplicity while centralizing writes, migrations,
|
|
|
140
167
|
Install the published Pi package, then install its supervised user service:
|
|
141
168
|
|
|
142
169
|
```bash
|
|
143
|
-
|
|
170
|
+
packed install npm:@danypops/papyrus
|
|
144
171
|
~/.pi/agent/npm/node_modules/.bin/papyrus service install
|
|
145
172
|
```
|
|
146
173
|
|
|
174
|
+
Existing databases are never migrated on daemon boot. After upgrading across the task-lifecycle schema boundary, run the authenticated CLI migration explicitly:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
~/.pi/agent/npm/node_modules/.bin/papyrus migrate task-lifecycle
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Until that command succeeds, health reports `migrationRequired` and normal domain operations are rejected with actionable guidance. Migration is not exposed as a Pi tool or MCP action. New empty databases bootstrap directly at the current schema.
|
|
181
|
+
|
|
147
182
|
Reload Pi once the service is active. Git installs remain available for development builds:
|
|
148
183
|
|
|
149
184
|
```bash
|
|
150
|
-
|
|
185
|
+
packed install git:github.com/DanyPops/papyrus
|
|
151
186
|
```
|
|
@@ -22,23 +22,17 @@ export interface ActiveTaskContinuationDecision {
|
|
|
22
22
|
prompt?: string;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
const DISPLAYED_TASK_LIMIT = 3;
|
|
26
25
|
const TITLE_LIMIT = 120;
|
|
27
26
|
|
|
28
|
-
function fingerprint(
|
|
29
|
-
return
|
|
30
|
-
.sort((left, right) => left.id.localeCompare(right.id))
|
|
31
|
-
.map((task) => `${task.id}:${task.updated_at}`)
|
|
32
|
-
.join("|");
|
|
27
|
+
function fingerprint(task: ActiveTaskMarker): string {
|
|
28
|
+
return `${task.id}:${task.updated_at}`;
|
|
33
29
|
}
|
|
34
30
|
|
|
35
|
-
function continuationPrompt(
|
|
36
|
-
const names = tasks.slice(0, DISPLAYED_TASK_LIMIT).map((task) => `- ${task.id}: ${task.title.slice(0, TITLE_LIMIT)}`);
|
|
31
|
+
function continuationPrompt(task: ActiveTaskMarker): string {
|
|
37
32
|
return [
|
|
38
|
-
"Continue active Papyrus
|
|
39
|
-
"Reconcile
|
|
40
|
-
|
|
41
|
-
...names,
|
|
33
|
+
"Continue the active Papyrus Task now; do not hand off merely because the previous Pi run settled.",
|
|
34
|
+
"Reconcile its lifecycle, take the next concrete action, use tools, submit it for review when implementation effort is ready, and run gates plus checklist review before completion.",
|
|
35
|
+
`Active task: ${task.id}: ${task.title.slice(0, TITLE_LIMIT)}`,
|
|
42
36
|
].join("\n");
|
|
43
37
|
}
|
|
44
38
|
|
|
@@ -56,16 +50,16 @@ export class ActiveTaskContinuation {
|
|
|
56
50
|
}
|
|
57
51
|
}
|
|
58
52
|
|
|
59
|
-
evaluate(
|
|
53
|
+
evaluate(task: ActiveTaskMarker | null, context: { idle: boolean; pendingMessages: boolean }): ActiveTaskContinuationDecision {
|
|
60
54
|
if (!context.idle) return { action: "wait", reason: "Pi is not settled" };
|
|
61
55
|
if (context.pendingMessages) return { action: "wait", reason: "Pi already has pending messages" };
|
|
62
56
|
if (this.queued) return { action: "wait", reason: "continuation already queued" };
|
|
63
|
-
if (
|
|
57
|
+
if (!task) {
|
|
64
58
|
this.resetProgress();
|
|
65
|
-
return { action: "wait", reason: "no active
|
|
59
|
+
return { action: "wait", reason: "no active task" };
|
|
66
60
|
}
|
|
67
61
|
|
|
68
|
-
const currentFingerprint = fingerprint(
|
|
62
|
+
const currentFingerprint = fingerprint(task);
|
|
69
63
|
if (currentFingerprint !== this.lastFingerprint) {
|
|
70
64
|
this.lastFingerprint = currentFingerprint;
|
|
71
65
|
this.unchangedTurns = 0;
|
|
@@ -85,8 +79,8 @@ export class ActiveTaskContinuation {
|
|
|
85
79
|
this.consecutiveTurns += 1;
|
|
86
80
|
return {
|
|
87
81
|
action: "continue",
|
|
88
|
-
reason: "active
|
|
89
|
-
prompt: continuationPrompt(
|
|
82
|
+
reason: "an active task remains",
|
|
83
|
+
prompt: continuationPrompt(task),
|
|
90
84
|
};
|
|
91
85
|
}
|
|
92
86
|
|
|
@@ -6,10 +6,12 @@ import {
|
|
|
6
6
|
} from "../../src/constants.ts";
|
|
7
7
|
|
|
8
8
|
const STATUS_GLYPHS: Record<string, string> = {
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
todo: "○",
|
|
10
|
+
"in-progress": "●",
|
|
11
|
+
review: "◆",
|
|
12
|
+
rejected: "▲",
|
|
11
13
|
done: "■",
|
|
12
|
-
|
|
14
|
+
canceled: "×",
|
|
13
15
|
};
|
|
14
16
|
|
|
15
17
|
export interface MetadataFormatOptions {
|
|
@@ -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,
|
|
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()),
|
|
@@ -63,6 +64,16 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
63
64
|
const artifact = await callService<Record<string, unknown>, Artifact>("tasks.show", params);
|
|
64
65
|
return text(`${artifactLine(artifact)}\n\n${artifact.body}`, { artifact });
|
|
65
66
|
}
|
|
67
|
+
if (action === "active") {
|
|
68
|
+
const artifact = await callService<Record<string, unknown>, Artifact | null>("tasks.active", params);
|
|
69
|
+
return text(artifact ? `Active: ${artifactLine(artifact)}` : "No active task.", { artifact });
|
|
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
|
+
}
|
|
66
77
|
if (action === "plan") {
|
|
67
78
|
const plan = await callService<Record<string, unknown>, TaskExecutionPlan>("tasks.plan", params);
|
|
68
79
|
const byId = new Map(plan.nodes.map((node) => [node.id, node]));
|
|
@@ -83,17 +94,27 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
83
94
|
if (action === "complete") {
|
|
84
95
|
const result = await callService<Record<string, unknown>, TaskCompletion>("tasks.complete", params);
|
|
85
96
|
const gates = result.gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n");
|
|
86
|
-
const
|
|
97
|
+
const checklist = result.checklist.map((item) => `${item.accepted ? "✓" : "✗"} proof: ${item.item}${item.reason ? ` — ${item.reason}` : ""}`).join("\n");
|
|
98
|
+
const focused = result.focused ? `\nActive: ${artifactLine(result.focused)}` : "";
|
|
87
99
|
const blocked = result.blocked.length > 0
|
|
88
100
|
? `\nBlocked: ${result.blocked.map((entry) => `${artifactLine(entry.artifact)} waits for ${entry.dependencyIds.join(", ")}`).join("; ")}`
|
|
89
101
|
: "";
|
|
90
|
-
return text(`${result.completed ? "Completed" : "
|
|
102
|
+
return text(`${result.completed ? "Completed" : "Rejected"}: ${artifactLine(result.artifact)}${focused}${blocked}${checklist ? `\n${checklist}` : ""}${gates ? `\n${gates}` : ""}`, { ...result });
|
|
91
103
|
}
|
|
92
104
|
if (action === "run_gates") {
|
|
93
105
|
const gates = await callService<Record<string, unknown>, GateResult[]>("tasks.run_gates", params);
|
|
94
106
|
return text(gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n") || "No gates configured.", { gates });
|
|
95
107
|
}
|
|
96
|
-
const operations = {
|
|
108
|
+
const operations = {
|
|
109
|
+
focus: "tasks.focus",
|
|
110
|
+
start: "tasks.start",
|
|
111
|
+
submit: "tasks.submit",
|
|
112
|
+
reject: "tasks.reject",
|
|
113
|
+
retry: "tasks.retry",
|
|
114
|
+
cancel: "tasks.cancel",
|
|
115
|
+
depend: "tasks.depend",
|
|
116
|
+
contain: "tasks.contain",
|
|
117
|
+
} as const;
|
|
97
118
|
const operation = operations[action as keyof typeof operations];
|
|
98
119
|
if (!operation) return text(`Unknown tasks action: ${action}`);
|
|
99
120
|
const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
|
|
@@ -189,11 +210,13 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
189
210
|
pi.registerTool({
|
|
190
211
|
name: "skills",
|
|
191
212
|
label: "Skills",
|
|
192
|
-
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.",
|
|
193
214
|
parameters: Type.Object({
|
|
194
215
|
action: Type.String(), id: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
|
|
195
216
|
body: Type.Optional(Type.String()), trigger: Type.Optional(Type.String()), steps: Type.Optional(Type.Array(Type.String())),
|
|
196
|
-
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())),
|
|
197
220
|
extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())), status: Type.Optional(Type.String()),
|
|
198
221
|
text: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()), template_id: Type.Optional(Type.String()),
|
|
199
222
|
target_kind: Type.Optional(Type.String()), defaults: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
@@ -215,6 +238,17 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
215
238
|
const invocation = await callService<Record<string, unknown>, string>("skills.invoke", params);
|
|
216
239
|
return text(invocation, { invocation });
|
|
217
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
|
+
}
|
|
218
252
|
const operations = { show: "skills.show", enable: "skills.enable", disable: "skills.disable", instantiate: "skills.instantiate" } as const;
|
|
219
253
|
const operation = operations[action as keyof typeof operations];
|
|
220
254
|
if (!operation) return text(`Unknown skills action: ${action}`);
|
package/extension/src/index.ts
CHANGED
|
@@ -11,7 +11,6 @@ import type { ExtensionAPI, ExtensionContext, ExtensionUIContext, Theme } from "
|
|
|
11
11
|
import { Type } from "typebox";
|
|
12
12
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
13
13
|
import {
|
|
14
|
-
TASK_DRIVER_ACTIVE_LIMIT,
|
|
15
14
|
TASK_DRIVER_MAX_TURNS,
|
|
16
15
|
TASK_DRIVER_MAX_UNCHANGED_TURNS,
|
|
17
16
|
} from "../../src/constants.ts";
|
|
@@ -20,9 +19,10 @@ import type { GateResult } from "../../src/domain/gate.ts";
|
|
|
20
19
|
import { formatMetadata } from "./artifact-format.ts";
|
|
21
20
|
import { callService } from "./service-client.ts";
|
|
22
21
|
import { registerDomainTools } from "./domain-tools.ts";
|
|
23
|
-
import type { TaskGraph } from "../../src/task-service.ts";
|
|
22
|
+
import type { TaskGraph, TaskStatus } from "../../src/task-service.ts";
|
|
24
23
|
import { ActiveTaskContinuation, type ActiveTaskMarker } from "./active-task-continuation.ts";
|
|
25
|
-
import { buildTaskWidgetProjection } from "./task-widget.ts";
|
|
24
|
+
import { buildTaskWidgetProjection, type TaskWidgetProjection } from "./task-widget.ts";
|
|
25
|
+
import { TASK_STATUS_PRESENTATION, taskTreeConnector } from "./task-presentation.ts";
|
|
26
26
|
|
|
27
27
|
function text(t: string, details: Record<string, unknown> = {}) {
|
|
28
28
|
return { content: [{ type: "text" as const, text: t }], details };
|
|
@@ -32,15 +32,23 @@ function text(t: string, details: Record<string, unknown> = {}) {
|
|
|
32
32
|
// Task widget (TodoOverlay pattern from rpiv-todo: factory form, requestRender)
|
|
33
33
|
// ---------------------------------------------------------------------------
|
|
34
34
|
|
|
35
|
-
const GLYPHS: Record<string, (theme: Theme) => string> = {
|
|
36
|
-
pending: (t) => t.fg("dim", "○"),
|
|
37
|
-
active: (t) => t.fg("warning", "●"),
|
|
38
|
-
done: (t) => t.fg("success", "■"),
|
|
39
|
-
failed: (t) => t.fg("error", "▲"),
|
|
40
|
-
};
|
|
41
|
-
|
|
42
35
|
const WIDGET_KEY = "pi-papyrus";
|
|
43
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
|
+
|
|
44
52
|
class TaskOverlay {
|
|
45
53
|
private uiCtx: ExtensionUIContext | undefined;
|
|
46
54
|
private registered = false;
|
|
@@ -100,27 +108,7 @@ class TaskOverlay {
|
|
|
100
108
|
}
|
|
101
109
|
|
|
102
110
|
private renderLines(theme: Theme, width: number): string[] {
|
|
103
|
-
|
|
104
|
-
if (projection.total === 0) return [];
|
|
105
|
-
|
|
106
|
-
if (projection.activeTotal === 0) {
|
|
107
|
-
return [truncateToWidth(theme.bold("Tasks · no active tasks · /tasks"), width, "…")];
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const lines = [
|
|
111
|
-
truncateToWidth(
|
|
112
|
-
theme.bold(`Tasks · ${GLYPHS.active!(theme)} ${projection.activeTotal} active`),
|
|
113
|
-
width,
|
|
114
|
-
"…",
|
|
115
|
-
),
|
|
116
|
-
];
|
|
117
|
-
for (const row of projection.active) {
|
|
118
|
-
const hierarchy = row.depth === 0
|
|
119
|
-
? row.hasActiveChildren ? "▾" : "·"
|
|
120
|
-
: `${" ".repeat(row.depth)}↳`;
|
|
121
|
-
lines.push(truncateToWidth(` ${hierarchy} ${GLYPHS.active!(theme)} ${row.task.title}`, width, "…"));
|
|
122
|
-
}
|
|
123
|
-
return lines;
|
|
111
|
+
return renderTaskWidgetLines(theme, buildTaskWidgetProjection(this.snapshot), width);
|
|
124
112
|
}
|
|
125
113
|
|
|
126
114
|
dispose(): void {
|
|
@@ -145,10 +133,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
145
133
|
const driveActiveTasks = async (ctx: ExtensionContext): Promise<void> => {
|
|
146
134
|
if (ctx.mode !== "tui" && ctx.mode !== "rpc") return;
|
|
147
135
|
try {
|
|
148
|
-
const active = await callService<Record<string,
|
|
149
|
-
status: "active",
|
|
150
|
-
limit: TASK_DRIVER_ACTIVE_LIMIT,
|
|
151
|
-
});
|
|
136
|
+
const active = await callService<Record<string, never>, ActiveTaskMarker | null>("tasks.active", {});
|
|
152
137
|
const decision = taskContinuation.evaluate(active, {
|
|
153
138
|
idle: ctx.isIdle(),
|
|
154
139
|
pendingMessages: ctx.hasPendingMessages(),
|
|
@@ -372,7 +357,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
372
357
|
pi.on("agent_settled", async (_event, ctx) => { await driveActiveTasks(ctx); });
|
|
373
358
|
|
|
374
359
|
// ── "Are we there yet?" — inject active tasks into every turn ──────
|
|
375
|
-
// The agent sees its open work items every turn. If there are
|
|
360
|
+
// The agent sees its open work items every turn. If there are rejected
|
|
376
361
|
// tasks, they're explicitly called out — the agent should address them.
|
|
377
362
|
|
|
378
363
|
pi.on("before_agent_start", async (event, _ctx) => {
|
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");
|
|
@@ -3,10 +3,12 @@ import { checklistEntries, type ProofReference } from "../../src/domain/checklis
|
|
|
3
3
|
import { formatMetadata } from "./artifact-format.ts";
|
|
4
4
|
|
|
5
5
|
const TASK_STATUS_GLYPHS: Record<string, string> = {
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
todo: "○",
|
|
7
|
+
"in-progress": "●",
|
|
8
|
+
review: "◆",
|
|
9
|
+
rejected: "▲",
|
|
8
10
|
done: "■",
|
|
9
|
-
|
|
11
|
+
canceled: "×",
|
|
10
12
|
};
|
|
11
13
|
|
|
12
14
|
function proofLine(proof: ProofReference): string {
|
|
@@ -10,9 +10,18 @@ import type { GraphRenderer } from "../../src/ports/graph-renderer.ts";
|
|
|
10
10
|
import { projectTaskGraph, type TaskGraphView } from "../../src/task-graph-view.ts";
|
|
11
11
|
import type { TaskGraph } from "../../src/task-service.ts";
|
|
12
12
|
import { BeautifulMermaidRenderer } from "./beautiful-mermaid-renderer.ts";
|
|
13
|
+
import { TASK_STATUS_PRESENTATION } from "./task-presentation.ts";
|
|
13
14
|
|
|
14
15
|
const GRAPH_VIEWS: TaskGraphView[] = ["execution", "dependencies", "composition"];
|
|
15
16
|
|
|
17
|
+
export function colorizeTaskGraphLine(theme: Theme, line: string): string {
|
|
18
|
+
let colored = line;
|
|
19
|
+
for (const presentation of Object.values(TASK_STATUS_PRESENTATION)) {
|
|
20
|
+
colored = colored.replaceAll(presentation.glyph, theme.fg(presentation.color, presentation.glyph));
|
|
21
|
+
}
|
|
22
|
+
return colored.replaceAll("▶", theme.fg("accent", "▶"));
|
|
23
|
+
}
|
|
24
|
+
|
|
16
25
|
export class TaskGraphViewport {
|
|
17
26
|
private viewIndex = 0;
|
|
18
27
|
private offsetX = 0;
|
|
@@ -51,7 +60,8 @@ export class TaskGraphViewport {
|
|
|
51
60
|
truncateToWidth(this.theme.bold(`Task graph · ${GRAPH_VIEWS[this.viewIndex]}`), contentWidth, ""),
|
|
52
61
|
truncateToWidth(this.theme.fg("dim", `Tab switch · arrows pan · Esc back${position}`), contentWidth, ""),
|
|
53
62
|
border,
|
|
54
|
-
...this.graphLines.slice(this.offsetY, end).map((line) =>
|
|
63
|
+
...this.graphLines.slice(this.offsetY, end).map((line) =>
|
|
64
|
+
colorizeTaskGraphLine(this.theme, sliceByColumn(line, this.offsetX, contentWidth, true))),
|
|
55
65
|
border,
|
|
56
66
|
];
|
|
57
67
|
}
|
|
@@ -76,8 +86,17 @@ export class TaskGraphViewport {
|
|
|
76
86
|
|
|
77
87
|
private rebuild(): void {
|
|
78
88
|
const view = GRAPH_VIEWS[this.viewIndex]!;
|
|
79
|
-
|
|
80
|
-
|
|
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
|
+
}
|
|
81
100
|
this.offsetY = Math.min(this.offsetY, Math.max(0, this.graphLines.length - this.viewportHeight));
|
|
82
101
|
}
|
|
83
102
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { TaskStatus } from "../../src/task-service.ts";
|
|
3
|
+
|
|
4
|
+
export interface TaskStatusPresentation {
|
|
5
|
+
label: string;
|
|
6
|
+
glyph: string;
|
|
7
|
+
color: ThemeColor;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const TASK_STATUS_PRESENTATION: Record<TaskStatus, TaskStatusPresentation> = {
|
|
11
|
+
todo: { label: "To-Do", glyph: "○", color: "muted" },
|
|
12
|
+
"in-progress": { label: "in-progress", glyph: "●", color: "warning" },
|
|
13
|
+
review: { label: "review", glyph: "◆", color: "mdLink" },
|
|
14
|
+
rejected: { label: "rejected", glyph: "▲", color: "accent" },
|
|
15
|
+
done: { label: "done", glyph: "■", color: "success" },
|
|
16
|
+
canceled: { label: "canceled", glyph: "×", color: "error" },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function taskTreeConnector(options: {
|
|
20
|
+
depth: number;
|
|
21
|
+
hasChildren: boolean;
|
|
22
|
+
hasLaterSibling: boolean;
|
|
23
|
+
}): string {
|
|
24
|
+
if (options.depth === 0) return options.hasChildren ? "▾" : "·";
|
|
25
|
+
return `${"│ ".repeat(Math.max(0, options.depth - 1))}${options.hasLaterSibling ? "├─" : "└─"}`;
|
|
26
|
+
}
|