@danypops/papyrus 0.6.0 → 0.8.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
@@ -32,13 +32,14 @@ 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
34
 
35
- # Authenticated daemon-backed task automation (add --json for machine output)
35
+ # Authenticated daemon-backed Task operations (add --json for machine output)
36
36
  papyrus tasks plan
37
37
  papyrus tasks depend <task-id> <prerequisite-id>
38
- papyrus tasks start <task-id>
38
+ papyrus tasks update <task-id> --title "Revised task"
39
+ papyrus tasks focus <task-id>
40
+ papyrus tasks pause
41
+ papyrus tasks unpause
39
42
  papyrus tasks complete <task-id>
40
- papyrus tasks automate <task-id> <on|off>
41
- papyrus automation status
42
43
  ```
43
44
 
44
45
  For repository work, install the versioned ownership guard once:
@@ -92,7 +93,7 @@ The `papyrus_*` tools are the low-level graph-store API:
92
93
 
93
94
  Agent-facing domain tools own lifecycle invariants and sit above this store API:
94
95
 
95
- - **`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
96
+ - **`tasks`** — create/update/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
96
97
  - **`docs`** — create/list/show, activate/archive/reopen, and document-safe graph links
97
98
  - **`rules`** — create/list/show/preview, enable/disable, and attach governance gates to tasks
98
99
  - **`skills`** — create/list/show/invoke/run, enable/disable, create compatibility templates, and atomically instantiate parameterized workflow runs
@@ -103,7 +104,7 @@ Internally, application services depend on the `ArtifactStore` and `GateRunner`
103
104
 
104
105
  ## Interactive frontends
105
106
 
106
- - `/tasks` — task lifecycle, append-only history, gates, dependencies, and nested metadata
107
+ - `/tasks` — project/focused-graph scope, task lifecycle, append-only history, gates, dependencies, and nested metadata
107
108
  - `/docs` — searchable documents, lifecycle, details, and graph links
108
109
  - `/rules` — severity/condition rows, exact injection preview, enable/disable, and task gating
109
110
  - `/skills` — trigger/tools rows, invocation into the editor, and artifact templates
@@ -114,56 +115,49 @@ All four use daemon-backed domain operations; none opens SQLite from the Pi proc
114
115
 
115
116
  Run `/tasks` for the interactive task panel:
116
117
 
117
- - `/` filters; arrow keys navigate; Enter opens task actions
118
+ - `/` filters; arrow keys navigate; Enter opens task actions; `s` switches among the persisted current-project, focused-root graph, and explicit all-projects views
118
119
  - `g` opens the programmatic Unicode graph; Tab switches dependency/composition views and arrow keys pan
119
120
  - routed graph layouts are bounded to 48 nodes/96 edges; larger graphs use a deterministic, box-drawn line fallback, and renderer failures are contained inside the viewport rather than escaping Pi
120
121
  - advance the `todo → in-progress → review → done` lifecycle; failed review becomes `rejected`, retry returns to `in-progress`, and `canceled` is terminal
121
- - use **active** only as the independent singleton focus that auto-drive continues; focusing a task never changes its lifecycle
122
+ - use **focus** as the independent singleton Task selection that automatic continuation follows; focusing, pausing, or resuming never changes lifecycle
122
123
  - starting nested effort moves todo ancestors to in-progress; submitting enters review; completing review checks both typed checklist proofs and executable gates
123
124
  - passing review marks only that task done and focuses one deterministic ready successor while leaving the successor todo until effort starts
124
125
  - successors are never auto-completed; fan-in, fan-out, diamonds, and disconnected DAGs remain explicit
125
126
  - inspect deterministic execution layers, readiness, a box-drawn nested hierarchy, composition, dependencies, evidence-bearing checklists, and verification gates
126
127
  - 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
127
128
  - Show details keeps Checklist and Validation gates separate from incidental Metadata, renders bounded post-migration lifecycle history with actor/source/reason and gate evidence, then renders relationships as a Unicode box-drawing graph footer; `↑/↓` scrolls and `←/→` pans wide graphs
128
- - the compact persistent widget shows bounded open work in containment order and always retains the active focus
129
+ - the compact persistent widget shows the current scope label plus bounded open work in containment order and always retains active focus when it belongs to that scope
129
130
 
130
131
  Authenticated CLI parity covers the changed lifecycle and focus operations:
131
132
 
132
133
  ```bash
133
134
  papyrus tasks graph --json
135
+ papyrus tasks scope --json
136
+ papyrus tasks scope project --json
137
+ papyrus tasks scope graph <root-id> --json
138
+ papyrus tasks scope all --json
139
+ papyrus tasks assign-project <task-id> [project-root] --json
134
140
  papyrus tasks active --json
135
141
  papyrus tasks history <id> --json
136
142
  papyrus tasks focus <id> --json
143
+ papyrus tasks focused --json
144
+ papyrus tasks pause --json
145
+ papyrus tasks unpause --json
146
+ papyrus tasks clear-focus --json
147
+ papyrus tasks update <id> --title "Revised title" --body "Revised body" --json
137
148
  papyrus tasks start <id> --json
138
149
  papyrus tasks submit <id> --json
139
150
  papyrus tasks complete <id> --json
140
151
  papyrus tasks reject <id> --json
141
152
  papyrus tasks retry <id> --json
142
153
  papyrus tasks cancel <id> --json
143
- papyrus tasks automate <id> <on|off> --json
144
- papyrus automation status --json
145
- papyrus automation run --json
146
154
  ```
