@danypops/papyrus 0.5.0 → 0.7.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
@@ -37,6 +37,8 @@ papyrus tasks plan
37
37
  papyrus tasks depend <task-id> <prerequisite-id>
38
38
  papyrus tasks start <task-id>
39
39
  papyrus tasks complete <task-id>
40
+ papyrus tasks automate <task-id> <on|off>
41
+ papyrus automation status
40
42
  ```
41
43
 
42
44
  For repository work, install the versioned ownership guard once:
@@ -101,7 +103,7 @@ Internally, application services depend on the `ArtifactStore` and `GateRunner`
101
103
 
102
104
  ## Interactive frontends
103
105
 
104
- - `/tasks` — task lifecycle, append-only history, gates, dependencies, and nested metadata
106
+ - `/tasks` — project/focused-graph scope, task lifecycle, append-only history, gates, dependencies, and nested metadata
105
107
  - `/docs` — searchable documents, lifecycle, details, and graph links
106
108
  - `/rules` — severity/condition rows, exact injection preview, enable/disable, and task gating
107
109
  - `/skills` — trigger/tools rows, invocation into the editor, and artifact templates
@@ -112,7 +114,7 @@ All four use daemon-backed domain operations; none opens SQLite from the Pi proc
112
114
 
113
115
  Run `/tasks` for the interactive task panel:
114
116
 
115
- - `/` filters; arrow keys navigate; Enter opens task actions
117
+ - `/` filters; arrow keys navigate; Enter opens task actions; `s` switches among the persisted current-project, focused-root graph, and explicit all-projects views
116
118
  - `g` opens the programmatic Unicode graph; Tab switches dependency/composition views and arrow keys pan
117
119
  - routed graph layouts are bounded to 48 nodes/96 edges; larger graphs use a deterministic, box-drawn line fallback, and renderer failures are contained inside the viewport rather than escaping Pi
118
120
  - advance the `todo → in-progress → review → done` lifecycle; failed review becomes `rejected`, retry returns to `in-progress`, and `canceled` is terminal
@@ -123,12 +125,17 @@ Run `/tasks` for the interactive task panel:
123
125
  - inspect deterministic execution layers, readiness, a box-drawn nested hierarchy, composition, dependencies, evidence-bearing checklists, and verification gates
124
126
  - lifecycle colors are semantic and redundant with text/glyphs: To-Do grey, in-progress yellow, review blue, rejected orange, done green, and canceled red; `▶` marks active focus
125
127
  - 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
126
- - the compact persistent widget shows bounded open work in containment order and always retains the active focus
128
+ - 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
127
129
 
128
130
  Authenticated CLI parity covers the changed lifecycle and focus operations:
129
131
 
130
132
  ```bash
131
133
  papyrus tasks graph --json
134
+ papyrus tasks scope --json
135
+ papyrus tasks scope project --json
136
+ papyrus tasks scope graph <root-id> --json
137
+ papyrus tasks scope all --json
138
+ papyrus tasks assign-project <task-id> [project-root] --json
132
139
  papyrus tasks active --json
133
140
  papyrus tasks history <id> --json
134
141
  papyrus tasks focus <id> --json
@@ -138,8 +145,31 @@ papyrus tasks complete <id> --json
138
145
  papyrus tasks reject <id> --json
139
146
  papyrus tasks retry <id> --json
140
147
  papyrus tasks cancel <id> --json
148
+ papyrus tasks automate <id> <on|off> --json
149
+ papyrus automation status --json
150
+ papyrus automation run --json
141
151
  ```
142
152
 
153
+ ### Opt-in supervised automation
154
+
155
+ 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.
156
+
157
+ Enable the daemon with a systemd user-service override and restart it:
158
+
159
+ ```ini
160
+ [Service]
161
+ Environment=PAPYRUS_AUTOMATION_ENABLED=1
162
+ ```
163
+
164
+ ```bash
165
+ systemctl --user edit papyrus.service
166
+ systemctl --user restart papyrus.service
167
+ papyrus tasks automate <task-id> on
168
+ papyrus automation status
169
+ ```
170
+
171
+ 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.
172
+
143
173
  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:
144
174
 
145
175
  ```ts
