@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # Papyrus
2
2
 
3
- Graph artifact service for Pi — enforced SQLite schema, domain facades, and native interactive frontends.
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 facades
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 facade tools.
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` — reusable procedural knowledge
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
- ### Artifact templates
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
- Templates remain inside the four-kind model: create a `skill` with subtype `artifact-template` and 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.
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 facade tools own domain lifecycle invariants and sit above this store API:
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
- - inspect a nested task hierarchy, composition, dependencies, evidence-bearing checklists, and verification gates
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 registerFacadeTools(pi: ExtensionAPI): void {
28
+ export function registerDomainTools(pi: ExtensionAPI): void {
27
29
  pi.registerTool({
28
30
  name: "tasks",
29
31
  label: "Tasks",
30
- description: "Task domain facade. ACTIONS: create, list, show, start, complete (runs gates and refuses done on failure), fail, retry, run_gates, set_checklist, depend, contain. 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.",
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>, { artifact: Artifact; gates: GateResult[]; completed: boolean }>("tasks.complete", params);
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
- return text(`${result.completed ? "Completed" : "Not completed"}: ${artifactLine(result.artifact)}${gates ? `\n${gates}` : ""}`, { ...result });
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 facade. ACTIONS: create, list, show, activate, archive, reopen, link. Prefer this over low-level papyrus_* tools for document work.",
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 facade. ACTIONS: create, list, show, preview, enable, disable, gate. Active rules inject into the agent system prompt.",
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 artifact-template domain facade. ACTIONS: create, create_template, list, show, invoke, enable, disable, instantiate.",
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())),
@@ -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 { registerFacadeTools } from "./facade-tools.ts";
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
- registerFacadeTools(pi);
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 (proceduralwhen using X do A,B,C). " +
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, "dependencies"));
92
- ctx.ui.notify(rendered.lines.join("\n") || "No task dependency relationships", "info");
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) =>
@@ -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 type { TaskGraph } from "../../src/task-service.ts";
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>, { artifact: Artifact; gates: GateResult[]; completed: boolean }>(operation, { id: action.row.id });
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
- ctx.ui.notify(result.completed ? `Completed ${result.artifact.id}${gates ? `\n${gates}` : ""}` : `Not complete; gates failed\n${gates}`, result.completed ? "info" : "warning");
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 r of rows) counts[r.status] = (counts[r.status] ?? 0) + 1;
152
- return ["pending", "active", "done", "failed"]
153
- .filter((s) => (counts[s] ?? 0) > 0)
154
- .map((s) => `${GLYPHS[s] ?? s} ${counts[s]} ${s}`)
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 glyph = GLYPHS[row.status] ?? "?";
200
- const statusColor = row.status === "active" ? "accent" : row.status === "done" ? "dim" : row.status === "failed" ? "warning" : "muted";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],
@@ -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
  }