@danypops/papyrus 0.1.0 → 0.2.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 +22 -10
- package/extension/src/{facade-tools.ts → domain-tools.ts} +26 -7
- package/extension/src/index.ts +77 -5
- package/extension/src/task-driver.ts +132 -0
- package/extension/src/task-graph.ts +3 -3
- package/extension/src/tasks.ts +26 -9
- package/package.json +1 -1
- package/src/adapters/sqlite-artifact-store.ts +7 -0
- package/src/cli.ts +103 -3
- package/src/constants.ts +10 -4
- package/src/db.ts +1 -1
- package/src/domain/artifact.ts +1 -0
- package/src/domain/skill-definition.ts +217 -0
- package/src/{facades.ts → domain-services.ts} +1 -1
- package/src/service.ts +14 -3
- package/src/task-execution.ts +124 -0
- package/src/task-graph-view.ts +25 -6
- package/src/task-service.ts +98 -6
- package/src/version.ts +16 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Papyrus
|
|
2
2
|
|
|
3
|
-
Graph artifact service for Pi — enforced SQLite schema, domain
|
|
3
|
+
Graph artifact service for Pi — enforced SQLite schema, domain tools and services, and native interactive frontends.
|
|
4
4
|
|
|
5
5
|
Artifacts are rows in SQLite. Edges are typed relations. Kinds and relations are **registered and enforced**—the schema is the protocol. A supervised Bun daemon is the sole database owner; Pi extensions and other clients use its authenticated loopback service API.
|
|
6
6
|
|
|
@@ -9,7 +9,7 @@ Artifacts are rows in SQLite. Edges are typed relations. Kinds and relations are
|
|
|
9
9
|
```text
|
|
10
10
|
Pi tools + TUI
|
|
11
11
|
↓
|
|
12
|
-
tasks / docs / rules / skills
|
|
12
|
+
tasks / docs / rules / skills domain tools
|
|
13
13
|
↓
|
|
14
14
|
Papyrus client → authenticated loopback daemon
|
|
15
15
|
↓
|
|
@@ -18,7 +18,7 @@ operation registry + lifecycle services
|
|
|
18
18
|
graph-store operations → SQLite (WAL)
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
The `papyrus_*` tools remain low-level administration escape hatches. Normal agent work should use the domain
|
|
21
|
+
The `papyrus_*` tools remain low-level administration escape hatches. Normal agent work should use the domain tools.
|
|
22
22
|
|
|
23
23
|
## Storage and service
|
|
24
24
|
|
|
@@ -31,6 +31,12 @@ $XDG_RUNTIME_DIR/papyrus/{port,token} # private daemon discovery
|
|
|
31
31
|
bun src/cli.ts service install # install, enable, and start user service
|
|
32
32
|
bun src/cli.ts service status
|
|
33
33
|
bun src/cli.ts service restart
|
|
34
|
+
|
|
35
|
+
# Authenticated daemon-backed task automation (add --json for machine output)
|
|
36
|
+
papyrus tasks plan
|
|
37
|
+
papyrus tasks depend <task-id> <prerequisite-id>
|
|
38
|
+
papyrus tasks start <task-id>
|
|
39
|
+
papyrus tasks complete <task-id>
|
|
34
40
|
```
|
|
35
41
|
|
|
36
42
|
For repository work, install the versioned ownership guard once:
|
|
@@ -50,17 +56,19 @@ Papyrus enforces four artifact kinds:
|
|
|
50
56
|
- `doc` — knowledge: specifications, decisions, and research
|
|
51
57
|
- `task` — work: desired outcomes, gates, checklists, and dependencies
|
|
52
58
|
- `rule` — governance injected into the Pi system prompt
|
|
53
|
-
- `skill` —
|
|
59
|
+
- `skill` — a parameterized workflow bundle whose validated arguments render a connected collection of deterministic Tasks plus contextual Rules and Docs
|
|
54
60
|
|
|
55
61
|
Each kind has an enforced status vocabulary. Every edge endpoint must exist, and every edge relation must be registered in `relation_names`. Relations are universal: any artifact kind can link to any other kind.
|
|
56
62
|
|
|
57
63
|
### Hierarchy and traversal
|
|
58
64
|
|
|
59
|
-
Use `contains` and `part_of` for explicit parent/child structure; use `depends_on` for execution ordering. Graph reads are cycle-safe and bounded by `depth` and `max_nodes` (defaults: depth 4, 100 nodes; hard ceilings: depth 20, 1,000 nodes).
|
|
65
|
+
Use `contains` and `part_of` for explicit parent/child structure; use `depends_on` for execution ordering. Dependency edges form an executable DAG: self-dependencies and cycles are rejected, fan-in waits for every prerequisite, and fan-out may activate several successors. Graph reads are cycle-safe and bounded by `depth` and `max_nodes` (defaults: depth 4, 100 nodes; hard ceilings: depth 20, 1,000 nodes). Executable task plans are additionally bounded to 1,000 tasks and 10,000 relationships.
|
|
66
|
+
|
|
67
|
+
### Skills and compatibility templates
|
|
60
68
|
|
|
61
|
-
|
|
69
|
+
A Papyrus Skill is distinct from a conventional prompt-only skill: its input API and templates define a connected Task/Rule/Doc workflow. Task dependencies and gates provide deterministic execution; Rules provide scoped governance; Docs provide invocation context and provenance. The versioned workflow-instantiation API is tracked as active Papyrus work.
|
|
62
70
|
|
|
63
|
-
|
|
71
|
+
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.
|
|
64
72
|
|
|
65
73
|
## Tools
|
|
66
74
|
|
|
@@ -71,9 +79,9 @@ The `papyrus_*` tools are the low-level graph-store API:
|
|
|
71
79
|
- **`papyrus_graph`** — link artifacts, perform bounded traversal, or update status
|
|
72
80
|
- **`papyrus_show`** — read nested metadata and bounded edges, optionally running gates
|
|
73
81
|
|
|
74
|
-
Agent-facing
|
|
82
|
+
Agent-facing domain tools own lifecycle invariants and sit above this store API:
|
|
75
83
|
|
|
76
|
-
- **`tasks`** — create/list/show, replace evidence-bearing checklists, hierarchy/dependencies, start/fail/retry, non-blocking gates, and gate-enforced completion
|
|
84
|
+
- **`tasks`** — create/list/show/plan, replace evidence-bearing checklists, hierarchy/dependencies, start/fail/retry, non-blocking gates, and gate-enforced completion with automatic activation of newly ready successors
|
|
77
85
|
- **`docs`** — create/list/show, activate/archive/reopen, and document-safe graph links
|
|
78
86
|
- **`rules`** — create/list/show/preview, enable/disable, and attach governance gates to tasks
|
|
79
87
|
- **`skills`** — create/list/show/invoke, enable/disable, create templates, and instantiate templates
|
|
@@ -98,7 +106,9 @@ Run `/tasks` for the interactive task panel:
|
|
|
98
106
|
- `/` filters; arrow keys navigate; Enter opens task actions
|
|
99
107
|
- `g` opens the programmatic Unicode graph; Tab switches dependency/composition views and arrow keys pan
|
|
100
108
|
- advance the `pending → active → done` lifecycle or retry `failed → pending`
|
|
101
|
-
-
|
|
109
|
+
- completing an active task runs only that task’s gates; success marks it done and activates every direct pending successor whose full prerequisite set is done
|
|
110
|
+
- successors are never auto-completed: each must pass its own gates; fan-in, fan-out, diamonds, and disconnected DAGs remain explicit
|
|
111
|
+
- inspect deterministic execution layers, readiness, a nested task hierarchy, composition, dependencies, evidence-bearing checklists, and verification gates
|
|
102
112
|
- Show details keeps Checklist and Validation gates separate from incidental Metadata, then renders relationships as a Unicode graph footer; `↑/↓` scrolls and `←/→` pans wide graphs
|
|
103
113
|
- the compact persistent widget shows active work in containment order, indents active children beneath active parents, and points to `/tasks` for the complete graph
|
|
104
114
|
|
|
@@ -119,6 +129,8 @@ Proof types are `file`, `symbol`, `code`, `test`, `command`, `artifact`, and `ur
|
|
|
119
129
|
|
|
120
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 gates before marking it done. The injection disappears when every task is complete.
|
|
121
131
|
|
|
132
|
+
In TUI and RPC modes, the extension checks bounded active Tasks at Pi’s public `agent_settled` lifecycle boundary. If active work remains and no continuation is already pending, it queues one hidden next turn so the agent continues instead of handing off merely because a low-level run ended. Driving is single-flight and pauses after 20 automatic turns or 6 unchanged task snapshots. Use `/task-drive on`, `/task-drive off`, or `/task-drive status`; human input and task progress reset the bounded counters.
|
|
133
|
+
|
|
122
134
|
## Why
|
|
123
135
|
|
|
124
136
|
Papyrus keeps SQLite’s local simplicity while centralizing writes, migrations, lifecycle invariants, gate execution, and maintenance in one small supervised process. The loopback bearer token prevents unrelated local HTTP callers from mutating the graph, while the native Pi extension provides richer domain tools and TUI integration.
|
|
@@ -3,6 +3,8 @@ import { Type } from "typebox";
|
|
|
3
3
|
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
|
+
import type { TaskExecutionPlan } from "../../src/task-execution.ts";
|
|
7
|
+
import type { TaskCompletion } from "../../src/task-service.ts";
|
|
6
8
|
import { callService } from "./service-client.ts";
|
|
7
9
|
|
|
8
10
|
function text(message: string, details: Record<string, unknown> = {}) {
|
|
@@ -23,11 +25,11 @@ const checklistCriterionSchema = Type.Object({
|
|
|
23
25
|
proof: Type.Array(proofReferenceSchema, { minItems: 1 }),
|
|
24
26
|
});
|
|
25
27
|
|
|
26
|
-
export function
|
|
28
|
+
export function registerDomainTools(pi: ExtensionAPI): void {
|
|
27
29
|
pi.registerTool({
|
|
28
30
|
name: "tasks",
|
|
29
31
|
label: "Tasks",
|
|
30
|
-
description: "Task domain
|
|
32
|
+
description: "Task domain tool. ACTIONS: create, list, show, plan, start, complete (runs gates, refuses done on failure, and starts newly ready successors), fail, retry, run_gates, set_checklist, depend, contain. Dependency graphs support deterministic execution layers, fan-in, and fan-out; cycles are rejected. Checklist is an item-to-proof map; every item requires one or more typed evidence references. Prefer this over low-level papyrus_* tools for task work.",
|
|
31
33
|
parameters: Type.Object({
|
|
32
34
|
action: Type.String(),
|
|
33
35
|
id: Type.Optional(Type.String()),
|
|
@@ -61,14 +63,31 @@ export function registerFacadeTools(pi: ExtensionAPI): void {
|
|
|
61
63
|
const artifact = await callService<Record<string, unknown>, Artifact>("tasks.show", params);
|
|
62
64
|
return text(`${artifactLine(artifact)}\n\n${artifact.body}`, { artifact });
|
|
63
65
|
}
|
|
66
|
+
if (action === "plan") {
|
|
67
|
+
const plan = await callService<Record<string, unknown>, TaskExecutionPlan>("tasks.plan", params);
|
|
68
|
+
const byId = new Map(plan.nodes.map((node) => [node.id, node]));
|
|
69
|
+
const lines = plan.layers.flatMap((layer, index) => [
|
|
70
|
+
`Layer ${index + 1}`,
|
|
71
|
+
...layer.map((id) => {
|
|
72
|
+
const node = byId.get(id);
|
|
73
|
+
return node ? ` [${node.state}] ${node.id} ${node.title}` : ` [unknown] ${id}`;
|
|
74
|
+
}),
|
|
75
|
+
]);
|
|
76
|
+
if (plan.cycleIds.length > 0) lines.push(`Invalid cycle: ${plan.cycleIds.join(", ")}`);
|
|
77
|
+
return text(lines.join("\n") || "No tasks in execution plan.", { plan });
|
|
78
|
+
}
|
|
64
79
|
if (action === "set_checklist") {
|
|
65
80
|
const artifact = await callService<Record<string, unknown>, Artifact>("tasks.set_checklist", params);
|
|
66
81
|
return text(`Updated checklist: ${artifactLine(artifact)}`, { artifact });
|
|
67
82
|
}
|
|
68
83
|
if (action === "complete") {
|
|
69
|
-
const result = await callService<Record<string, unknown>,
|
|
84
|
+
const result = await callService<Record<string, unknown>, TaskCompletion>("tasks.complete", params);
|
|
70
85
|
const gates = result.gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n");
|
|
71
|
-
|
|
86
|
+
const started = result.started.length > 0 ? `\nStarted: ${result.started.map(artifactLine).join(", ")}` : "";
|
|
87
|
+
const blocked = result.blocked.length > 0
|
|
88
|
+
? `\nBlocked: ${result.blocked.map((entry) => `${artifactLine(entry.artifact)} waits for ${entry.dependencyIds.join(", ")}`).join("; ")}`
|
|
89
|
+
: "";
|
|
90
|
+
return text(`${result.completed ? "Completed" : "Not completed"}: ${artifactLine(result.artifact)}${started}${blocked}${gates ? `\n${gates}` : ""}`, { ...result });
|
|
72
91
|
}
|
|
73
92
|
if (action === "run_gates") {
|
|
74
93
|
const gates = await callService<Record<string, unknown>, GateResult[]>("tasks.run_gates", params);
|
|
@@ -88,7 +107,7 @@ export function registerFacadeTools(pi: ExtensionAPI): void {
|
|
|
88
107
|
pi.registerTool({
|
|
89
108
|
name: "docs",
|
|
90
109
|
label: "Documents",
|
|
91
|
-
description: "Document domain
|
|
110
|
+
description: "Document domain tool. ACTIONS: create, list, show, activate, archive, reopen, link. Prefer this over low-level papyrus_* tools for document work.",
|
|
92
111
|
parameters: Type.Object({
|
|
93
112
|
action: Type.String(),
|
|
94
113
|
id: Type.Optional(Type.String()),
|
|
@@ -133,7 +152,7 @@ export function registerFacadeTools(pi: ExtensionAPI): void {
|
|
|
133
152
|
pi.registerTool({
|
|
134
153
|
name: "rules",
|
|
135
154
|
label: "Rules",
|
|
136
|
-
description: "Rule domain
|
|
155
|
+
description: "Rule domain tool. ACTIONS: create, list, show, preview, enable, disable, gate. Active rules inject into the agent system prompt.",
|
|
137
156
|
parameters: Type.Object({
|
|
138
157
|
action: Type.String(), id: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
|
|
139
158
|
body: Type.Optional(Type.String()), condition: Type.Optional(Type.String()), rule_action: Type.Optional(Type.String()),
|
|
@@ -170,7 +189,7 @@ export function registerFacadeTools(pi: ExtensionAPI): void {
|
|
|
170
189
|
pi.registerTool({
|
|
171
190
|
name: "skills",
|
|
172
191
|
label: "Skills",
|
|
173
|
-
description: "Skill and
|
|
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.",
|
|
174
193
|
parameters: Type.Object({
|
|
175
194
|
action: Type.String(), id: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
|
|
176
195
|
body: Type.Optional(Type.String()), trigger: Type.Optional(Type.String()), steps: Type.Optional(Type.Array(Type.String())),
|
package/extension/src/index.ts
CHANGED
|
@@ -7,15 +7,21 @@
|
|
|
7
7
|
* Injection: active rules + open tasks appended to system prompt every turn.
|
|
8
8
|
* "Are we there yet?" — the agent sees its open work items.
|
|
9
9
|
*/
|
|
10
|
-
import type { ExtensionAPI, ExtensionUIContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import type { ExtensionAPI, ExtensionContext, ExtensionUIContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import { Type } from "typebox";
|
|
12
12
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
13
|
+
import {
|
|
14
|
+
TASK_DRIVER_ACTIVE_LIMIT,
|
|
15
|
+
TASK_DRIVER_MAX_TURNS,
|
|
16
|
+
TASK_DRIVER_MAX_UNCHANGED_TURNS,
|
|
17
|
+
} from "../../src/constants.ts";
|
|
13
18
|
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
14
19
|
import type { GateResult } from "../../src/domain/gate.ts";
|
|
15
20
|
import { formatMetadata } from "./artifact-format.ts";
|
|
16
21
|
import { callService } from "./service-client.ts";
|
|
17
|
-
import {
|
|
22
|
+
import { registerDomainTools } from "./domain-tools.ts";
|
|
18
23
|
import type { TaskGraph } from "../../src/task-service.ts";
|
|
24
|
+
import { TaskDriver, type ActiveTaskMarker } from "./task-driver.ts";
|
|
19
25
|
import { buildTaskWidgetProjection } from "./task-widget.ts";
|
|
20
26
|
|
|
21
27
|
function text(t: string, details: Record<string, unknown> = {}) {
|
|
@@ -130,7 +136,63 @@ class TaskOverlay {
|
|
|
130
136
|
// ---------------------------------------------------------------------------
|
|
131
137
|
|
|
132
138
|
export default async function (pi: ExtensionAPI) {
|
|
133
|
-
|
|
139
|
+
registerDomainTools(pi);
|
|
140
|
+
const taskDriver = new TaskDriver({
|
|
141
|
+
maxTurns: TASK_DRIVER_MAX_TURNS,
|
|
142
|
+
maxUnchangedTurns: TASK_DRIVER_MAX_UNCHANGED_TURNS,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const driveActiveTasks = async (ctx: ExtensionContext): Promise<void> => {
|
|
146
|
+
if (ctx.mode !== "tui" && ctx.mode !== "rpc") return;
|
|
147
|
+
try {
|
|
148
|
+
const active = await callService<Record<string, unknown>, ActiveTaskMarker[]>("tasks.list", {
|
|
149
|
+
status: "active",
|
|
150
|
+
limit: TASK_DRIVER_ACTIVE_LIMIT,
|
|
151
|
+
});
|
|
152
|
+
const decision = taskDriver.evaluate(active, {
|
|
153
|
+
idle: ctx.isIdle(),
|
|
154
|
+
pendingMessages: ctx.hasPendingMessages(),
|
|
155
|
+
});
|
|
156
|
+
if (decision.action === "continue" && decision.prompt) {
|
|
157
|
+
pi.sendMessage({
|
|
158
|
+
customType: "papyrus-task-continuation",
|
|
159
|
+
content: decision.prompt,
|
|
160
|
+
display: false,
|
|
161
|
+
}, { triggerTurn: true, deliverAs: "nextTurn" });
|
|
162
|
+
} else if (decision.action === "pause" && ctx.hasUI) {
|
|
163
|
+
ctx.ui.notify(`Papyrus task driving paused: ${decision.reason}. Use /task-drive on to resume.`, "warning");
|
|
164
|
+
}
|
|
165
|
+
} catch {
|
|
166
|
+
// The daemon may be unavailable during startup, reload, or shutdown.
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
pi.registerCommand("task-drive", {
|
|
171
|
+
description: "Control bounded automatic continuation while active Papyrus Tasks remain",
|
|
172
|
+
handler: async (args, ctx) => {
|
|
173
|
+
const action = args.trim().toLowerCase() || "status";
|
|
174
|
+
if (action === "on") {
|
|
175
|
+
taskDriver.setEnabled(true);
|
|
176
|
+
ctx.ui.notify("Papyrus task driving enabled", "info");
|
|
177
|
+
await driveActiveTasks(ctx);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (action === "off") {
|
|
181
|
+
taskDriver.setEnabled(false);
|
|
182
|
+
ctx.ui.notify("Papyrus task driving disabled", "info");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (action !== "status") {
|
|
186
|
+
ctx.ui.notify("Usage: /task-drive <on|off|status>", "warning");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const status = taskDriver.status();
|
|
190
|
+
ctx.ui.notify(
|
|
191
|
+
`Papyrus task driving: ${status.enabled ? "on" : "off"} · ${status.consecutiveTurns}/${TASK_DRIVER_MAX_TURNS} automatic turns${status.pausedReason ? ` · paused: ${status.pausedReason}` : ""}`,
|
|
192
|
+
"info",
|
|
193
|
+
);
|
|
194
|
+
},
|
|
195
|
+
});
|
|
134
196
|
|
|
135
197
|
// ── Low-level graph-store tools ────────────────────────────────────
|
|
136
198
|
|
|
@@ -140,10 +202,10 @@ export default async function (pi: ExtensionAPI) {
|
|
|
140
202
|
description:
|
|
141
203
|
"Create a graph artifact. KINDS: doc (knowledge — specs, decisions, research), " +
|
|
142
204
|
"task (work — with gates/checklists in extra), rule (governance — when doing X, follow Y; " +
|
|
143
|
-
"active rules inject into the system prompt), skill (
|
|
205
|
+
"active rules inject into the system prompt), skill (parameterized workflow bundle — validated inputs render connected Tasks, Rules, and Docs). " +
|
|
144
206
|
"RULE extra: {condition, action, severity: 'block'|'warn'|'info'}. " +
|
|
145
207
|
"TASK extra: {gates: [{type:'file-exists'|'contains'|'command'|'test', target, expect}], checklist: {'criterion': {proof: [{type:'file'|'symbol'|'code'|'test'|'command'|'artifact'|'url', target, expect}]}}}. " +
|
|
146
|
-
"SKILL extra: {trigger, steps: [...], tools: [...]}. " +
|
|
208
|
+
"Legacy SKILL extra: {trigger, steps: [...], tools: [...]}. Workflow Skill schemas are versioned separately. " +
|
|
147
209
|
"Templates are skills with subtype='artifact-template' and extra {targetKind, defaults, required}; pass template_id to instantiate.",
|
|
148
210
|
parameters: Type.Object({
|
|
149
211
|
kind: Type.Optional(Type.String({ description: "doc | task | rule | skill; optional when template_id supplies targetKind" })),
|
|
@@ -326,6 +388,16 @@ export default async function (pi: ExtensionAPI) {
|
|
|
326
388
|
}
|
|
327
389
|
});
|
|
328
390
|
|
|
391
|
+
// ── Keep driving active work after Pi has exhausted built-in continuations ──
|
|
392
|
+
// agent_settled is intentionally later than agent_end: Pi guarantees that
|
|
393
|
+
// retry, compaction retry, and queued follow-up processing have finished.
|
|
394
|
+
|
|
395
|
+
pi.on("input", (event) => {
|
|
396
|
+
if (event.source !== "extension") taskDriver.onHumanInput();
|
|
397
|
+
});
|
|
398
|
+
pi.on("agent_start", () => { taskDriver.onAgentStart(); });
|
|
399
|
+
pi.on("agent_settled", async (_event, ctx) => { await driveActiveTasks(ctx); });
|
|
400
|
+
|
|
329
401
|
// ── "Are we there yet?" — inject active tasks into every turn ──────
|
|
330
402
|
// The agent sees its open work items every turn. If there are failed
|
|
331
403
|
// tasks, they're explicitly called out — the agent should address them.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
export interface ActiveTaskMarker {
|
|
2
|
+
id: string;
|
|
3
|
+
title: string;
|
|
4
|
+
updated_at: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface TaskDriverOptions {
|
|
8
|
+
maxTurns: number;
|
|
9
|
+
maxUnchangedTurns: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface TaskDriverState {
|
|
13
|
+
enabled: boolean;
|
|
14
|
+
queued: boolean;
|
|
15
|
+
consecutiveTurns: number;
|
|
16
|
+
unchangedTurns: number;
|
|
17
|
+
pausedReason?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface TaskDriverDecision {
|
|
21
|
+
action: "continue" | "wait" | "pause";
|
|
22
|
+
reason: string;
|
|
23
|
+
prompt?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DISPLAYED_TASK_LIMIT = 3;
|
|
27
|
+
const TITLE_LIMIT = 120;
|
|
28
|
+
|
|
29
|
+
function fingerprint(tasks: ActiveTaskMarker[]): string {
|
|
30
|
+
return [...tasks]
|
|
31
|
+
.sort((left, right) => left.id.localeCompare(right.id))
|
|
32
|
+
.map((task) => `${task.id}:${task.updated_at}`)
|
|
33
|
+
.join("|");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function continuationPrompt(tasks: ActiveTaskMarker[]): string {
|
|
37
|
+
const names = tasks.slice(0, DISPLAYED_TASK_LIMIT).map((task) => `- ${task.id}: ${task.title.slice(0, TITLE_LIMIT)}`);
|
|
38
|
+
return [
|
|
39
|
+
"Continue active Papyrus work now; do not hand off merely because the previous Pi run settled.",
|
|
40
|
+
"Reconcile the active Tasks, choose the next concrete action, use tools, run gates before completion, and continue until done, blocked, or the bounded task driver pauses.",
|
|
41
|
+
"Active tasks:",
|
|
42
|
+
...names,
|
|
43
|
+
].join("\n");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class TaskDriver {
|
|
47
|
+
private enabled = true;
|
|
48
|
+
private queued = false;
|
|
49
|
+
private consecutiveTurns = 0;
|
|
50
|
+
private unchangedTurns = 0;
|
|
51
|
+
private lastFingerprint: string | undefined;
|
|
52
|
+
private pausedReason: string | undefined;
|
|
53
|
+
|
|
54
|
+
constructor(private readonly options: TaskDriverOptions) {
|
|
55
|
+
if (!Number.isInteger(options.maxTurns) || options.maxTurns < 1) throw new Error("maxTurns must be a positive integer");
|
|
56
|
+
if (!Number.isInteger(options.maxUnchangedTurns) || options.maxUnchangedTurns < 1) {
|
|
57
|
+
throw new Error("maxUnchangedTurns must be a positive integer");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
evaluate(tasks: ActiveTaskMarker[], context: { idle: boolean; pendingMessages: boolean }): TaskDriverDecision {
|
|
62
|
+
if (!this.enabled) return { action: "wait", reason: "disabled" };
|
|
63
|
+
if (!context.idle) return { action: "wait", reason: "Pi is not settled" };
|
|
64
|
+
if (context.pendingMessages) return { action: "wait", reason: "Pi already has pending messages" };
|
|
65
|
+
if (this.queued) return { action: "wait", reason: "continuation already queued" };
|
|
66
|
+
if (tasks.length === 0) {
|
|
67
|
+
this.resetProgress();
|
|
68
|
+
return { action: "wait", reason: "no active tasks" };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const currentFingerprint = fingerprint(tasks);
|
|
72
|
+
if (currentFingerprint !== this.lastFingerprint) {
|
|
73
|
+
this.lastFingerprint = currentFingerprint;
|
|
74
|
+
this.unchangedTurns = 0;
|
|
75
|
+
this.pausedReason = undefined;
|
|
76
|
+
} else {
|
|
77
|
+
this.unchangedTurns += 1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (this.consecutiveTurns >= this.options.maxTurns) {
|
|
81
|
+
return this.pause(`automatic turn limit reached (${this.options.maxTurns})`);
|
|
82
|
+
}
|
|
83
|
+
if (this.unchangedTurns >= this.options.maxUnchangedTurns) {
|
|
84
|
+
return this.pause(`no task progress after ${this.options.maxUnchangedTurns} automatic turns`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
this.queued = true;
|
|
88
|
+
this.consecutiveTurns += 1;
|
|
89
|
+
return {
|
|
90
|
+
action: "continue",
|
|
91
|
+
reason: "active tasks remain",
|
|
92
|
+
prompt: continuationPrompt(tasks),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
onAgentStart(): void {
|
|
97
|
+
this.queued = false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
onHumanInput(): void {
|
|
101
|
+
this.resetProgress();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
setEnabled(enabled: boolean): void {
|
|
105
|
+
this.enabled = enabled;
|
|
106
|
+
if (enabled) this.resetProgress();
|
|
107
|
+
else this.queued = false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
status(): TaskDriverState {
|
|
111
|
+
return {
|
|
112
|
+
enabled: this.enabled,
|
|
113
|
+
queued: this.queued,
|
|
114
|
+
consecutiveTurns: this.consecutiveTurns,
|
|
115
|
+
unchangedTurns: this.unchangedTurns,
|
|
116
|
+
...(this.pausedReason ? { pausedReason: this.pausedReason } : {}),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private pause(reason: string): TaskDriverDecision {
|
|
121
|
+
this.pausedReason = reason;
|
|
122
|
+
return { action: "pause", reason };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private resetProgress(): void {
|
|
126
|
+
this.queued = false;
|
|
127
|
+
this.consecutiveTurns = 0;
|
|
128
|
+
this.unchangedTurns = 0;
|
|
129
|
+
this.lastFingerprint = undefined;
|
|
130
|
+
this.pausedReason = undefined;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -11,7 +11,7 @@ import { projectTaskGraph, type TaskGraphView } from "../../src/task-graph-view.
|
|
|
11
11
|
import type { TaskGraph } from "../../src/task-service.ts";
|
|
12
12
|
import { BeautifulMermaidRenderer } from "./beautiful-mermaid-renderer.ts";
|
|
13
13
|
|
|
14
|
-
const GRAPH_VIEWS: TaskGraphView[] = ["dependencies", "composition"];
|
|
14
|
+
const GRAPH_VIEWS: TaskGraphView[] = ["execution", "dependencies", "composition"];
|
|
15
15
|
|
|
16
16
|
export class TaskGraphViewport {
|
|
17
17
|
private viewIndex = 0;
|
|
@@ -88,8 +88,8 @@ export async function showTaskGraph(
|
|
|
88
88
|
renderer: GraphRenderer = new BeautifulMermaidRenderer(),
|
|
89
89
|
): Promise<void> {
|
|
90
90
|
if (ctx.mode !== "tui") {
|
|
91
|
-
const rendered = renderer.render(projectTaskGraph(graph, "
|
|
92
|
-
ctx.ui.notify(rendered.lines.join("\n") || "No
|
|
91
|
+
const rendered = renderer.render(projectTaskGraph(graph, "execution"));
|
|
92
|
+
ctx.ui.notify(rendered.lines.join("\n") || "No tasks in the execution graph", "info");
|
|
93
93
|
return;
|
|
94
94
|
}
|
|
95
95
|
await ctx.ui.custom<void>((tui, theme, _keybindings, done) =>
|
package/extension/src/tasks.ts
CHANGED
|
@@ -14,13 +14,17 @@ export { taskDetailsText } from "./task-detail-format.ts";
|
|
|
14
14
|
export { showTaskDetails } from "./task-detail-view.ts";
|
|
15
15
|
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
16
16
|
import type { GateResult } from "../../src/domain/gate.ts";
|
|
17
|
-
import
|
|
17
|
+
import { projectTaskExecution } from "../../src/task-execution.ts";
|
|
18
|
+
import type { TaskCompletion, TaskGraph } from "../../src/task-service.ts";
|
|
18
19
|
|
|
19
20
|
const GLYPHS: Record<string, string> = {
|
|
20
21
|
pending: "○",
|
|
22
|
+
ready: "◇",
|
|
23
|
+
blocked: "○",
|
|
21
24
|
active: "●",
|
|
22
25
|
done: "■",
|
|
23
26
|
failed: "▲",
|
|
27
|
+
invalid: "!",
|
|
24
28
|
};
|
|
25
29
|
|
|
26
30
|
const STATUS_ACTIONS: Record<string, string[]> = {
|
|
@@ -105,10 +109,19 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
105
109
|
try {
|
|
106
110
|
const operation = choice === "Start" ? "tasks.start" : choice === "Fail" ? "tasks.fail" : choice === "Retry" ? "tasks.retry" : "tasks.complete";
|
|
107
111
|
if (operation === "tasks.complete") {
|
|
108
|
-
const result = await callService<Record<string, unknown>,
|
|
112
|
+
const result = await callService<Record<string, unknown>, TaskCompletion>(operation, { id: action.row.id });
|
|
109
113
|
action.row.status = result.artifact.status;
|
|
110
114
|
const gates = result.gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target}`).join("\n");
|
|
111
|
-
|
|
115
|
+
const started = result.started.length > 0 ? `\nStarted: ${result.started.map((task) => task.title).join(", ")}` : "";
|
|
116
|
+
const blocked = result.blocked.length > 0
|
|
117
|
+
? `\nWaiting: ${result.blocked.map((entry) => `${entry.artifact.title} needs ${entry.dependencyIds.join(", ")}`).join("; ")}`
|
|
118
|
+
: "";
|
|
119
|
+
ctx.ui.notify(
|
|
120
|
+
result.completed
|
|
121
|
+
? `Completed ${result.artifact.id}${started}${blocked}${gates ? `\n${gates}` : ""}`
|
|
122
|
+
: `Not complete; gates failed\n${gates}`,
|
|
123
|
+
result.completed ? "info" : "warning",
|
|
124
|
+
);
|
|
112
125
|
} else {
|
|
113
126
|
const updated = await callService<Record<string, unknown>, Artifact>(operation, { id: action.row.id });
|
|
114
127
|
action.row.status = updated.status;
|
|
@@ -133,6 +146,7 @@ function renderPanel(ctx: ExtensionCommandContext, graph: TaskGraph): Promise<Pa
|
|
|
133
146
|
const searchInput = new Input();
|
|
134
147
|
const hierarchy = buildTaskHierarchy(graph);
|
|
135
148
|
const taskById = new Map(rows.map((task) => [task.id, task]));
|
|
149
|
+
const executionById = new Map(projectTaskExecution(graph).nodes.map((node) => [node.id, node]));
|
|
136
150
|
let searchActive = false;
|
|
137
151
|
let filtered = [...hierarchy];
|
|
138
152
|
let selectedIndex = 0;
|
|
@@ -148,10 +162,10 @@ function renderPanel(ctx: ExtensionCommandContext, graph: TaskGraph): Promise<Pa
|
|
|
148
162
|
|
|
149
163
|
function statusLine(): string {
|
|
150
164
|
const counts: Record<string, number> = {};
|
|
151
|
-
for (const
|
|
152
|
-
return ["
|
|
153
|
-
.filter((
|
|
154
|
-
.map((
|
|
165
|
+
for (const node of executionById.values()) counts[node.state] = (counts[node.state] ?? 0) + 1;
|
|
166
|
+
return ["ready", "active", "blocked", "done", "failed", "invalid"]
|
|
167
|
+
.filter((state) => (counts[state] ?? 0) > 0)
|
|
168
|
+
.map((state) => `${GLYPHS[state] ?? state} ${counts[state]} ${state}`)
|
|
155
169
|
.join(", ");
|
|
156
170
|
}
|
|
157
171
|
|
|
@@ -196,14 +210,17 @@ function renderPanel(ctx: ExtensionCommandContext, graph: TaskGraph): Promise<Pa
|
|
|
196
210
|
const row = entry.task;
|
|
197
211
|
const selected = i === selectedIndex;
|
|
198
212
|
const cursor = selected ? theme.fg("accent", "❯") : " ";
|
|
199
|
-
const
|
|
200
|
-
const
|
|
213
|
+
const execution = executionById.get(row.id);
|
|
214
|
+
const state = execution?.state ?? row.status;
|
|
215
|
+
const glyph = GLYPHS[state] ?? "?";
|
|
216
|
+
const statusColor = state === "active" || state === "ready" ? "accent" : state === "done" ? "dim" : state === "failed" || state === "invalid" ? "warning" : "muted";
|
|
201
217
|
const glyphStyled = theme.fg(statusColor, glyph);
|
|
202
218
|
const title = selected ? theme.bold(row.title) : row.title;
|
|
203
219
|
const indent = " ".repeat(entry.depth);
|
|
204
220
|
const node = entry.childCount > 0 ? theme.fg("accent", "▾") : theme.fg("dim", "·");
|
|
205
221
|
const gates = (row.extra?.["gates"] as any[])?.length;
|
|
206
222
|
const relationParts: string[] = [];
|
|
223
|
+
if (execution) relationParts.push(execution.layer === null ? state : `layer ${execution.layer + 1} · ${state}`);
|
|
207
224
|
if (entry.childCount > 0) relationParts.push(`${entry.childCount} subtask${entry.childCount === 1 ? "" : "s"}`);
|
|
208
225
|
if (entry.dependencies.length > 0) {
|
|
209
226
|
const names = entry.dependencies.map((id) => taskById.get(id)?.title ?? id);
|
package/package.json
CHANGED
|
@@ -52,6 +52,12 @@ export class SQLiteArtifactStore implements ArtifactStore {
|
|
|
52
52
|
parameters.push(...filter.artifactIds, ...filter.artifactIds);
|
|
53
53
|
}
|
|
54
54
|
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
55
|
+
let limit = "";
|
|
56
|
+
if (filter.limit !== undefined) {
|
|
57
|
+
if (!Number.isInteger(filter.limit) || filter.limit < 1) throw new Error("relationship limit must be a positive integer");
|
|
58
|
+
limit = "LIMIT ?";
|
|
59
|
+
parameters.push(filter.limit);
|
|
60
|
+
}
|
|
55
61
|
return this.db.prepare(`
|
|
56
62
|
SELECT edges.from_id AS "from", edges.relation, edges.to_id AS "to"
|
|
57
63
|
FROM edges
|
|
@@ -59,6 +65,7 @@ export class SQLiteArtifactStore implements ArtifactStore {
|
|
|
59
65
|
JOIN artifacts AS target ON target.id = edges.to_id
|
|
60
66
|
${where}
|
|
61
67
|
ORDER BY edges.rowid
|
|
68
|
+
${limit}
|
|
62
69
|
`).all(...parameters) as ArtifactEdge[];
|
|
63
70
|
}
|
|
64
71
|
}
|