@@ -172,10 +202,10 @@ packed install npm:@danypops/papyrus
172
202
  ~/.pi/agent/npm/node_modules/.bin/papyrus service install
173
203
  ```
174
204
 
175
- 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:
205
+ Existing databases are never migrated on daemon boot. After upgrading to project-scoped task views, 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]`:
176
206
 
177
207
  ```bash
178
- ~/.pi/agent/npm/node_modules/.bin/papyrus migrate task-history
208
+ ~/.pi/agent/npm/node_modules/.bin/papyrus migrate task-scope
179
209
  ```
180
210
 
181
211
  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.
@@ -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, 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, list, show, history, scope, set_scope, assign_project, 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.",
35
35
  parameters: Type.Object({
36
36
  action: Type.String(),
37
37
  id: Type.Optional(Type.String()),
@@ -44,6 +44,7 @@ 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()),
47
48
  labels: Type.Optional(Type.Array(Type.String())),
48
49
  extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
49
50
  gates: Type.Optional(Type.Array(Type.Record(Type.String(), Type.Unknown()))),
@@ -53,17 +54,20 @@ export function registerDomainTools(pi: ExtensionAPI): void {
53
54
  child_id: Type.Optional(Type.String()),
54
55
  dependency_id: Type.Optional(Type.String()),
55
56
  depends_on: Type.Optional(Type.Array(Type.String())),
57
+ project_root: Type.Optional(Type.String()),
58
+ scope: Type.Optional(Type.Union([Type.Literal("project"), Type.Literal("graph"), Type.Literal("all")])),
59
+ root_task_id: Type.Optional(Type.String()),
56
60
  }),