147
155
 
148
- ### Opt-in supervised automation
156
+ ### Focus-driven automatic continuation
149
157
 
150
- Background graph reconciliation is off by default and requires two independent opt-ins: daemon configuration and `automation.enabled` on each Task. Only opted-in Tasks already in `review` are eligible for automatic gate/checklist review; Papyrus never skips the review lifecycle. When one completes, directly dependent opted-in successors that become ready may move from `todo` to `in-progress`. Every completion, rejection, and start is written to append-only history with actor `daemon`, source `automation-reconciler`, reason, and bounded gate evidence.
158
+ Automatic continuation is a property of the singleton Task focus, not a per-Task automation flag. An active focus continues at Pi's public `agent_settled` boundary when Pi is idle and has no queued messages. `tasks pause` preserves the focused Task while stopping continuation; `tasks unpause` resumes it; `tasks clear-focus` removes it. Replacing focus selects an existing Task rather than creating or canceling one.
151
159
 
152
- Enable the daemon with a systemd user-service override and restart it:
153
-
154
- ```ini
155
- [Service]
156
- Environment=PAPYRUS_AUTOMATION_ENABLED=1
157
- ```
158
-
159
- ```bash
160
- systemctl --user edit papyrus.service
161
- systemctl --user restart papyrus.service
162
- papyrus tasks automate <task-id> on
163
- papyrus automation status
164
- ```
165
-
166
- Secure defaults are a 60-second interval, 10 Task transitions per sweep, gate concurrency 1, and a 120-second sweep deadline. Optional environment settings are `PAPYRUS_AUTOMATION_INTERVAL_MS` (10 seconds–1 hour), `PAPYRUS_AUTOMATION_MAX_TASKS` (1–100), `PAPYRUS_AUTOMATION_GATE_CONCURRENCY` (1–4), and `PAPYRUS_AUTOMATION_MAX_RUNTIME_MS` (1 ms–10 minutes). Candidate scans are capped at 1,000 review Tasks, sweeps are single-flight, subprocess gates inherit the sweep deadline, result arrays are bounded by the Task limit, and logs contain counts rather than gate output. `papyrus automation run` uses the same policy and refuses to reconcile while global automation is disabled.
160
+ Continuation is single-flight and bounded to 20 automatic turns or 6 unchanged Task snapshots. Reaching either bound persists a paused focus and records the reason in append-only Task history. Human input resumes only these automatically paused focuses; an explicit user pause remains paused.
167
161
 
168
162
  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:
169
163
 
@@ -182,7 +176,7 @@ Proof types are `file`, `symbol`, `code`, `test`, `command`, `artifact`, and `ur
182
176
 
183
177
  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.
184
178
 
185
- 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.
179
+ Task edits mutate the existing Papyrus-owned Task identity and append an `updated` event; title, body, and labels can be revised without canceling the Task or creating a replacement. Lifecycle, relationships, gates, checklist metadata, scope, and focus remain intact.
186
180
 
187
181
  ## Why
188
182
 
@@ -197,10 +191,10 @@ packed install npm:@danypops/papyrus
197
191
  ~/.pi/agent/npm/node_modules/.bin/papyrus service install
198
192
  ```
199
193
 
200
- Existing databases are never migrated on daemon boot. After upgrading to append-only task history, run the authenticated CLI migration explicitly. A v1 database receives the lifecycle prerequisite and history schema in one transaction; existing tasks receive no fabricated events:
194
+ Existing databases are never migrated on daemon boot. After upgrading to focus-driven Task continuation, run the authenticated CLI migration explicitly. Older databases receive prerequisite schemas in one transaction. Existing Tasks are deliberately marked **unscoped**: Papyrus does not guess ownership from titles, labels, historical cwd, or repository names. They remain visible in **All projects** until explicitly assigned with `papyrus tasks assign-project <task-id> [project-root]`:
201
195
 
202
196
  ```bash
203
- ~/.pi/agent/npm/node_modules/.bin/papyrus migrate task-history
197
+ ~/.pi/agent/npm/node_modules/.bin/papyrus migrate task-focus
204
198
  ```
205
199
 
206
200
  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.
@@ -23,6 +23,15 @@ export interface ActiveTaskContinuationDecision {
23
23
  }
24
24
 
25
25
  const TITLE_LIMIT = 120;
26
+ const AUTOMATIC_PAUSE_PREFIX = "automatic continuation paused:";
27
+
28
+ export function automaticPauseReason(reason: string): string {
29
+ return `${AUTOMATIC_PAUSE_PREFIX} ${reason}`;
30
+ }
31
+
32
+ export function shouldResumeFocusOnHumanInput(status: string, pauseReason?: string): boolean {
33
+ return status === "paused" && pauseReason?.startsWith(AUTOMATIC_PAUSE_PREFIX) === true;
34
+ }
26
35
 
