@danypops/papyrus 0.2.0 → 0.3.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
@@ -81,7 +81,7 @@ The `papyrus_*` tools are the low-level graph-store API:
81
81
 
82
82
  Agent-facing domain tools own lifecycle invariants and sit above this store API:
83
83
 
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
84
+ - **`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
85
  - **`docs`** — create/list/show, activate/archive/reopen, and document-safe graph links
86
86
  - **`rules`** — create/list/show/preview, enable/disable, and attach governance gates to tasks
87
87
  - **`skills`** — create/list/show/invoke, enable/disable, create templates, and instantiate templates
@@ -105,12 +105,28 @@ Run `/tasks` for the interactive task panel:
105
105
 
106
106
  - `/` filters; arrow keys navigate; Enter opens task actions
107
107
  - `g` opens the programmatic Unicode graph; Tab switches dependency/composition views and arrow keys pan
108
- - advance the `pendingactive → done` lifecycle or retry `failed pending`
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
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
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
108
+ - advance the `todoin-progressreview → done` lifecycle; failed review becomes `rejected`, retry returns to `in-progress`, and `canceled` is terminal
109
+ - use **active** only as the independent singleton focus that auto-drive continues; focusing a task never changes its lifecycle
110
+ - starting nested effort moves todo ancestors to in-progress; submitting enters review; completing review checks both typed checklist proofs and executable gates
111
+ - passing review marks only that task done and focuses one deterministic ready successor while leaving the successor todo until effort starts
112
+ - successors are never auto-completed; fan-in, fan-out, diamonds, and disconnected DAGs remain explicit
113
+ - inspect deterministic execution layers, readiness, a box-drawn nested hierarchy, composition, dependencies, evidence-bearing checklists, and verification gates
114
+ - 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
115
+ - 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
116
+ - the compact persistent widget shows bounded open work in containment order and always retains the active focus
117
+
118
+ Authenticated CLI parity covers the changed lifecycle and focus operations:
119
+
120
+ ```bash
121
+ papyrus tasks active --json
122
+ papyrus tasks focus <id> --json
123
+ papyrus tasks start <id> --json
124
+ papyrus tasks submit <id> --json
125
+ papyrus tasks complete <id> --json
126
+ papyrus tasks reject <id> --json
127
+ papyrus tasks retry <id> --json
128
+ papyrus tasks cancel <id> --json
129
+ ```
114
130
 
115
131
  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
132
 
@@ -127,9 +143,9 @@ checklist: {
127
143
 
128
144
  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
145
 
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.
146
+ 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
147
 
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.
148
+ 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
149
 
134
150
  ## Why
135
151
 
@@ -140,12 +156,20 @@ Papyrus keeps SQLite’s local simplicity while centralizing writes, migrations,
140
156
  Install the published Pi package, then install its supervised user service:
141
157
 
142
158
  ```bash
143
- pi install npm:@danypops/papyrus
159
+ packed install npm:@danypops/papyrus
144
160
  ~/.pi/agent/npm/node_modules/.bin/papyrus service install
145
161
  ```
146
162
 
163
+ Existing databases are never migrated on daemon boot. After upgrading across the task-lifecycle schema boundary, run the authenticated CLI migration explicitly:
164
+
165
+ ```bash
166
+ ~/.pi/agent/npm/node_modules/.bin/papyrus migrate task-lifecycle
167
+ ```
168
+
169
+ 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.
170
+
147
171
  Reload Pi once the service is active. Git installs remain available for development builds:
148
172
 
149
173
  ```bash