57
- async execute(_id, params) {
61
+ async execute(_id, params, _signal, _onUpdate, ctx) {
58
62
  try {
59
63
  const action = params.action;
60
- const request = { ...params, actor: "agent", source: "pi-tool" };
64
+ const request = { ...params, project_root: params.project_root ?? ctx.cwd, actor: "agent", source: "pi-tool" };
61
65
  if (action === "create") {
62
66
  const artifact = await callService<Record<string, unknown>, Artifact>("tasks.create", request);
63
67
  return text(`Created task ${artifactLine(artifact)}`, { artifact });
64
68
  }
65
69
  if (action === "list") {
66
- const rows = await callService<Record<string, unknown>, Artifact[]>("tasks.list", params);
70
+ const rows = await callService<Record<string, unknown>, Artifact[]>("tasks.list", request);
67
71
  return text(rows.length ? rows.map(artifactLine).join("\n") : "No tasks found.", { rows });
68
72
  }
69
73
  if (action === "show") {
@@ -75,18 +79,22 @@ export function registerDomainTools(pi: ExtensionAPI): void {
75
79
  const lines = page.events.map((event) => `${event.occurredAt} ${event.type} ${event.fromStatus ?? "∅"} → ${event.toStatus ?? "∅"} · ${event.actor}/${event.source}${event.reason ? ` · ${event.reason}` : ""}`);
76
80
  return text(lines.join("\n") || "No recorded history for this task.", { page });
77
81
  }
82
+ if (action === "scope") {
83
+ const selection = await callService<Record<string, unknown>, import("../../src/domain/task-scope.ts").TaskViewSelection>("tasks.scope", request);
84
+ return text(`Task scope: ${selection.label}`, { selection });
85
+ }
78
86
  if (action === "active") {
79
- const artifact = await callService<Record<string, unknown>, Artifact | null>("tasks.active", params);
87
+ const artifact = await callService<Record<string, unknown>, Artifact | null>("tasks.active", request);
80
88
  return text(artifact ? `Active: ${artifactLine(artifact)}` : "No active task.", { artifact });
81
89
  }
82
90
  if (action === "graph") {
83
- const graph = await callService<Record<string, unknown>, TaskGraph>("tasks.graph", params);
91
+ const graph = await callService<Record<string, unknown>, TaskGraph>("tasks.graph", request);
84
92
  const dependencies = graph.nodes.reduce((count, node) => count + node.dependencyIds.length, 0);
85
93
  const containment = graph.nodes.reduce((count, node) => count + node.childIds.length, 0);
86
94
  return text(`Task graph: ${graph.nodes.length} nodes, ${graph.rootIds.length} roots, ${dependencies} dependencies, ${containment} containment edges.`, { graph });
87
95
  }
88
96
  if (action === "plan") {
89
- const plan = await callService<Record<string, unknown>, TaskExecutionPlan>("tasks.plan", params);
97
+ const plan = await callService<Record<string, unknown>, TaskExecutionPlan>("tasks.plan", request);
90
98
  const byId = new Map(plan.nodes.map((node) => [node.id, node]));
91
99
  const lines = plan.layers.flatMap((layer, index) => [
92
100
  `Layer ${index + 1}`,
@@ -123,6 +131,9 @@ export function registerDomainTools(pi: ExtensionAPI): void {
123
131
  reject: "tasks.reject",
124
132
  retry: "tasks.retry",
125
133
  cancel: "tasks.cancel",
134
+ set_scope: "tasks.set_scope",
135
+ assign_project: "tasks.assign_project",
136
+ set_automation: "tasks.set_automation",
126
137
  depend: "tasks.depend",
127
138
  contain: "tasks.contain",
128
139
  } as const;
@@ -232,10 +243,12 @@ export function registerDomainTools(pi: ExtensionAPI): void {
232
243
  text: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()), template_id: Type.Optional(Type.String()),
233
244
  target_kind: Type.Optional(Type.String()), defaults: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
234
245
  required: Type.Optional(Type.Array(Type.String())), kind: Type.Optional(Type.String()), subtype: Type.Optional(Type.String()),
246
+ project_root: Type.Optional(Type.String()),
235
247
  }),
236
- async execute(_id, params) {
248
+ async execute(_id, params, _signal, _onUpdate, ctx) {
237
249
  try {
238
250
  const action = params.action;
251
+ const request = { ...params, project_root: params.project_root ?? ctx.cwd };
239
252
  if (action === "create" || action === "create_template") {
240
253
  const operation = action === "create" ? "skills.create" : "skills.create_template";
241
254
  const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
@@ -250,7 +263,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
250
263
  return text(invocation, { invocation });
251
264
  }
252
265
  if (action === "run") {
253
- const run = await callService<Record<string, unknown>, SkillWorkflowRunResult>("skills.run", params);
266
+ const run = await callService<Record<string, unknown>, SkillWorkflowRunResult>("skills.run", request);
254
267
  const execution = run.execution.nodes.map((node) => ` [${node.state}] ${node.id} ${node.title}`).join("\n");
255
268
  return text([
256
269
  `Created Skill run ${run.runId}: ${run.created.tasks.length} tasks, ${run.created.rules.length} rules, ${run.created.docs.length} docs.`,
@@ -263,7 +276,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
263
276
  const operations = { show: "skills.show", enable: "skills.enable", disable: "skills.disable", instantiate: "skills.instantiate" } as const;
264
277
  const operation = operations[action as keyof typeof operations];
265
278
  if (!operation) return text(`Unknown skills action: ${action}`);
266
- const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
279
+ const artifact = await callService<Record<string, unknown>, Artifact>(operation, action === "instantiate" ? request : params);
267
280
  return text(`${artifactLine(artifact)}${action === "show" ? `\n\n${artifact.body}` : ""}`, { artifact });
268
281
  } catch (error) {
269
282
  return text(`skills failed: ${error instanceof Error ? error.message : error}`);
@@ -36,7 +36,7 @@ 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);
@@ -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(),
@@ -174,10 +179,14 @@ export default async function (pi: ExtensionAPI) {
174
179
  labels: Type.Optional(Type.Array(Type.String())),
175
180
  extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
176
181
  template_id: Type.Optional(Type.String({ description: "skill/artifact-template id whose defaults and requirements apply" })),
182
+ project_root: Type.Optional(Type.String({ description: "required for Tasks; defaults to Pi cwd" })),
177
183
  }),
178
- async execute(_id, params, _signal, _onUpdate, _ctx) {
184
+ async execute(_id, params, _signal, _onUpdate, ctx) {
179
185
  try {
180
- const a = await callService<Record<string, unknown>, Artifact>("artifact.create", params);
186
+ const a = await callService<Record<string, unknown>, Artifact>("artifact.create", {
187
+ ...params,
188
+ ...(params.kind === "task" ? { project_root: params.project_root ?? ctx.cwd } : {}),
189
+ });
181
190
  return text(`Created ${a.id} [${a.kind}|${a.status}] ${a.title}`, { id: a.id });
182
191
  } catch (e) {
183
192
  return text(`papyrus_create failed: ${e instanceof Error ? e.message : e}`);
@@ -309,6 +318,7 @@ export default async function (pi: ExtensionAPI) {
309
318
  pi.registerCommand("tasks", {
310
319
  description: "Browse and manage Papyrus tasks (interactive)",
311
320
  handler: async (_args, ctx) => {
321
+ overlay?.setProjectRoot(ctx.cwd);
312
322
  await tasksModule.showTasks(ctx);
313
323
  await overlay?.refresh();
314
324
  },
@@ -332,6 +342,7 @@ export default async function (pi: ExtensionAPI) {
332
342
  if (!ctx.hasUI) return;
333
343
  overlay ??= new TaskOverlay();
334
344
  overlay.setUI(ctx.ui);
345
+ overlay.setProjectRoot(ctx.cwd);
335
346
  await overlay.refresh();
336
347
  });
337
348
 
@@ -360,11 +371,11 @@ export default async function (pi: ExtensionAPI) {
360
371
  // The agent sees its open work items every turn. If there are rejected
361
372
  // tasks, they're explicitly called out — the agent should address them.
362
373
 
363
- pi.on("before_agent_start", async (event, _ctx) => {
374
+ pi.on("before_agent_start", async (event, ctx) => {
364
375
  try {
365
376
  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", {}),
377
+ callService<Record<string, unknown>, Array<Pick<Artifact, "title" | "body" | "extra">>>("rules.injectable", { project_root: ctx.cwd }),
378
+ callService<Record<string, unknown>, string | null>("tasks.context", { project_root: ctx.cwd }),
368
379
  ]);
369
380
  let prompt = event.systemPrompt ?? "";
370
381
  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`,
@@ -13,6 +13,7 @@ export interface TaskWidgetProjection {
13
13
  rows: TaskWidgetRow[];
14
14
  openTotal: number;
15
15
  total: number;
16
+ scopeLabel: string;
16
17
  }
17
18
 
18
19
  function isOpen(task: Artifact): boolean {
@@ -52,5 +53,5 @@ export function buildTaskWidgetProjection(
52
53
  rows = [...rows.slice(0, Math.max(0, limit - 1)), active]
53
54
  .sort((left, right) => ordered.indexOf(left) - ordered.indexOf(right));
54
55
  }
55
- return { rows, openTotal: ordered.length, total: graph.nodes.length };
56
+ return { rows, openTotal: ordered.length, total: graph.nodes.length, scopeLabel: graph.scope?.label ?? "All projects" };
56
57
  }
@@ -30,6 +30,12 @@ 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
+
33
39
  export interface TaskHierarchyRow {
34
40
  task: TaskRow;
35
41
  depth: number;
@@ -56,8 +62,13 @@ export function buildTaskHierarchy(graph: TaskGraph): TaskHierarchyRow[] {
56
62
  return result;
57
63
  }
58
64
 
59
- async function loadTaskGraph(): Promise<TaskGraph> {
60
- return callService<Record<string, unknown>, TaskGraph>("tasks.graph", { limit: 200 });
65
+ async function loadTaskGraph(projectRoot: string, scope?: "project" | "graph" | "all", rootTaskId?: string): Promise<TaskGraph> {
66
+ return callService<Record<string, unknown>, TaskGraph>("tasks.graph", {
67
+ limit: 200,
68
+ project_root: projectRoot,
69
+ ...(scope ? { scope } : {}),
70
+ ...(rootTaskId ? { root_task_id: rootTaskId } : {}),
71
+ });
61
72
  }
62
73
 
63
74
  export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
@@ -65,14 +76,14 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
65
76
  ctx.ui.notify("/tasks requires interactive mode", "warning");
66
77
  return;
67
78
  }
68
- let graph = await loadTaskGraph();
79
+ let graph = await loadTaskGraph(ctx.cwd);
69
80
  if (graph.nodes.length === 0) {
70
81
  const create = await ctx.ui.select("No tasks yet", ["Create a task", "Cancel"]);
71
82
  if (create === "Create a task") {
72
83
  const title = await ctx.ui.input("Task title:", "");
73
84
  if (title) {
74
- await callService("tasks.create", { title, actor: "user", source: "tasks-tui" });
75
- graph = await loadTaskGraph();
85
+ await callService("tasks.create", { title, project_root: ctx.cwd, actor: "user", source: "tasks-tui" });
86
+ graph = await loadTaskGraph(ctx.cwd);
76
87
  }
77
88
  }
78
89
  if (graph.nodes.length === 0) return;
@@ -81,14 +92,33 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
81
92
  for (;;) {
82
93
  const action = await renderPanel(ctx, graph);
83
94
  if (!action) return;
84
- if (action.type === "refresh") { graph = await loadTaskGraph(); continue; }
95
+ if (action.type === "refresh") { graph = await loadTaskGraph(ctx.cwd); continue; }
96
+ if (action.type === "scope") {
97
+ const choice = await ctx.ui.select("Task scope", ["Current project", "Focused graph", "All projects"]);
98
+ if (!choice) continue;
99
+ const scope: "project" | "graph" | "all" = choice === "Current project" ? "project" : choice === "All projects" ? "all" : "graph";
100
+ let rootTaskId: string | undefined;
101
+ if (scope === "graph") {
102
+ const projectGraph = await loadTaskGraph(ctx.cwd, "project");
103
+ const roots = projectGraph.rootIds.map((id) => projectGraph.nodes.find((node) => node.task.id === id)?.task).filter((task): task is Artifact => task !== undefined);
104
+ const selected = await ctx.ui.select("Focused root or epic", roots.map((task) => `${task.title} · ${task.id}`));
105
+ if (!selected) continue;
106
+ rootTaskId = roots.find((task) => `${task.title} · ${task.id}` === selected)?.id;
107
+ if (!rootTaskId) continue;
108
+ }
109
+ await callService("tasks.set_scope", { project_root: ctx.cwd, scope, ...(rootTaskId ? { root_task_id: rootTaskId } : {}) });
110
+ graph = await loadTaskGraph(ctx.cwd);
111
+ continue;
112
+ }
85
113
  if (action.type === "graph") { await showTaskGraph(ctx, graph); continue; }
86
114
  if (action.type !== "action" || !action.row) continue;
87
115
 
88
116
  const active = graph.nodes.find((node) => node.task.id === action.row!.id)?.active === true;
117
+ const automationEnabled = taskAutomationEnabled(action.row);
89
118
  const choices = [
90
119
  "Show details",
91
120
  ...(!active && action.row.status !== "done" && action.row.status !== "canceled" ? ["Make active"] : []),
121
+ ...(action.row.status !== "done" && action.row.status !== "canceled" ? [automationEnabled ? "Disable automation" : "Enable automation"] : []),
92
122
  ...(action.row.status === "review" ? ["Run gates"] : []),
93
123
  ...(STATUS_ACTIONS[action.row.status] ?? []),
94
124
  ];
@@ -107,6 +137,20 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
107
137
  } catch (error) {
108
138
  ctx.ui.notify(`Focus failed: ${error instanceof Error ? error.message : error}`, "error");
109
139
  }
140
+ } else if (choice === "Enable automation" || choice === "Disable automation") {
141
+ try {
142
+ const enabled = choice === "Enable automation";
143
+ const updated = await callService<Record<string, unknown>, Artifact>("tasks.set_automation", {
144
+ id: action.row.id,
145
+ enabled,
146
+ actor: "user",
147
+ source: "tasks-tui",
148
+ });
149
+ action.row.extra = updated.extra;
150
+ ctx.ui.notify(`Automation ${enabled ? "enabled" : "disabled"}: ${action.row.title}`, enabled ? "warning" : "info");
151
+ } catch (error) {
152
+ ctx.ui.notify(`Automation setting failed: ${error instanceof Error ? error.message : error}`, "error");
153
+ }
110
154
  } else if (choice === "Run gates") {
111
155
  try {
112
156
  const results = await callService<Record<string, unknown>, GateResult[]>("tasks.run_gates", { id: action.row.id, actor: "user", source: "tasks-tui" });
@@ -151,12 +195,12 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
151
195
  ctx.ui.notify(`Task action failed: ${error instanceof Error ? error.message : error}`, "error");
152
196
  }
153
197
  }
154
- graph = await loadTaskGraph();
198
+ graph = await loadTaskGraph(ctx.cwd);
155
199
  }
156
200
  }
157
201
 
158
202
  interface PanelAction {
159
- type: "action" | "refresh" | "graph";
203
+ type: "action" | "refresh" | "graph" | "scope";
160
204
  row?: TaskRow;
161
205
  }
162
206
 
@@ -196,7 +240,7 @@ function renderPanel(ctx: ExtensionCommandContext, graph: TaskGraph): Promise<Pa
196
240
  const header = {
197
241
  invalidate() {},
198
242
  render(width: number): string[] {
199
- const title = theme.bold("Tasks");
243
+ const title = theme.bold(`Tasks · ${graph.scope?.label ?? "scope unavailable"}`);
200
244
  const hint = searchActive
201
245
  ? rawKeyHint("esc", "clear")
202
246
  : rawKeyHint("↑/↓", "navigate") +
@@ -207,6 +251,8 @@ function renderPanel(ctx: ExtensionCommandContext, graph: TaskGraph): Promise<Pa
207
251
  theme.fg("muted", " · ") +
208
252
  rawKeyHint("g", "graph") +
209
253
  theme.fg("muted", " · ") +
254
+ rawKeyHint("s", "scope") +
255
+ theme.fg("muted", " · ") +
210
256
  rawKeyHint("r", "refresh") +
211
257
  theme.fg("muted", " · ") +
212
258
  rawKeyHint("esc", "close");
@@ -300,6 +346,7 @@ function renderPanel(ctx: ExtensionCommandContext, graph: TaskGraph): Promise<Pa
300
346
  else if (matchesKey(data, "down")) selectedIndex = (selectedIndex + 1) % Math.max(filtered.length, 1);
301
347
  else if (data === "/") searchActive = true;
302
348
  else if (data === "g") { done({ type: "graph" }); return; }
349
+ else if (data === "s") { done({ type: "scope" }); return; }
303
350
  else if (data === "r") { done({ type: "refresh" }); return; }
304
351
  else if (matchesKey(data, "enter")) {
305
352
  const entry = filtered[selectedIndex];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.5.0",
3
+ "version": "0.7.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"],
@@ -1,5 +1,5 @@
1
1
  import type { Db } from "../db.ts";
2
- import type { GateResult } from "../domain/gate.ts";
2
+ import type { GateResult, GateRunOptions } from "../domain/gate.ts";
3
3
  import type { GateRunner } from "../ports/gate-runner.ts";
4
4
  import { runGates, runGatesAsync } from "../ops.ts";
5
5
 
@@ -10,7 +10,7 @@ export class SQLiteGateRunner implements GateRunner {
10
10
  return runGates(this.db, artifactId);
11
11
  }
12
12
 
13
- runAsync(artifactId: string): Promise<GateResult[]> {
14
- return runGatesAsync(this.db, artifactId);
13
+ runAsync(artifactId: string, options?: GateRunOptions): Promise<GateResult[]> {
14
+ return runGatesAsync(this.db, artifactId, options);
15
15
  }
16
16
  }
@@ -0,0 +1,59 @@
1
+ import type { Db } from "../db.ts";
2
+ import { inTransaction } from "../db.ts";
3
+ import type { TaskProjectScope, TaskScopeSource, TaskViewMode, TaskViewPreference } from "../domain/task-scope.ts";
4
+ import type { TaskScopeStore } from "../ports/task-scope-store.ts";
5
+
6
+ export class SQLiteTaskScopeStore implements TaskScopeStore {
7
+ constructor(private readonly db: Db) {}
8
+
9
+ assign(taskId: string, projectRoot: string | undefined, source: TaskScopeSource): TaskProjectScope {
10
+ inTransaction(this.db, () => {
11
+ this.db.prepare(`
12
+ INSERT INTO task_scopes (task_id, project_root, source, assigned_at)
13
+ VALUES (?, ?, ?, ?)
14
+ ON CONFLICT(task_id) DO UPDATE SET
15
+ project_root = excluded.project_root,
16
+ source = excluded.source,
17
+ assigned_at = excluded.assigned_at
18
+ `).run(taskId, projectRoot ?? null, source, new Date().toISOString());
19
+ });
20
+ return { taskId, ...(projectRoot === undefined ? {} : { projectRoot }), source };
21
+ }
22
+
23
+ get(taskId: string): TaskProjectScope | undefined {
24
+ const row = this.db.prepare("SELECT task_id, project_root, source FROM task_scopes WHERE task_id = ?").get(taskId) as
25
+ | { task_id: string; project_root: string | null; source: TaskScopeSource }
26
+ | null;
27
+ return row ? { taskId: row.task_id, ...(row.project_root === null ? {} : { projectRoot: row.project_root }), source: row.source } : undefined;
28
+ }
29
+
30
+ taskIds(projectRoot: string | undefined, limit: number): string[] {
31
+ const rows = projectRoot === undefined
32
+ ? this.db.prepare("SELECT task_id FROM task_scopes WHERE project_root IS NULL ORDER BY task_id LIMIT ?").all(limit)
33
+ : this.db.prepare("SELECT task_id FROM task_scopes WHERE project_root = ? ORDER BY task_id LIMIT ?").all(projectRoot, limit);
34
+ return (rows as Array<{ task_id: string }>).map((row) => row.task_id);
35
+ }
36
+
37
+ view(projectRoot: string): TaskViewPreference {
38
+ const row = this.db.prepare("SELECT project_root, mode, root_task_id FROM task_views WHERE project_root = ?").get(projectRoot) as
39
+ | { project_root: string; mode: TaskViewMode; root_task_id: string | null }
40
+ | null;
41
+ return row
42
+ ? { projectRoot: row.project_root, mode: row.mode, ...(row.root_task_id === null ? {} : { rootTaskId: row.root_task_id }) }
43
+ : { projectRoot, mode: "project" };
44
+ }
45
+
46
+ setView(projectRoot: string, mode: TaskViewMode, rootTaskId?: string): TaskViewPreference {
47
+ inTransaction(this.db, () => {
48
+ this.db.prepare(`
49
+ INSERT INTO task_views (project_root, mode, root_task_id, updated_at)
50
+ VALUES (?, ?, ?, ?)
51
+ ON CONFLICT(project_root) DO UPDATE SET
52
+ mode = excluded.mode,
53
+ root_task_id = excluded.root_task_id,
54
+ updated_at = excluded.updated_at
55
+ `).run(projectRoot, mode, rootTaskId ?? null, new Date().toISOString());
56
+ });
57
+ return { projectRoot, mode, ...(rootTaskId === undefined ? {} : { rootTaskId }) };
58
+ }
59
+ }