27
36
  function fingerprint(task: ActiveTaskMarker): string {
28
37
  return `${task.id}:${task.updated_at}`;
@@ -31,7 +31,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
31
31
  pi.registerTool({
32
32
  name: "tasks",
33
33
  label: "Tasks",
34
- description: "Task domain tool. ACTIONS: create, list, show, history, graph, plan, active, focus, start, submit, complete, reject, retry, cancel, run_gates, set_checklist, set_automation, 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.",
34
+ description: "Task domain tool. ACTIONS: create, update, list, show, history, scope, set_scope, assign_project, graph, plan, active, focused, focus, pause, unpause, clear_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.",
35
35
  parameters: Type.Object({
36
36
  action: Type.String(),
37
37
  id: Type.Optional(Type.String()),
@@ -44,7 +44,6 @@ export function registerDomainTools(pi: ExtensionAPI): void {
44
44
  direction: Type.Optional(Type.Union([Type.Literal("asc"), Type.Literal("desc")])),
45
45
  reason: Type.Optional(Type.String()),
46
46
  session_id: Type.Optional(Type.String()),
47
- enabled: Type.Optional(Type.Boolean()),
48
47
  labels: Type.Optional(Type.Array(Type.String())),
49
48
  extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
50
49
  gates: Type.Optional(Type.Array(Type.Record(Type.String(), Type.Unknown()))),
@@ -54,17 +53,20 @@ export function registerDomainTools(pi: ExtensionAPI): void {
54
53
  child_id: Type.Optional(Type.String()),
55
54
  dependency_id: Type.Optional(Type.String()),
56
55
  depends_on: Type.Optional(Type.Array(Type.String())),
56
+ project_root: Type.Optional(Type.String()),
57
+ scope: Type.Optional(Type.Union([Type.Literal("project"), Type.Literal("graph"), Type.Literal("all")])),
58
+ root_task_id: Type.Optional(Type.String()),
57
59
  }),
58
- async execute(_id, params) {
60
+ async execute(_id, params, _signal, _onUpdate, ctx) {
59
61
  try {
60
62
  const action = params.action;
61
- const request = { ...params, actor: "agent", source: "pi-tool" };
63
+ const request = { ...params, project_root: params.project_root ?? ctx.cwd, actor: "agent", source: "pi-tool" };
62
64
  if (action === "create") {
63
65
  const artifact = await callService<Record<string, unknown>, Artifact>("tasks.create", request);
64
66
  return text(`Created task ${artifactLine(artifact)}`, { artifact });
65
67
  }
66
68
  if (action === "list") {
67
- const rows = await callService<Record<string, unknown>, Artifact[]>("tasks.list", params);
69
+ const rows = await callService<Record<string, unknown>, Artifact[]>("tasks.list", request);
68
70
  return text(rows.length ? rows.map(artifactLine).join("\n") : "No tasks found.", { rows });
69
71
  }
70
72
  if (action === "show") {
@@ -76,18 +78,35 @@ export function registerDomainTools(pi: ExtensionAPI): void {
76
78
  const lines = page.events.map((event) => `${event.occurredAt} ${event.type} ${event.fromStatus ?? "∅"} → ${event.toStatus ?? "∅"} · ${event.actor}/${event.source}${event.reason ? ` · ${event.reason}` : ""}`);
77
79
  return text(lines.join("\n") || "No recorded history for this task.", { page });
78
80
  }
81
+ if (action === "scope") {
82
+ const selection = await callService<Record<string, unknown>, import("../../src/domain/task-scope.ts").TaskViewSelection>("tasks.scope", request);
83
+ return text(`Task scope: ${selection.label}`, { selection });
84
+ }
79
85
  if (action === "active") {
80
- const artifact = await callService<Record<string, unknown>, Artifact | null>("tasks.active", params);
86
+ const artifact = await callService<Record<string, unknown>, Artifact | null>("tasks.active", request);
81
87
  return text(artifact ? `Active: ${artifactLine(artifact)}` : "No active task.", { artifact });
82
88
  }
89
+ if (action === "focused") {
90
+ const focus = await callService<Record<string, unknown>, { artifact: Artifact; status: string } | null>("tasks.focused", request);
91
+ return text(focus ? `Focused (${focus.status}): ${artifactLine(focus.artifact)}` : "No focused task.", { focus });
92
+ }
93
+ if (action === "pause" || action === "unpause") {
94
+ const operation = action === "pause" ? "tasks.pause" : "tasks.unpause";
95
+ const focus = await callService<Record<string, unknown>, { artifact: Artifact; status: string }>(operation, request);
96
+ return text(`Focused (${focus.status}): ${artifactLine(focus.artifact)}`, { focus });
97
+ }
98
+ if (action === "clear_focus") {
99
+ const result = await callService<Record<string, unknown>, { cleared: boolean }>("tasks.clear_focus", request);
100
+ return text(result.cleared ? "Task focus cleared." : "No focused task.", result);
101
+ }
83
102
  if (action === "graph") {
84
- const graph = await callService<Record<string, unknown>, TaskGraph>("tasks.graph", params);
103
+ const graph = await callService<Record<string, unknown>, TaskGraph>("tasks.graph", request);
85
104
  const dependencies = graph.nodes.reduce((count, node) => count + node.dependencyIds.length, 0);
86
105
  const containment = graph.nodes.reduce((count, node) => count + node.childIds.length, 0);
87
106
  return text(`Task graph: ${graph.nodes.length} nodes, ${graph.rootIds.length} roots, ${dependencies} dependencies, ${containment} containment edges.`, { graph });
88
107
  }
89
108
  if (action === "plan") {
90
- const plan = await callService<Record<string, unknown>, TaskExecutionPlan>("tasks.plan", params);
109
+ const plan = await callService<Record<string, unknown>, TaskExecutionPlan>("tasks.plan", request);
91
110
  const byId = new Map(plan.nodes.map((node) => [node.id, node]));
92
111
  const lines = plan.layers.flatMap((layer, index) => [
93
112
  `Layer ${index + 1}`,
@@ -124,7 +143,9 @@ export function registerDomainTools(pi: ExtensionAPI): void {
124
143
  reject: "tasks.reject",
125
144
  retry: "tasks.retry",
126
145
  cancel: "tasks.cancel",
127
- set_automation: "tasks.set_automation",
146
+ update: "tasks.update",
147
+ set_scope: "tasks.set_scope",
148
+ assign_project: "tasks.assign_project",
128
149
  depend: "tasks.depend",
129
150
  contain: "tasks.contain",
130
151
  } as const;
@@ -234,10 +255,12 @@ export function registerDomainTools(pi: ExtensionAPI): void {
234
255
  text: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()), template_id: Type.Optional(Type.String()),
235
256
  target_kind: Type.Optional(Type.String()), defaults: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
236
257
  required: Type.Optional(Type.Array(Type.String())), kind: Type.Optional(Type.String()), subtype: Type.Optional(Type.String()),
258
+ project_root: Type.Optional(Type.String()),
237
259
  }),
238
- async execute(_id, params) {
260
+ async execute(_id, params, _signal, _onUpdate, ctx) {
239
261
  try {
240
262
  const action = params.action;
263
+ const request = { ...params, project_root: params.project_root ?? ctx.cwd };
241
264
  if (action === "create" || action === "create_template") {
242
265
  const operation = action === "create" ? "skills.create" : "skills.create_template";
243
266
  const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
@@ -252,7 +275,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
252
275
  return text(invocation, { invocation });
253
276
  }
254
277
  if (action === "run") {
255
- const run = await callService<Record<string, unknown>, SkillWorkflowRunResult>("skills.run", params);
278
+ const run = await callService<Record<string, unknown>, SkillWorkflowRunResult>("skills.run", request);
256
279
  const execution = run.execution.nodes.map((node) => ` [${node.state}] ${node.id} ${node.title}`).join("\n");
257
280
  return text([
258
281
  `Created Skill run ${run.runId}: ${run.created.tasks.length} tasks, ${run.created.rules.length} rules, ${run.created.docs.length} docs.`,
@@ -265,7 +288,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
265
288
  const operations = { show: "skills.show", enable: "skills.enable", disable: "skills.disable", instantiate: "skills.instantiate" } as const;
266
289
  const operation = operations[action as keyof typeof operations];
267
290
  if (!operation) return text(`Unknown skills action: ${action}`);
268
- const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
291
+ const artifact = await callService<Record<string, unknown>, Artifact>(operation, action === "instantiate" ? request : params);
269
292
  return text(`${artifactLine(artifact)}${action === "show" ? `\n\n${artifact.body}` : ""}`, { artifact });
270
293
  } catch (error) {
271
294
  return text(`skills failed: ${error instanceof Error ? error.message : error}`);
@@ -20,7 +20,7 @@ import { formatMetadata } from "./artifact-format.ts";
20
20
  import { callService } from "./service-client.ts";
21
21
  import { registerDomainTools } from "./domain-tools.ts";
22
22
  import type { TaskGraph, TaskStatus } from "../../src/task-service.ts";
23
- import { ActiveTaskContinuation, type ActiveTaskMarker } from "./active-task-continuation.ts";
23
+ import { ActiveTaskContinuation, automaticPauseReason, shouldResumeFocusOnHumanInput, type ActiveTaskMarker } from "./active-task-continuation.ts";
24
24
  import { buildTaskWidgetProjection, type TaskWidgetProjection } from "./task-widget.ts";
25
25
  import { TASK_STATUS_PRESENTATION, taskTreeConnector } from "./task-presentation.ts";
26
26
 
@@ -36,12 +36,12 @@ const WIDGET_KEY = "pi-papyrus";
36
36
 
37
37
  export function renderTaskWidgetLines(theme: Theme, projection: TaskWidgetProjection, width: number): string[] {
38
38
  if (projection.openTotal === 0) return [];
39
- const lines: string[] = [];
39
+ const lines: string[] = [theme.fg("muted", `Tasks · ${projection.scopeLabel}`)];
40
40
  for (let index = 0; index < projection.rows.length; index++) {
41
41
  const row = projection.rows[index]!;
42
42
  const laterSibling = projection.rows.slice(index + 1).some((candidate) => candidate.depth === row.depth);
43
43
  const hierarchy = taskTreeConnector({ depth: row.depth, hasChildren: row.hasOpenChildren, hasLaterSibling: laterSibling });
44
- const focus = row.active ? theme.fg("accent", "▶") : " ";
44
+ const focus = row.active ? theme.fg("accent", row.focusStatus === "paused" ? "Ⅱ" : "▶") : " ";
45
45
  const presentation = TASK_STATUS_PRESENTATION[row.task.status as TaskStatus];
46
46
  const glyph = presentation ? theme.fg(presentation.color, presentation.glyph) : theme.fg("muted", "?");
47
47
  lines.push(truncateToWidth(`${focus} ${hierarchy} ${glyph} ${row.task.title}`, width, "…"));
@@ -54,6 +54,7 @@ class TaskOverlay {
54
54
  private registered = false;
55
55
  private tui: any | undefined;
56
56
  private snapshot: TaskGraph = { nodes: [], rootIds: [] };
57
+ private projectRoot: string | undefined;
57
58
 
58
59
  setUI(ctx: ExtensionUIContext): void {
59
60
  if (ctx !== this.uiCtx) {
@@ -63,9 +64,12 @@ class TaskOverlay {
63
64
  }
64
65
  }
65
66
 
67
+ setProjectRoot(projectRoot: string): void { this.projectRoot = projectRoot; }
68
+
66
69
  async refresh(): Promise<void> {
70
+ if (!this.projectRoot) return;
67
71
  try {
68
- this.snapshot = await callService<Record<string, unknown>, TaskGraph>("tasks.graph", { limit: 500 });
72
+ this.snapshot = await callService<Record<string, unknown>, TaskGraph>("tasks.graph", { limit: 500, project_root: this.projectRoot });
69
73
  } catch {
70
74
  this.snapshot = { nodes: [], rootIds: [] };
71
75
  }
@@ -116,6 +120,7 @@ class TaskOverlay {
116
120
  this.registered = false;
117
121
  this.tui = undefined;
118
122
  this.uiCtx = undefined;
123
+ this.projectRoot = undefined;
119
124
  }
120
125
  }
121
126
 
@@ -133,7 +138,7 @@ export default async function (pi: ExtensionAPI) {
133
138
  const driveActiveTasks = async (ctx: ExtensionContext): Promise<void> => {
134
139
  if (ctx.mode !== "tui" && ctx.mode !== "rpc") return;
135
140
  try {
136
- const active = await callService<Record<string, never>, ActiveTaskMarker | null>("tasks.active", {});
141
+ const active = await callService<Record<string, unknown>, ActiveTaskMarker | null>("tasks.active", { project_root: ctx.cwd });
137
142
  const decision = taskContinuation.evaluate(active, {
138
143
  idle: ctx.isIdle(),
139
144
  pendingMessages: ctx.hasPendingMessages(),
@@ -144,8 +149,13 @@ export default async function (pi: ExtensionAPI) {
144
149
  content: decision.prompt,
145
150
  display: false,
146
151
  }, { triggerTurn: true, deliverAs: "nextTurn" });
147
- } else if (decision.action === "pause" && ctx.hasUI) {
148
- ctx.ui.notify(`Papyrus task driving paused: ${decision.reason}. Human input or task progress resumes it automatically.`, "warning");
152
+ } else if (decision.action === "pause") {
153
+ await callService("tasks.pause", {
154
+ actor: "system",
155
+ source: "task-continuation",
156
+ reason: automaticPauseReason(decision.reason),
157
+ });
158
+ if (ctx.hasUI) ctx.ui.notify(`Papyrus task driving paused: ${decision.reason}. Human input resumes it automatically.`, "warning");
149
159
  }
150
160
  } catch {
151
161
  // The daemon may be unavailable during startup, reload, or shutdown.
@@ -174,10 +184,14 @@ export default async function (pi: ExtensionAPI) {
174
184
  labels: Type.Optional(Type.Array(Type.String())),
175
185
  extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
176
186
  template_id: Type.Optional(Type.String({ description: "skill/artifact-template id whose defaults and requirements apply" })),
187
+ project_root: Type.Optional(Type.String({ description: "required for Tasks; defaults to Pi cwd" })),
177
188
  }),
178
- async execute(_id, params, _signal, _onUpdate, _ctx) {
189
+ async execute(_id, params, _signal, _onUpdate, ctx) {
179
190
  try {
180
- const a = await callService<Record<string, unknown>, Artifact>("artifact.create", params);
191
+ const a = await callService<Record<string, unknown>, Artifact>("artifact.create", {
192
+ ...params,
193
+ ...(params.kind === "task" ? { project_root: params.project_root ?? ctx.cwd } : {}),
194
+ });
181
195
  return text(`Created ${a.id} [${a.kind}|${a.status}] ${a.title}`, { id: a.id });
182
196
  } catch (e) {
183
197
  return text(`papyrus_create failed: ${e instanceof Error ? e.message : e}`);
@@ -309,6 +323,7 @@ export default async function (pi: ExtensionAPI) {
309
323
  pi.registerCommand("tasks", {
310
324
  description: "Browse and manage Papyrus tasks (interactive)",
311
325
  handler: async (_args, ctx) => {
326
+ overlay?.setProjectRoot(ctx.cwd);
312
327
  await tasksModule.showTasks(ctx);
313
328
  await overlay?.refresh();
314
329
  },
@@ -332,6 +347,7 @@ export default async function (pi: ExtensionAPI) {
332
347
  if (!ctx.hasUI) return;
333
348
  overlay ??= new TaskOverlay();
334
349
  overlay.setUI(ctx.ui);
350
+ overlay.setProjectRoot(ctx.cwd);
335
351
  await overlay.refresh();
336
352
  });
337
353
 
@@ -350,8 +366,17 @@ export default async function (pi: ExtensionAPI) {
350
366
  // agent_settled is intentionally later than agent_end: Pi guarantees that
351
367
  // retry, compaction retry, and queued follow-up processing have finished.
352
368
 
353
- pi.on("input", (event) => {
354
- if (event.source !== "extension") taskContinuation.onHumanInput();
369
+ pi.on("input", async (event) => {
370
+ if (event.source === "extension") return;
371
+ taskContinuation.onHumanInput();
372
+ try {
373
+ const focus = await callService<Record<string, never>, { status: string; pauseReason?: string } | null>("tasks.focused", {});
374
+ if (focus && shouldResumeFocusOnHumanInput(focus.status, focus.pauseReason)) {
375
+ await callService("tasks.unpause", { actor: "system", source: "task-continuation", reason: "human input resumed automatic task continuation" });
376
+ }
377
+ } catch {
378
+ // The daemon may be unavailable during startup, reload, or shutdown.
379
+ }
355
380
  });
356
381
  pi.on("agent_start", () => { taskContinuation.onAgentStart(); });
357
382
  pi.on("agent_settled", async (_event, ctx) => { await driveActiveTasks(ctx); });
@@ -360,11 +385,11 @@ export default async function (pi: ExtensionAPI) {
360
385
  // The agent sees its open work items every turn. If there are rejected
361
386
  // tasks, they're explicitly called out — the agent should address them.
362
387
 
363
- pi.on("before_agent_start", async (event, _ctx) => {
388
+ pi.on("before_agent_start", async (event, ctx) => {
364
389
  try {
365
390
  const [rules, summary] = await Promise.all([
366
- callService<Record<string, unknown>, Array<Pick<Artifact, "title" | "body" | "extra">>>("rules.injectable", {}),
367
- callService<Record<string, unknown>, string | null>("tasks.context", {}),
391
+ callService<Record<string, unknown>, Array<Pick<Artifact, "title" | "body" | "extra">>>("rules.injectable", { project_root: ctx.cwd }),
392
+ callService<Record<string, unknown>, string | null>("tasks.context", { project_root: ctx.cwd }),
368
393
  ]);
369
394
  let prompt = event.systemPrompt ?? "";
370
395
  if (rules.length > 0) {
@@ -93,6 +93,7 @@ export async function showSkills(ctx: ExtensionCommandContext): Promise<void> {
93
93
  const run = await callService<Record<string, unknown>, SkillWorkflowRunResult>("skills.run", {
94
94
  id: skill.id,
95
95
  arguments: arguments_ as Record<string, unknown>,
96
+ project_root: commandCtx.cwd,
96
97
  });
97
98
  commandCtx.ui.notify([
98
99
  `Created ${run.runId} · ${run.created.tasks.length} tasks · ${run.rootTaskIds.length} ready roots`,
@@ -7,12 +7,14 @@ export interface TaskWidgetRow {
7
7
  depth: number;
8
8
  hasOpenChildren: boolean;
9
9
  active: boolean;
10
+ focusStatus?: "active" | "paused";
10
11
  }
11
12
 
12
13
  export interface TaskWidgetProjection {
13
14
  rows: TaskWidgetRow[];
14
15
  openTotal: number;
15
16
  total: number;
17
+ scopeLabel: string;
16
18
  }
17
19
 
18
20
  function isOpen(task: Artifact): boolean {
@@ -34,7 +36,7 @@ export function buildTaskWidgetProjection(
34
36
  if (!node) return;
35
37
  visited.add(id);
36
38
  const open = isOpen(node.task);
37
- if (open) ordered.push({ task: node.task, depth: openDepth, hasOpenChildren: false, active: node.active === true });
39
+ if (open) ordered.push({ task: node.task, depth: openDepth, hasOpenChildren: false, active: node.active === true, focusStatus: node.focusStatus });
38
40
  const childDepth = open ? openDepth + 1 : openDepth;
39
41
  for (const childId of node.childIds) visit(childId, childDepth);
40
42
  };
@@ -52,5 +54,5 @@ export function buildTaskWidgetProjection(
52
54
  rows = [...rows.slice(0, Math.max(0, limit - 1)), active]
53
55
  .sort((left, right) => ordered.indexOf(left) - ordered.indexOf(right));
54
56
  }
55
- return { rows, openTotal: ordered.length, total: graph.nodes.length };
57
+ return { rows, openTotal: ordered.length, total: graph.nodes.length, scopeLabel: graph.scope?.label ?? "All projects" };
56
58
  }
@@ -30,12 +30,6 @@ const STATUS_ACTIONS: Record<string, string[]> = {
30
30
 
31
31
  type TaskRow = Artifact;
32
32
 
33
- function taskAutomationEnabled(task: Artifact): boolean {
34
- const automation = task.extra["automation"];
35
- return typeof automation === "object" && automation !== null && !Array.isArray(automation)
36
- && (automation as Record<string, unknown>)["enabled"] === true;
37
- }
38
-
39
33
  export interface TaskHierarchyRow {
40
34
  task: TaskRow;
41
35
  depth: number;
@@ -62,8 +56,13 @@ export function buildTaskHierarchy(graph: TaskGraph): TaskHierarchyRow[] {
62
56
  return result;
63
57
  }
64
58
 
65
- async function loadTaskGraph(): Promise<TaskGraph> {
66
- return callService<Record<string, unknown>, TaskGraph>("tasks.graph", { limit: 200 });
59
+ async function loadTaskGraph(projectRoot: string, scope?: "project" | "graph" | "all", rootTaskId?: string): Promise<TaskGraph> {
60
+ return callService<Record<string, unknown>, TaskGraph>("tasks.graph", {
61
+ limit: 200,
62
+ project_root: projectRoot,
63
+ ...(scope ? { scope } : {}),
64
+ ...(rootTaskId ? { root_task_id: rootTaskId } : {}),
65
+ });
67
66
  }
68
67
 
69
68
  export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
@@ -71,14 +70,14 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
71
70
  ctx.ui.notify("/tasks requires interactive mode", "warning");
72
71
  return;
73
72
  }
74
- let graph = await loadTaskGraph();
73
+ let graph = await loadTaskGraph(ctx.cwd);
75
74
  if (graph.nodes.length === 0) {
76
75
  const create = await ctx.ui.select("No tasks yet", ["Create a task", "Cancel"]);
77
76
  if (create === "Create a task") {
78
77
  const title = await ctx.ui.input("Task title:", "");
79
78
  if (title) {
80
- await callService("tasks.create", { title, actor: "user", source: "tasks-tui" });
81
- graph = await loadTaskGraph();
79
+ await callService("tasks.create", { title, project_root: ctx.cwd, actor: "user", source: "tasks-tui" });
80
+ graph = await loadTaskGraph(ctx.cwd);
82
81
  }
83
82
  }
84
83
  if (graph.nodes.length === 0) return;
@@ -87,16 +86,34 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
87
86
  for (;;) {
88
87
  const action = await renderPanel(ctx, graph);
89
88
  if (!action) return;
90
- if (action.type === "refresh") { graph = await loadTaskGraph(); continue; }
89
+ if (action.type === "refresh") { graph = await loadTaskGraph(ctx.cwd); continue; }
90
+ if (action.type === "scope") {
91
+ const choice = await ctx.ui.select("Task scope", ["Current project", "Focused graph", "All projects"]);
92
+ if (!choice) continue;
93
+ const scope: "project" | "graph" | "all" = choice === "Current project" ? "project" : choice === "All projects" ? "all" : "graph";
94
+ let rootTaskId: string | undefined;
95
+ if (scope === "graph") {
96
+ const projectGraph = await loadTaskGraph(ctx.cwd, "project");
97
+ const roots = projectGraph.rootIds.map((id) => projectGraph.nodes.find((node) => node.task.id === id)?.task).filter((task): task is Artifact => task !== undefined);
98
+ const selected = await ctx.ui.select("Focused root or epic", roots.map((task) => `${task.title} · ${task.id}`));
99
+ if (!selected) continue;
100
+ rootTaskId = roots.find((task) => `${task.title} · ${task.id}` === selected)?.id;
101
+ if (!rootTaskId) continue;
102
+ }
103
+ await callService("tasks.set_scope", { project_root: ctx.cwd, scope, ...(rootTaskId ? { root_task_id: rootTaskId } : {}) });
104
+ graph = await loadTaskGraph(ctx.cwd);
105
+ continue;
106
+ }
91
107
  if (action.type === "graph") { await showTaskGraph(ctx, graph); continue; }
92
108
  if (action.type !== "action" || !action.row) continue;
93
109
 
94
110
  const active = graph.nodes.find((node) => node.task.id === action.row!.id)?.active === true;
95
- const automationEnabled = taskAutomationEnabled(action.row);
111
+ const focusStatus = graph.nodes.find((node) => node.task.id === action.row!.id)?.focusStatus;
96
112
  const choices = [
97
113
  "Show details",
114
+ "Edit task",
98
115
  ...(!active && action.row.status !== "done" && action.row.status !== "canceled" ? ["Make active"] : []),
99
- ...(action.row.status !== "done" && action.row.status !== "canceled" ? [automationEnabled ? "Disable automation" : "Enable automation"] : []),
116
+ ...(active ? [focusStatus === "paused" ? "Resume focus" : "Pause focus", "Clear focus"] : []),
100
117
  ...(action.row.status === "review" ? ["Run gates"] : []),
101
118
  ...(STATUS_ACTIONS[action.row.status] ?? []),
102
119
  ];
@@ -108,26 +125,39 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
108
125
  if (!art) { ctx.ui.notify("Not found", "error"); continue; }
109
126
  const history = await callService<Record<string, unknown>, TaskHistoryPage>("tasks.history", { id: art.id, direction: "desc" });
110
127
  await showTaskDetails(ctx, art, graph, undefined, [...history.events].reverse());
128
+ } else if (choice === "Edit task") {
129
+ const title = await ctx.ui.input("Task title:", action.row.title);
130
+ if (title === undefined) continue;
131
+ const body = await ctx.ui.input("Task body:", action.row.body);
132
+ if (body === undefined) continue;
133
+ try {
134
+ const updated = await callService<Record<string, unknown>, Artifact>("tasks.update", {
135
+ id: action.row.id,
136
+ title,
137
+ body,
138
+ actor: "user",
139
+ source: "tasks-tui",
140
+ });
141
+ action.row.title = updated.title;
142
+ action.row.body = updated.body;
143
+ ctx.ui.notify(`Updated: ${updated.title}`, "info");
144
+ } catch (error) {
145
+ ctx.ui.notify(`Task update failed: ${error instanceof Error ? error.message : error}`, "error");
146
+ }
111
147
  } else if (choice === "Make active") {
112
148
  try {
113
- await callService<Record<string, unknown>, Artifact>("tasks.focus", { id: action.row.id });
149
+ await callService<Record<string, unknown>, Artifact>("tasks.focus", { id: action.row.id, actor: "user", source: "tasks-tui" });
114
150
  ctx.ui.notify(`Active: ${action.row.title}`, "info");
115
151
  } catch (error) {
116
152
  ctx.ui.notify(`Focus failed: ${error instanceof Error ? error.message : error}`, "error");
117
153
  }
118
- } else if (choice === "Enable automation" || choice === "Disable automation") {
154
+ } else if (choice === "Pause focus" || choice === "Resume focus" || choice === "Clear focus") {
119
155
  try {
120
- const enabled = choice === "Enable automation";
121
- const updated = await callService<Record<string, unknown>, Artifact>("tasks.set_automation", {
122
- id: action.row.id,
123
- enabled,
124
- actor: "user",
125
- source: "tasks-tui",
126
- });
127
- action.row.extra = updated.extra;
128
- ctx.ui.notify(`Automation ${enabled ? "enabled" : "disabled"}: ${action.row.title}`, enabled ? "warning" : "info");
156
+ const operation = choice === "Pause focus" ? "tasks.pause" : choice === "Resume focus" ? "tasks.unpause" : "tasks.clear_focus";
157
+ await callService(operation, { actor: "user", source: "tasks-tui" });
158
+ ctx.ui.notify(choice === "Clear focus" ? "Task focus cleared" : choice, "info");
129
159
  } catch (error) {
130
- ctx.ui.notify(`Automation setting failed: ${error instanceof Error ? error.message : error}`, "error");
160
+ ctx.ui.notify(`Focus action failed: ${error instanceof Error ? error.message : error}`, "error");
131
161
  }
132
162
  } else if (choice === "Run gates") {
133
163
  try {
@@ -173,12 +203,12 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
173
203
  ctx.ui.notify(`Task action failed: ${error instanceof Error ? error.message : error}`, "error");
174
204
  }
175
205
  }
176
- graph = await loadTaskGraph();
206
+ graph = await loadTaskGraph(ctx.cwd);
177
207
  }
178
208
  }
179
209
 
180
210
  interface PanelAction {
181
- type: "action" | "refresh" | "graph";
211
+ type: "action" | "refresh" | "graph" | "scope";
182
212
  row?: TaskRow;
183
213
  }
184
214
 
@@ -218,7 +248,7 @@ function renderPanel(ctx: ExtensionCommandContext, graph: TaskGraph): Promise<Pa
218
248
  const header = {
219
249
  invalidate() {},
220
250
  render(width: number): string[] {
221
- const title = theme.bold("Tasks");
251
+ const title = theme.bold(`Tasks · ${graph.scope?.label ?? "scope unavailable"}`);
222
252
  const hint = searchActive
223
253
  ? rawKeyHint("esc", "clear")
224
254
  : rawKeyHint("↑/↓", "navigate") +
@@ -229,6 +259,8 @@ function renderPanel(ctx: ExtensionCommandContext, graph: TaskGraph): Promise<Pa
229
259
  theme.fg("muted", " · ") +
230
260
  rawKeyHint("g", "graph") +
231
261
  theme.fg("muted", " · ") +
262
+ rawKeyHint("s", "scope") +
263
+ theme.fg("muted", " · ") +
232
264
  rawKeyHint("r", "refresh") +
233
265
  theme.fg("muted", " · ") +
234
266
  rawKeyHint("esc", "close");
@@ -322,6 +354,7 @@ function renderPanel(ctx: ExtensionCommandContext, graph: TaskGraph): Promise<Pa
322
354
  else if (matchesKey(data, "down")) selectedIndex = (selectedIndex + 1) % Math.max(filtered.length, 1);
323
355
  else if (data === "/") searchActive = true;
324
356
  else if (data === "g") { done({ type: "graph" }); return; }
357
+ else if (data === "s") { done({ type: "scope" }); return; }
325
358
  else if (data === "r") { done({ type: "refresh" }); return; }
326
359
  else if (matchesKey(data, "enter")) {
327
360
  const entry = filtered[selectedIndex];