150
- pi install git:github.com/DanyPops/papyrus
174
+ packed install git:github.com/DanyPops/papyrus
151
175
  ```
@@ -4,71 +4,62 @@ export interface ActiveTaskMarker {
4
4
  updated_at: string;
5
5
  }
6
6
 
7
- export interface TaskDriverOptions {
7
+ export interface ActiveTaskContinuationOptions {
8
8
  maxTurns: number;
9
9
  maxUnchangedTurns: number;
10
10
  }
11
11
 
12
- export interface TaskDriverState {
13
- enabled: boolean;
12
+ export interface ActiveTaskContinuationState {
14
13
  queued: boolean;
15
14
  consecutiveTurns: number;
16
15
  unchangedTurns: number;
17
16
  pausedReason?: string;
18
17
  }
19
18
 
20
- export interface TaskDriverDecision {
19
+ export interface ActiveTaskContinuationDecision {
21
20
  action: "continue" | "wait" | "pause";
22
21
  reason: string;
23
22
  prompt?: string;
24
23
  }
25
24
 
26
- const DISPLAYED_TASK_LIMIT = 3;
27
25
  const TITLE_LIMIT = 120;
28
26
 
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("|");
27
+ function fingerprint(task: ActiveTaskMarker): string {
28
+ return `${task.id}:${task.updated_at}`;
34
29
  }
35
30
 
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)}`);
31
+ function continuationPrompt(task: ActiveTaskMarker): string {
38
32
  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,
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)}`,
43
36
  ].join("\n");
44
37
  }
45
38
 
46
- export class TaskDriver {
47
- private enabled = true;
39
+ export class ActiveTaskContinuation {
48
40
  private queued = false;
49
41
  private consecutiveTurns = 0;
50
42
  private unchangedTurns = 0;
51
43
  private lastFingerprint: string | undefined;
52
44
  private pausedReason: string | undefined;
53
45
 
54
- constructor(private readonly options: TaskDriverOptions) {
46
+ constructor(private readonly options: ActiveTaskContinuationOptions) {
55
47
  if (!Number.isInteger(options.maxTurns) || options.maxTurns < 1) throw new Error("maxTurns must be a positive integer");
56
48
  if (!Number.isInteger(options.maxUnchangedTurns) || options.maxUnchangedTurns < 1) {
57
49
  throw new Error("maxUnchangedTurns must be a positive integer");
58
50
  }
59
51
  }
60
52
 
61
- evaluate(tasks: ActiveTaskMarker[], context: { idle: boolean; pendingMessages: boolean }): TaskDriverDecision {
62
- if (!this.enabled) return { action: "wait", reason: "disabled" };
53
+ evaluate(task: ActiveTaskMarker | null, context: { idle: boolean; pendingMessages: boolean }): ActiveTaskContinuationDecision {
63
54
  if (!context.idle) return { action: "wait", reason: "Pi is not settled" };
64
55
  if (context.pendingMessages) return { action: "wait", reason: "Pi already has pending messages" };
65
56
  if (this.queued) return { action: "wait", reason: "continuation already queued" };
66
- if (tasks.length === 0) {
57
+ if (!task) {
67
58
  this.resetProgress();
68
- return { action: "wait", reason: "no active tasks" };
59
+ return { action: "wait", reason: "no active task" };
69
60
  }
70
61
 
71
- const currentFingerprint = fingerprint(tasks);
62
+ const currentFingerprint = fingerprint(task);
72
63
  if (currentFingerprint !== this.lastFingerprint) {
73
64
  this.lastFingerprint = currentFingerprint;
74
65
  this.unchangedTurns = 0;
@@ -88,8 +79,8 @@ export class TaskDriver {
88
79
  this.consecutiveTurns += 1;
89
80
  return {
90
81
  action: "continue",
91
- reason: "active tasks remain",
92
- prompt: continuationPrompt(tasks),
82
+ reason: "an active task remains",
83
+ prompt: continuationPrompt(task),
93
84
  };
94
85
  }
95
86
 
@@ -101,15 +92,8 @@ export class TaskDriver {
101
92
  this.resetProgress();
102
93
  }
103
94
 
104
- setEnabled(enabled: boolean): void {
105
- this.enabled = enabled;
106
- if (enabled) this.resetProgress();
107
- else this.queued = false;
108
- }
109
-
110
- status(): TaskDriverState {
95
+ status(): ActiveTaskContinuationState {
111
96
  return {
112
- enabled: this.enabled,
113
97
  queued: this.queued,
114
98
  consecutiveTurns: this.consecutiveTurns,
115
99
  unchangedTurns: this.unchangedTurns,
@@ -117,7 +101,7 @@ export class TaskDriver {
117
101
  };
118
102
  }
119
103
 
120
- private pause(reason: string): TaskDriverDecision {
104
+ private pause(reason: string): ActiveTaskContinuationDecision {
121
105
  this.pausedReason = reason;
122
106
  return { action: "pause", reason };
123
107
  }
@@ -6,10 +6,12 @@ import {
6
6
  } from "../../src/constants.ts";
7
7
 
8
8
  const STATUS_GLYPHS: Record<string, string> = {
9
- pending: "○",
10
- active: "●",
9
+ todo: "○",
10
+ "in-progress": "●",
11
+ review: "◆",
12
+ rejected: "▲",
11
13
  done: "■",
12
- failed: "",
14
+ canceled: "×",
13
15
  };
14
16
 
15
17
  export interface MetadataFormatOptions {
@@ -29,7 +29,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
29
29
  pi.registerTool({
30
30
  name: "tasks",
31
31
  label: "Tasks",
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.",
32
+ description: "Task domain tool. ACTIONS: create, list, show, plan, active, focus, start, submit, complete, reject, retry, cancel, run_gates, set_checklist, depend, contain. Lifecycle is todo in-progress review → done, with review failure → rejected and retry → in-progress; canceled is terminal. Active focus is independent and identifies the one task auto-drive continues. Completion runs gates and checklist-proof review, then focuses one deterministic ready successor without claiming effort. Dependency cycles are rejected. Prefer this over low-level papyrus_* tools for task work.",
33
33
  parameters: Type.Object({
34
34
  action: Type.String(),
35
35
  id: Type.Optional(Type.String()),
@@ -63,6 +63,10 @@ export function registerDomainTools(pi: ExtensionAPI): void {
63
63
  const artifact = await callService<Record<string, unknown>, Artifact>("tasks.show", params);
64
64
  return text(`${artifactLine(artifact)}\n\n${artifact.body}`, { artifact });
65
65
  }
66
+ if (action === "active") {
67
+ const artifact = await callService<Record<string, unknown>, Artifact | null>("tasks.active", params);
68
+ return text(artifact ? `Active: ${artifactLine(artifact)}` : "No active task.", { artifact });
69
+ }
66
70
  if (action === "plan") {
67
71
  const plan = await callService<Record<string, unknown>, TaskExecutionPlan>("tasks.plan", params);
68
72
  const byId = new Map(plan.nodes.map((node) => [node.id, node]));
@@ -83,17 +87,27 @@ export function registerDomainTools(pi: ExtensionAPI): void {
83
87
  if (action === "complete") {
84
88
  const result = await callService<Record<string, unknown>, TaskCompletion>("tasks.complete", params);
85
89
  const gates = result.gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n");
86
- const started = result.started.length > 0 ? `\nStarted: ${result.started.map(artifactLine).join(", ")}` : "";
90
+ const checklist = result.checklist.map((item) => `${item.accepted ? "✓" : "✗"} proof: ${item.item}${item.reason ? ` — ${item.reason}` : ""}`).join("\n");
91
+ const focused = result.focused ? `\nActive: ${artifactLine(result.focused)}` : "";
87
92
  const blocked = result.blocked.length > 0
88
93
  ? `\nBlocked: ${result.blocked.map((entry) => `${artifactLine(entry.artifact)} waits for ${entry.dependencyIds.join(", ")}`).join("; ")}`
89
94
  : "";
90
- return text(`${result.completed ? "Completed" : "Not completed"}: ${artifactLine(result.artifact)}${started}${blocked}${gates ? `\n${gates}` : ""}`, { ...result });
95
+ return text(`${result.completed ? "Completed" : "Rejected"}: ${artifactLine(result.artifact)}${focused}${blocked}${checklist ? `\n${checklist}` : ""}${gates ? `\n${gates}` : ""}`, { ...result });
91
96
  }
92
97
  if (action === "run_gates") {
93
98
  const gates = await callService<Record<string, unknown>, GateResult[]>("tasks.run_gates", params);
94
99
  return text(gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n") || "No gates configured.", { gates });
95
100
  }
96
- const operations = { start: "tasks.start", fail: "tasks.fail", retry: "tasks.retry", depend: "tasks.depend", contain: "tasks.contain" } as const;
101
+ const operations = {
102
+ focus: "tasks.focus",
103
+ start: "tasks.start",
104
+ submit: "tasks.submit",
105
+ reject: "tasks.reject",
106
+ retry: "tasks.retry",
107
+ cancel: "tasks.cancel",
108
+ depend: "tasks.depend",
109
+ contain: "tasks.contain",
110
+ } as const;
97
111
  const operation = operations[action as keyof typeof operations];
98
112
  if (!operation) return text(`Unknown tasks action: ${action}`);
99
113
  const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
@@ -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";
24
- import { TaskDriver, type ActiveTaskMarker } from "./task-driver.ts";
22
+ import type { TaskGraph, TaskStatus } from "../../src/task-service.ts";
23
+ import { ActiveTaskContinuation, type ActiveTaskMarker } from "./active-task-continuation.ts";
25
24
  import { buildTaskWidgetProjection } 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,13 +32,6 @@ 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
 
44
37
  class TaskOverlay {
@@ -103,22 +96,26 @@ class TaskOverlay {
103
96
  const projection = buildTaskWidgetProjection(this.snapshot);
104
97
  if (projection.total === 0) return [];
105
98
 
106
- if (projection.activeTotal === 0) {
107
- return [truncateToWidth(theme.bold("Tasks · no active tasks · /tasks"), width, "…")];
99
+ if (projection.openTotal === 0) {
100
+ return [truncateToWidth(theme.bold("Tasks · no open tasks · /tasks"), width, "…")];
108
101
  }
109
102
 
103
+ const active = projection.rows.find((row) => row.active);
110
104
  const lines = [
111
105
  truncateToWidth(
112
- theme.bold(`Tasks · ${GLYPHS.active!(theme)} ${projection.activeTotal} active`),
106
+ theme.bold(`Tasks · ${active ? theme.fg("accent", "▶ active") : "no active focus"} · ${projection.openTotal} open`),
113
107
  width,
114
108
  "…",
115
109
  ),
116
110
  ];
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, ""));
111
+ for (let index = 0; index < projection.rows.length; index++) {
112
+ const row = projection.rows[index]!;
113
+ const laterSibling = projection.rows.slice(index + 1).some((candidate) => candidate.depth === row.depth);
114
+ const hierarchy = taskTreeConnector({ depth: row.depth, hasChildren: row.hasOpenChildren, hasLaterSibling: laterSibling });
115
+ const focus = row.active ? theme.fg("accent", "") : " ";
116
+ const presentation = TASK_STATUS_PRESENTATION[row.task.status as TaskStatus];
117
+ const glyph = presentation ? theme.fg(presentation.color, presentation.glyph) : theme.fg("muted", "?");
118
+ lines.push(truncateToWidth(`${focus} ${hierarchy} ${glyph} ${row.task.title}`, width, "…"));
122
119
  }
123
120
  return lines;
124
121
  }
@@ -137,7 +134,7 @@ class TaskOverlay {
137
134
 
138
135
  export default async function (pi: ExtensionAPI) {
139
136
  registerDomainTools(pi);
140
- const taskDriver = new TaskDriver({
137
+ const taskContinuation = new ActiveTaskContinuation({
141
138
  maxTurns: TASK_DRIVER_MAX_TURNS,
142
139
  maxUnchangedTurns: TASK_DRIVER_MAX_UNCHANGED_TURNS,
143
140
  });
@@ -145,11 +142,8 @@ export default async function (pi: ExtensionAPI) {
145
142
  const driveActiveTasks = async (ctx: ExtensionContext): Promise<void> => {
146
143
  if (ctx.mode !== "tui" && ctx.mode !== "rpc") return;
147
144
  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, {
145
+ const active = await callService<Record<string, never>, ActiveTaskMarker | null>("tasks.active", {});
146
+ const decision = taskContinuation.evaluate(active, {
153
147
  idle: ctx.isIdle(),
154
148
  pendingMessages: ctx.hasPendingMessages(),
155
149
  });
@@ -160,40 +154,13 @@ export default async function (pi: ExtensionAPI) {
160
154
  display: false,
161
155
  }, { triggerTurn: true, deliverAs: "nextTurn" });
162
156
  } else if (decision.action === "pause" && ctx.hasUI) {
163
- ctx.ui.notify(`Papyrus task driving paused: ${decision.reason}. Use /task-drive on to resume.`, "warning");
157
+ ctx.ui.notify(`Papyrus task driving paused: ${decision.reason}. Human input or task progress resumes it automatically.`, "warning");
164
158
  }
165
159
  } catch {
166
160
  // The daemon may be unavailable during startup, reload, or shutdown.
167
161
  }
168
162
  };
169
163
 
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
- });
196
-
197
164
  // ── Low-level graph-store tools ────────────────────────────────────
198
165
 
199
166
  pi.registerTool({
@@ -393,13 +360,13 @@ export default async function (pi: ExtensionAPI) {
393
360
  // retry, compaction retry, and queued follow-up processing have finished.
394
361
 
395
362
  pi.on("input", (event) => {
396
- if (event.source !== "extension") taskDriver.onHumanInput();
363
+ if (event.source !== "extension") taskContinuation.onHumanInput();
397
364
  });
398
- pi.on("agent_start", () => { taskDriver.onAgentStart(); });
365
+ pi.on("agent_start", () => { taskContinuation.onAgentStart(); });
399
366
  pi.on("agent_settled", async (_event, ctx) => { await driveActiveTasks(ctx); });
400
367
 
401
368
  // ── "Are we there yet?" — inject active tasks into every turn ──────
402
- // The agent sees its open work items every turn. If there are failed
369
+ // The agent sees its open work items every turn. If there are rejected
403
370
  // tasks, they're explicitly called out — the agent should address them.
404
371
 
405
372
  pi.on("before_agent_start", async (event, _ctx) => {
@@ -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
- pending: "○",
7
- active: "●",
6
+ todo: "○",
7
+ "in-progress": "●",
8
+ review: "◆",
9
+ rejected: "▲",
8
10
  done: "■",
9
- failed: "",
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) => sliceByColumn(line, this.offsetX, contentWidth, true)),
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
  }
@@ -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
+ }
@@ -1,49 +1,56 @@
1
- import { TASK_WIDGET_ACTIVE_LIMIT } from "../../src/constants.ts";
1
+ import { TASK_WIDGET_OPEN_LIMIT } from "../../src/constants.ts";
2
2
  import type { Artifact } from "../../src/domain/artifact.ts";
3
3
  import type { TaskGraph } from "../../src/task-service.ts";
4
4
 
5
5
  export interface TaskWidgetRow {
6
6
  task: Artifact;
7
7
  depth: number;
8
- hasActiveChildren: boolean;
8
+ hasOpenChildren: boolean;
9
+ active: boolean;
9
10
  }
10
11
 
11
12
  export interface TaskWidgetProjection {
12
- active: TaskWidgetRow[];
13
- activeTotal: number;
13
+ rows: TaskWidgetRow[];
14
+ openTotal: number;
14
15
  total: number;
15
16
  }
16
17
 
17
- /** Keep active work in containment order; /tasks owns full graph navigation. */
18
+ function isOpen(task: Artifact): boolean {
19
+ return task.status !== "done" && task.status !== "canceled";
20
+ }
21
+
22
+ /** Keep bounded actionable work in containment order while always retaining active focus. */
18
23
  export function buildTaskWidgetProjection(
19
24
  graph: TaskGraph,
20
- activeLimit = TASK_WIDGET_ACTIVE_LIMIT,
25
+ openLimit = TASK_WIDGET_OPEN_LIMIT,
21
26
  ): TaskWidgetProjection {
22
- const visibleNodes = graph.nodes.filter((node) => node.task.status !== "deleted");
23
- const byId = new Map(visibleNodes.map((node) => [node.task.id, node]));
27
+ const byId = new Map(graph.nodes.map((node) => [node.task.id, node]));
24
28
  const visited = new Set<string>();
25
29
  const ordered: TaskWidgetRow[] = [];
26
30
 
27
- const visit = (id: string, activeDepth: number): void => {
31
+ const visit = (id: string, openDepth: number): void => {
28
32
  if (visited.has(id)) return;
29
33
  const node = byId.get(id);
30
34
  if (!node) return;
31
35
  visited.add(id);
32
- const active = node.task.status === "active";
33
- if (active) ordered.push({ task: node.task, depth: activeDepth, hasActiveChildren: false });
34
- const childDepth = active ? activeDepth + 1 : activeDepth;
36
+ const open = isOpen(node.task);
37
+ if (open) ordered.push({ task: node.task, depth: openDepth, hasOpenChildren: false, active: node.active === true });
38
+ const childDepth = open ? openDepth + 1 : openDepth;
35
39
  for (const childId of node.childIds) visit(childId, childDepth);
36
40
  };
37
41
 
38
42
  for (const rootId of graph.rootIds) visit(rootId, 0);
39
- for (const node of visibleNodes) visit(node.task.id, 0);
43
+ for (const node of graph.nodes) visit(node.task.id, 0);
40
44
  for (let index = 0; index < ordered.length - 1; index++) {
41
- ordered[index]!.hasActiveChildren = ordered[index + 1]!.depth > ordered[index]!.depth;
45
+ ordered[index]!.hasOpenChildren = ordered[index + 1]!.depth > ordered[index]!.depth;
42
46
  }
43
- const active = ordered.slice(0, Math.max(0, activeLimit));
44
- return {
45
- active,
46
- activeTotal: ordered.length,
47
- total: visibleNodes.length,
48
- };
47
+
48
+ const limit = Math.max(0, openLimit);
49
+ let rows = ordered.slice(0, limit);
50
+ const active = ordered.find((row) => row.active);
51
+ if (active && !rows.some((row) => row.task.id === active.task.id) && limit > 0) {
52
+ rows = [...rows.slice(0, Math.max(0, limit - 1)), active]
53
+ .sort((left, right) => ordered.indexOf(left) - ordered.indexOf(right));
54
+ }
55
+ return { rows, openTotal: ordered.length, total: graph.nodes.length };
49
56
  }