@danypops/papyrus 0.6.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
@@ -103,7 +103,7 @@ Internally, application services depend on the `ArtifactStore` and `GateRunner`
103
103
 
104
104
  ## Interactive frontends
105
105
 
106
- - `/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
107
107
  - `/docs` — searchable documents, lifecycle, details, and graph links
108
108
  - `/rules` — severity/condition rows, exact injection preview, enable/disable, and task gating
109
109
  - `/skills` — trigger/tools rows, invocation into the editor, and artifact templates
@@ -114,7 +114,7 @@ All four use daemon-backed domain operations; none opens SQLite from the Pi proc
114
114
 
115
115
  Run `/tasks` for the interactive task panel:
116
116
 
117
- - `/` 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
118
118
  - `g` opens the programmatic Unicode graph; Tab switches dependency/composition views and arrow keys pan
119
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
120
120
  - advance the `todo → in-progress → review → done` lifecycle; failed review becomes `rejected`, retry returns to `in-progress`, and `canceled` is terminal
@@ -125,12 +125,17 @@ Run `/tasks` for the interactive task panel:
125
125
  - inspect deterministic execution layers, readiness, a box-drawn nested hierarchy, composition, dependencies, evidence-bearing checklists, and verification gates
126
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
127
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
128
- - 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
129
129
 
130
130
  Authenticated CLI parity covers the changed lifecycle and focus operations:
131
131
 
132
132
  ```bash
133
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
134
139
  papyrus tasks active --json
135
140
  papyrus tasks history <id> --json
136
141
  papyrus tasks focus <id> --json
@@ -197,10 +202,10 @@ packed install npm:@danypops/papyrus
197
202
  ~/.pi/agent/npm/node_modules/.bin/papyrus service install
198
203
  ```
199
204
 
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:
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]`:
201
206
 
202
207
  ```bash
203
- ~/.pi/agent/npm/node_modules/.bin/papyrus migrate task-history
208
+ ~/.pi/agent/npm/node_modules/.bin/papyrus migrate task-scope
204
209
  ```
205
210
 
206
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, 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, 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()),
@@ -54,17 +54,20 @@ export function registerDomainTools(pi: ExtensionAPI): void {
54
54
  child_id: Type.Optional(Type.String()),
55
55
  dependency_id: Type.Optional(Type.String()),
56
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()),
57
60
  }),
58
- async execute(_id, params) {
61
+ async execute(_id, params, _signal, _onUpdate, ctx) {
59
62
  try {
60
63
  const action = params.action;
61
- const request = { ...params, actor: "agent", source: "pi-tool" };
64
+ const request = { ...params, project_root: params.project_root ?? ctx.cwd, actor: "agent", source: "pi-tool" };
62
65
  if (action === "create") {
63
66
  const artifact = await callService<Record<string, unknown>, Artifact>("tasks.create", request);
64
67
  return text(`Created task ${artifactLine(artifact)}`, { artifact });
65
68
  }
66
69
  if (action === "list") {
67
- const rows = await callService<Record<string, unknown>, Artifact[]>("tasks.list", params);
70
+ const rows = await callService<Record<string, unknown>, Artifact[]>("tasks.list", request);
68
71
  return text(rows.length ? rows.map(artifactLine).join("\n") : "No tasks found.", { rows });
69
72
  }
70
73
  if (action === "show") {
@@ -76,18 +79,22 @@ export function registerDomainTools(pi: ExtensionAPI): void {
76
79
  const lines = page.events.map((event) => `${event.occurredAt} ${event.type} ${event.fromStatus ?? "∅"} → ${event.toStatus ?? "∅"} · ${event.actor}/${event.source}${event.reason ? ` · ${event.reason}` : ""}`);
77
80
  return text(lines.join("\n") || "No recorded history for this task.", { page });
78
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
+ }
79
86
  if (action === "active") {
80
- 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);
81
88
  return text(artifact ? `Active: ${artifactLine(artifact)}` : "No active task.", { artifact });
82
89
  }
83
90
  if (action === "graph") {
84
- const graph = await callService<Record<string, unknown>, TaskGraph>("tasks.graph", params);
91
+ const graph = await callService<Record<string, unknown>, TaskGraph>("tasks.graph", request);
85
92
  const dependencies = graph.nodes.reduce((count, node) => count + node.dependencyIds.length, 0);
86
93
  const containment = graph.nodes.reduce((count, node) => count + node.childIds.length, 0);
87
94
  return text(`Task graph: ${graph.nodes.length} nodes, ${graph.rootIds.length} roots, ${dependencies} dependencies, ${containment} containment edges.`, { graph });
88
95
  }
89
96
  if (action === "plan") {
90
- const plan = await callService<Record<string, unknown>, TaskExecutionPlan>("tasks.plan", params);
97
+ const plan = await callService<Record<string, unknown>, TaskExecutionPlan>("tasks.plan", request);
91
98
  const byId = new Map(plan.nodes.map((node) => [node.id, node]));
92
99
  const lines = plan.layers.flatMap((layer, index) => [
93
100
  `Layer ${index + 1}`,
@@ -124,6 +131,8 @@ export function registerDomainTools(pi: ExtensionAPI): void {
124
131
  reject: "tasks.reject",
125
132
  retry: "tasks.retry",
126
133
  cancel: "tasks.cancel",
134
+ set_scope: "tasks.set_scope",
135
+ assign_project: "tasks.assign_project",
127
136
  set_automation: "tasks.set_automation",
128
137
  depend: "tasks.depend",
129
138
  contain: "tasks.contain",
@@ -234,10 +243,12 @@ export function registerDomainTools(pi: ExtensionAPI): void {
234
243
  text: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()), template_id: Type.Optional(Type.String()),
235
244
  target_kind: Type.Optional(Type.String()), defaults: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
236
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()),
237
247
  }),
238
- async execute(_id, params) {
248
+ async execute(_id, params, _signal, _onUpdate, ctx) {
239
249
  try {
240
250
  const action = params.action;
251
+ const request = { ...params, project_root: params.project_root ?? ctx.cwd };
241
252
  if (action === "create" || action === "create_template") {
242
253
  const operation = action === "create" ? "skills.create" : "skills.create_template";
243
254
  const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
@@ -252,7 +263,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
252
263
  return text(invocation, { invocation });
253
264
  }
254
265
  if (action === "run") {
255
- const run = await callService<Record<string, unknown>, SkillWorkflowRunResult>("skills.run", params);
266
+ const run = await callService<Record<string, unknown>, SkillWorkflowRunResult>("skills.run", request);
256
267
  const execution = run.execution.nodes.map((node) => ` [${node.state}] ${node.id} ${node.title}`).join("\n");
257
268
  return text([
258
269
  `Created Skill run ${run.runId}: ${run.created.tasks.length} tasks, ${run.created.rules.length} rules, ${run.created.docs.length} docs.`,
@@ -265,7 +276,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
265
276
  const operations = { show: "skills.show", enable: "skills.enable", disable: "skills.disable", instantiate: "skills.instantiate" } as const;
266
277
  const operation = operations[action as keyof typeof operations];
267
278
  if (!operation) return text(`Unknown skills action: ${action}`);
268
- const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
279
+ const artifact = await callService<Record<string, unknown>, Artifact>(operation, action === "instantiate" ? request : params);
269
280
  return text(`${artifactLine(artifact)}${action === "show" ? `\n\n${artifact.body}` : ""}`, { artifact });
270
281
  } catch (error) {
271
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
  }
@@ -62,8 +62,13 @@ export function buildTaskHierarchy(graph: TaskGraph): TaskHierarchyRow[] {
62
62
  return result;
63
63
  }
64
64
 
65
- async function loadTaskGraph(): Promise<TaskGraph> {
66
- 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
+ });
67
72
  }
68
73
 
69
74
  export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
@@ -71,14 +76,14 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
71
76
  ctx.ui.notify("/tasks requires interactive mode", "warning");
72
77
  return;
73
78
  }
74
- let graph = await loadTaskGraph();
79
+ let graph = await loadTaskGraph(ctx.cwd);
75
80
  if (graph.nodes.length === 0) {
76
81
  const create = await ctx.ui.select("No tasks yet", ["Create a task", "Cancel"]);
77
82
  if (create === "Create a task") {
78
83
  const title = await ctx.ui.input("Task title:", "");
79
84
  if (title) {
80
- await callService("tasks.create", { title, actor: "user", source: "tasks-tui" });
81
- graph = await loadTaskGraph();
85
+ await callService("tasks.create", { title, project_root: ctx.cwd, actor: "user", source: "tasks-tui" });
86
+ graph = await loadTaskGraph(ctx.cwd);
82
87
  }
83
88
  }
84
89
  if (graph.nodes.length === 0) return;
@@ -87,7 +92,24 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
87
92
  for (;;) {
88
93
  const action = await renderPanel(ctx, graph);
89
94
  if (!action) return;
90
- 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
+ }
91
113
  if (action.type === "graph") { await showTaskGraph(ctx, graph); continue; }
92
114
  if (action.type !== "action" || !action.row) continue;
93
115
 
@@ -173,12 +195,12 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
173
195
  ctx.ui.notify(`Task action failed: ${error instanceof Error ? error.message : error}`, "error");
174
196
  }
175
197
  }
176
- graph = await loadTaskGraph();
198
+ graph = await loadTaskGraph(ctx.cwd);
177
199
  }
178
200
  }
179
201
 
180
202
  interface PanelAction {
181
- type: "action" | "refresh" | "graph";
203
+ type: "action" | "refresh" | "graph" | "scope";
182
204
  row?: TaskRow;
183
205
  }
184
206
 
@@ -218,7 +240,7 @@ function renderPanel(ctx: ExtensionCommandContext, graph: TaskGraph): Promise<Pa
218
240
  const header = {
219
241
  invalidate() {},
220
242
  render(width: number): string[] {
221
- const title = theme.bold("Tasks");
243
+ const title = theme.bold(`Tasks · ${graph.scope?.label ?? "scope unavailable"}`);
222
244
  const hint = searchActive
223
245
  ? rawKeyHint("esc", "clear")
224
246
  : rawKeyHint("↑/↓", "navigate") +
@@ -229,6 +251,8 @@ function renderPanel(ctx: ExtensionCommandContext, graph: TaskGraph): Promise<Pa
229
251
  theme.fg("muted", " · ") +
230
252
  rawKeyHint("g", "graph") +
231
253
  theme.fg("muted", " · ") +
254
+ rawKeyHint("s", "scope") +
255
+ theme.fg("muted", " · ") +
232
256
  rawKeyHint("r", "refresh") +
233
257
  theme.fg("muted", " · ") +
234
258
  rawKeyHint("esc", "close");
@@ -322,6 +346,7 @@ function renderPanel(ctx: ExtensionCommandContext, graph: TaskGraph): Promise<Pa
322
346
  else if (matchesKey(data, "down")) selectedIndex = (selectedIndex + 1) % Math.max(filtered.length, 1);
323
347
  else if (data === "/") searchActive = true;
324
348
  else if (data === "g") { done({ type: "graph" }); return; }
349
+ else if (data === "s") { done({ type: "scope" }); return; }
325
350
  else if (data === "r") { done({ type: "refresh" }); return; }
326
351
  else if (matchesKey(data, "enter")) {
327
352
  const entry = filtered[selectedIndex];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.6.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"],
@@ -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
+ }
package/src/cli.ts CHANGED
@@ -57,13 +57,15 @@ function installService(): void {
57
57
  const USAGE = `Usage:
58
58
  papyrus serve
59
59
  papyrus service <install|start|stop|restart|status>
60
- papyrus migrate task-history [--json]
60
+ papyrus migrate task-scope [--json]
61
61
  papyrus automation <status|run> [--json]
62
62
  papyrus skills run <id> [--arguments-json <json>] [--run-id <id>] [--json]
63
63
  papyrus tasks plan [--json]
64
64
  papyrus tasks graph [--json]
65
65
  papyrus tasks active [--json]
66
66
  papyrus tasks history <id> [--json]
67
+ papyrus tasks scope [project|all|graph <root-id>] [--json]
68
+ papyrus tasks assign-project <id> [project-root] [--json]
67
69
  papyrus tasks focus <id> [--json]
68
70
  papyrus tasks complete <id> [--json]
69
71
  papyrus tasks start <id> [--json]
@@ -110,8 +112,8 @@ function planText(plan: TaskExecutionPlan): string {
110
112
  export async function runMigrationCli(args: string[], client: TaskCliClient): Promise<string> {
111
113
  const json = args.includes("--json");
112
114
  const positional = args.filter((arg) => arg !== "--json");
113
- if (positional.length !== 1 || positional[0] !== "task-history") {
114
- throw new Error("migrate requires exactly `task-history`");
115
+ if (positional.length !== 1 || positional[0] !== "task-scope") {
116
+ throw new Error("migrate requires exactly `task-scope`");
115
117
  }
116
118
  const result = await client.call<Record<string, never>, MigrationResult>("system.migrate", {});
117
119
  if (json) return JSON.stringify(result);
@@ -133,7 +135,7 @@ export async function runAutomationCli(args: string[], client: TaskCliClient): P
133
135
  return json ? JSON.stringify(result) : `Automation sweep: ${result.examined} examined · ${result.completed} completed · ${result.rejected} rejected · ${result.started} started · ${result.errors.length} errors${result.skipped ? ` · skipped ${result.skipped}` : ""}`;
134
136
  }
135
137
 
136
- export async function runSkillCli(args: string[], client: TaskCliClient): Promise<string> {
138
+ export async function runSkillCli(args: string[], client: TaskCliClient, projectRoot: string = process.cwd()): Promise<string> {
137
139
  const json = args.includes("--json");
138
140
  const positional: string[] = [];
139
141
  let runId: string | undefined;
@@ -160,7 +162,7 @@ export async function runSkillCli(args: string[], client: TaskCliClient): Promis
160
162
  positional.push(argument);
161
163
  }
162
164
  if (positional.length !== 2 || positional[0] !== "run") throw new Error("skills requires `run <id>`");
163
- const input: Record<string, unknown> = { id: positional[1], arguments: arguments_ };
165
+ const input: Record<string, unknown> = { id: positional[1], arguments: arguments_, project_root: projectRoot };
164
166
  if (runId) input["run_id"] = runId;
165
167
  const result = await client.call<Record<string, unknown>, {
166
168
  runId: string;
@@ -178,7 +180,7 @@ export async function runSkillCli(args: string[], client: TaskCliClient): Promis
178
180
  ].join("\n");
179
181
  }
180
182
 
181
- export async function runTaskCli(args: string[], client: TaskCliClient): Promise<string> {
183
+ export async function runTaskCli(args: string[], client: TaskCliClient, projectRoot: string = process.cwd()): Promise<string> {
182
184
  const json = args.includes("--json");
183
185
  const positional = args.filter((arg) => arg !== "--json");
184
186
  const [action, id, dependencyId] = positional;
@@ -187,7 +189,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
187
189
  switch (action) {
188
190
  case "active": {
189
191
  if (id) throw new Error("tasks active accepts no positional arguments");
190
- const active = await client.call<Record<string, never>, CliArtifact | null>("tasks.active", {});
192
+ const active = await client.call<Record<string, string>, CliArtifact | null>("tasks.active", { project_root: projectRoot });
191
193
  result = active;
192
194
  human = active ? `Active: ${artifactLabel(active)}` : "No active task.";
193
195
  break;
@@ -201,6 +203,37 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
201
203
  : [...page.events].reverse().map((event) => `${event.occurredAt} ${event.type} ${event.fromStatus ?? "∅"} → ${event.toStatus ?? "∅"} · ${event.actor}/${event.source}${event.reason ? ` · ${event.reason}` : ""}`).join("\n");
202
204
  break;
203
205
  }
206
+ case "scope": {
207
+ if (!id) {
208
+ const selection = await client.call<Record<string, string>, import("./domain/task-scope.ts").TaskViewSelection>("tasks.scope", { project_root: projectRoot });
209
+ result = selection;
210
+ human = `Task scope: ${selection.label}`;
211
+ break;
212
+ }
213
+ if (id !== "project" && id !== "all" && id !== "graph") throw new Error("tasks scope mode must be project, all, or graph");
214
+ if (id === "graph" && !dependencyId) throw new Error("tasks scope graph requires a root task id");
215
+ if (id !== "graph" && dependencyId) throw new Error(`tasks scope ${id} accepts no root task id`);
216
+ const selection = await client.call<Record<string, unknown>, import("./domain/task-scope.ts").TaskViewSelection>("tasks.set_scope", {
217
+ project_root: projectRoot,
218
+ scope: id,
219
+ ...(dependencyId ? { root_task_id: dependencyId } : {}),
220
+ });
221
+ result = selection;
222
+ human = `Task scope: ${selection.label}`;
223
+ break;
224
+ }
225
+ case "assign-project": {
226
+ if (!id || positional.length > 3) throw new Error("tasks assign-project requires a task id and optional project root");
227
+ const artifact = await client.call<Record<string, unknown>, CliArtifact>("tasks.assign_project", {
228
+ id,
229
+ project_root: dependencyId ?? projectRoot,
230
+ actor: "user",
231
+ source: "cli",
232
+ });
233
+ result = artifact;
234
+ human = `Project assigned: ${artifactLabel(artifact)}`;
235
+ break;
236
+ }
204
237
  case "focus": {
205
238
  if (!id || dependencyId) throw new Error("tasks focus requires exactly one task id");
206
239
  const active = await client.call<{ id: string }, CliArtifact>("tasks.focus", { id });
@@ -210,10 +243,10 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
210
243
  }
211
244
  case "graph": {
212
245
  if (id) throw new Error("tasks graph accepts no positional arguments");
213
- const graph = await client.call<{ limit: number }, {
246
+ const graph = await client.call<{ limit: number; project_root: string }, {
214
247
  nodes: Array<{ dependencyIds: string[]; childIds: string[] }>;
215
248
  rootIds: string[];
216
- }>("tasks.graph", { limit: TASK_EXECUTION_MAX_NODES + 1 });
249
+ }>("tasks.graph", { limit: TASK_EXECUTION_MAX_NODES + 1, project_root: projectRoot });
217
250
  result = graph;
218
251
  const dependencies = graph.nodes.reduce((count, node) => count + node.dependencyIds.length, 0);
219
252
  const children = graph.nodes.reduce((count, node) => count + node.childIds.length, 0);
@@ -222,7 +255,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
222
255
  }
223
256
  case "plan": {
224
257
  if (id) throw new Error("tasks plan accepts no positional arguments");
225
- const plan = await client.call<Record<string, never>, TaskExecutionPlan>("tasks.plan", {});
258
+ const plan = await client.call<Record<string, string>, TaskExecutionPlan>("tasks.plan", { project_root: projectRoot });
226
259
  result = plan;
227
260
  human = planText(plan);
228
261
  break;
@@ -281,7 +314,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
281
314
  break;
282
315
  }
283
316
  default:
284
- throw new Error("tasks action must be active, focus, graph, plan, history, complete, start, submit, reject, retry, cancel, automate, or depend");
317
+ throw new Error("tasks action must be active, focus, graph, plan, history, scope, assign-project, complete, start, submit, reject, retry, cancel, automate, or depend");
285
318
  }
286
319
  return json ? JSON.stringify(result) : human;
287
320
  }
package/src/constants.ts CHANGED
@@ -7,7 +7,7 @@ export const DAEMON_PROBE_TIMEOUT_MS = 800;
7
7
  export const DAEMON_UNIT_NAME = "papyrus.service";
8
8
  export const DAEMON_DIR_ENV = "PAPYRUS_DAEMON_DIR";
9
9
  export const SQLITE_BUSY_TIMEOUT_MS = 5_000;
10
- export const SQLITE_SCHEMA_VERSION = 3;
10
+ export const SQLITE_SCHEMA_VERSION = 4;
11
11
  export const SERVICE_MAX_BODY_BYTES = 1_048_576;
12
12
  export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
13
13
  export const DB_OPTIMIZE_INTERVAL_MS = 24 * 60 * 60_000;
@@ -62,6 +62,9 @@ export const TASK_AUTOMATION_HARD_MAX_RUNTIME_MS = 600_000;
62
62
  export const TASK_AUTOMATION_MAX_CANDIDATE_SCAN = 1_000;
63
63
  export const TASK_AUTOMATION_ERROR_ID_MAX_LENGTH = 128;
64
64
  export const TASK_AUTOMATION_ERROR_MESSAGE_MAX_LENGTH = 500;
65
+ /** Persisted project and focused-graph Task view bounds. */
66
+ export const TASK_SCOPE_MAX_TASKS = 1_000;
67
+ export const TASK_PROJECT_ROOT_MAX_LENGTH = 4_096;
65
68
  export const GRAPH_RENDER_PADDING_X = 2;
66
69
  export const GRAPH_RENDER_PADDING_Y = 1;
67
70
  export const GRAPH_RENDER_BOX_PADDING = 0;
package/src/db.ts CHANGED
@@ -122,6 +122,20 @@ CREATE TRIGGER IF NOT EXISTS task_events_no_update BEFORE UPDATE ON task_events
122
122
  BEGIN SELECT RAISE(ABORT, 'task_events are append-only'); END;
123
123
  CREATE TRIGGER IF NOT EXISTS task_events_no_delete BEFORE DELETE ON task_events
124
124
  BEGIN SELECT RAISE(ABORT, 'task_events are append-only'); END;
125
+ CREATE TABLE IF NOT EXISTS task_scopes (
126
+ task_id TEXT PRIMARY KEY REFERENCES artifacts(id),
127
+ project_root TEXT,
128
+ source TEXT NOT NULL CHECK (source IN ('cwd', 'explicit', 'unscoped')),
129
+ assigned_at TEXT NOT NULL
130
+ );
131
+ CREATE INDEX IF NOT EXISTS task_scopes_project_idx ON task_scopes(project_root, task_id);
132
+ CREATE TABLE IF NOT EXISTS task_views (
133
+ project_root TEXT PRIMARY KEY,
134
+ mode TEXT NOT NULL CHECK (mode IN ('project', 'graph', 'all')),
135
+ root_task_id TEXT REFERENCES artifacts(id),
136
+ updated_at TEXT NOT NULL,
137
+ CHECK ((mode = 'graph' AND root_task_id IS NOT NULL) OR (mode != 'graph' AND root_task_id IS NULL))
138
+ );
125
139
  `;
126
140
 
127
141
  const SEED_SQL = `
@@ -185,7 +199,7 @@ export function migrateDb(db: Db): MigrationResult {
185
199
  throw new Error(`database schema ${from} is newer than supported ${SQLITE_SCHEMA_VERSION}`);
186
200
  }
187
201
  if (from === SQLITE_SCHEMA_VERSION) return { from, to: from, applied: [] };
188
- if (from !== 1 && from !== 2) throw new Error(`no explicit migration path from database schema ${from}`);
202
+ if (from !== 1 && from !== 2 && from !== 3) throw new Error(`no explicit migration path from database schema ${from}`);
189
203
  const applied: string[] = [];
190
204
 
191
205
  inTransaction(db, () => {
@@ -243,6 +257,29 @@ export function migrateDb(db: Db): MigrationResult {
243
257
  `);
244
258
  applied.push("task-history");
245
259
  }
260
+ if (schemaVersion(db) === 3) {
261
+ db.exec(`
262
+ CREATE TABLE task_scopes (
263
+ task_id TEXT PRIMARY KEY REFERENCES artifacts(id),
264
+ project_root TEXT,
265
+ source TEXT NOT NULL CHECK (source IN ('cwd', 'explicit', 'unscoped')),
266
+ assigned_at TEXT NOT NULL
267
+ );
268
+ CREATE INDEX task_scopes_project_idx ON task_scopes(project_root, task_id);
269
+ CREATE TABLE task_views (
270
+ project_root TEXT PRIMARY KEY,
271
+ mode TEXT NOT NULL CHECK (mode IN ('project', 'graph', 'all')),
272
+ root_task_id TEXT REFERENCES artifacts(id),
273
+ updated_at TEXT NOT NULL,
274
+ CHECK ((mode = 'graph' AND root_task_id IS NOT NULL) OR (mode != 'graph' AND root_task_id IS NULL))
275
+ );
276
+ INSERT INTO task_scopes (task_id, project_root, source, assigned_at)
277
+ SELECT id, NULL, 'unscoped', strftime('%Y-%m-%dT%H:%M:%fZ','now')
278
+ FROM artifacts WHERE kind = 'task';
279
+ PRAGMA user_version = 4;
280
+ `);
281
+ applied.push("task-project-scope");
282
+ }
246
283
  });
247
284
  return { from, to: schemaVersion(db), applied };
248
285
  }
@@ -15,6 +15,7 @@ export const TASK_EVENT_TYPES = [
15
15
  "gates_evaluated",
16
16
  "automation_enabled",
17
17
  "automation_disabled",
18
+ "project_assigned",
18
19
  "review_rejected",
19
20
  "retried",
20
21
  "completed",
@@ -0,0 +1,39 @@
1
+ import { basename, isAbsolute, normalize } from "node:path";
2
+ import { TASK_PROJECT_ROOT_MAX_LENGTH } from "../constants.ts";
3
+
4
+ export type TaskViewMode = "project" | "graph" | "all";
5
+ export type TaskScopeSource = "cwd" | "explicit" | "unscoped";
6
+
7
+ export interface TaskProjectScope {
8
+ taskId: string;
9
+ projectRoot?: string;
10
+ source: TaskScopeSource;
11
+ }
12
+
13
+ export interface TaskViewPreference {
14
+ projectRoot: string;
15
+ mode: TaskViewMode;
16
+ rootTaskId?: string;
17
+ }
18
+
19
+ export interface TaskViewSelection {
20
+ mode: TaskViewMode;
21
+ label: string;
22
+ projectRoot?: string;
23
+ rootTaskId?: string;
24
+ }
25
+
26
+ export function normalizeProjectRoot(value: string): string {
27
+ if (!isAbsolute(value)) throw new Error("project_root must be an absolute path");
28
+ const normalized = normalize(value);
29
+ if (normalized.length > TASK_PROJECT_ROOT_MAX_LENGTH) {
30
+ throw new Error(`project_root cannot exceed ${TASK_PROJECT_ROOT_MAX_LENGTH} characters`);
31
+ }
32
+ return normalized;
33
+ }
34
+
35
+ export function taskScopeLabel(mode: TaskViewMode, projectRoot?: string, rootTitle?: string): string {
36
+ if (mode === "all") return "All projects";
37
+ const project = projectRoot ? basename(projectRoot) || projectRoot : "Unscoped";
38
+ return mode === "graph" ? `${project} · ${rootTitle ?? "focused graph"}` : project;
39
+ }
@@ -0,0 +1,40 @@
1
+ import type { TaskProjectScope, TaskScopeSource, TaskViewMode, TaskViewPreference } from "../domain/task-scope.ts";
2
+
3
+ export interface TaskScopeStore {
4
+ assign(taskId: string, projectRoot: string | undefined, source: TaskScopeSource): TaskProjectScope;
5
+ get(taskId: string): TaskProjectScope | undefined;
6
+ taskIds(projectRoot: string | undefined, limit: number): string[];
7
+ view(projectRoot: string): TaskViewPreference;
8
+ setView(projectRoot: string, mode: TaskViewMode, rootTaskId?: string): TaskViewPreference;
9
+ }
10
+
11
+ export class InMemoryTaskScopeStore implements TaskScopeStore {
12
+ private readonly scopes = new Map<string, TaskProjectScope>();
13
+ private readonly views = new Map<string, TaskViewPreference>();
14
+
15
+ assign(taskId: string, projectRoot: string | undefined, source: TaskScopeSource): TaskProjectScope {
16
+ const scope = { taskId, ...(projectRoot === undefined ? {} : { projectRoot }), source };
17
+ this.scopes.set(taskId, scope);
18
+ return scope;
19
+ }
20
+
21
+ get(taskId: string): TaskProjectScope | undefined { return this.scopes.get(taskId); }
22
+
23
+ taskIds(projectRoot: string | undefined, limit: number): string[] {
24
+ return [...this.scopes.values()]
25
+ .filter((scope) => scope.projectRoot === projectRoot)
26
+ .map((scope) => scope.taskId)
27
+ .sort()
28
+ .slice(0, limit);
29
+ }
30
+
31
+ view(projectRoot: string): TaskViewPreference {
32
+ return this.views.get(projectRoot) ?? { projectRoot, mode: "project" };
33
+ }
34
+
35
+ setView(projectRoot: string, mode: TaskViewMode, rootTaskId?: string): TaskViewPreference {
36
+ const view = { projectRoot, mode, ...(rootTaskId === undefined ? {} : { rootTaskId }) };
37
+ this.views.set(projectRoot, view);
38
+ return view;
39
+ }
40
+ }
package/src/service.ts CHANGED
@@ -5,12 +5,15 @@ import { SQLiteArtifactStore } from "./adapters/sqlite-artifact-store.ts";
5
5
  import { SQLiteGateRunner } from "./adapters/sqlite-gate-runner.ts";
6
6
  import { SQLiteTaskFocusStore } from "./adapters/sqlite-task-focus-store.ts";
7
7
  import { SQLiteTaskEventStore } from "./adapters/sqlite-task-event-store.ts";
8
+ import { SQLiteTaskScopeStore } from "./adapters/sqlite-task-scope-store.ts";
8
9
  import type { CreateArtifactInput } from "./domain/artifact.ts";
9
10
  import type { Checklist } from "./domain/checklist.ts";
10
11
  import type { TaskEventContext, TaskEventDirection } from "./domain/task-event.ts";
12
+ import type { TaskViewMode } from "./domain/task-scope.ts";
11
13
  import type { ArtifactStore } from "./ports/artifact-store.ts";
12
14
  import type { GateRunner } from "./ports/gate-runner.ts";
13
15
  import type { TaskEventStore } from "./ports/task-event-store.ts";
16
+ import type { TaskScopeStore } from "./ports/task-scope-store.ts";
14
17
  import { projectTaskExecution } from "./task-execution.ts";
15
18
  import { Tasks, type TaskStatus } from "./task-service.ts";
16
19
  import { TaskAutomationReconciler, taskAutomationSettings, type TaskAutomationSettings } from "./task-automation.ts";
@@ -57,6 +60,9 @@ export const EXPECTED_OPERATION_NAMES = [
57
60
  "tasks.plan",
58
61
  "tasks.show",
59
62
  "tasks.history",
63
+ "tasks.scope",
64
+ "tasks.set_scope",
65
+ "tasks.assign_project",
60
66
  "tasks.active",
61
67
  "tasks.focus",
62
68
  "tasks.start",
@@ -150,6 +156,7 @@ function handlers(
150
156
  tasks: Tasks,
151
157
  automation: TaskAutomationReconciler,
152
158
  events: TaskEventStore,
159
+ scopes: TaskScopeStore,
153
160
  migrate: () => unknown,
154
161
  ): Record<OperationName, OperationHandler> {
155
162
  const eventContext = (input: OperationInput): TaskEventContext => ({
@@ -166,6 +173,9 @@ function handlers(
166
173
  status: optionalString(input, "status"),
167
174
  text: optionalString(input, "text"),
168
175
  limit: optionalNumber(input, "limit"),
176
+ projectRoot: string(input, "project_root"),
177
+ scope: optionalString(input, "scope") as TaskViewMode | undefined,
178
+ rootTaskId: optionalString(input, "root_task_id"),
169
179
  });
170
180
  return {
171
181
  "system.migrate": () => migrate(),
@@ -183,6 +193,8 @@ function handlers(
183
193
  labels: normalized.labels,
184
194
  extra: normalized.extra,
185
195
  templateId: normalized.templateId,
196
+ projectRoot: string(input, "project_root"),
197
+ projectSource: "cwd",
186
198
  }, eventContextFor(input, "artifact-api"));
187
199
  },
188
200
  "artifact.query": (input) => artifacts.query(input),
@@ -218,7 +230,7 @@ function handlers(
218
230
  ? tasks.runGates(id, eventContextFor(input, "gates-api"))
219
231
  : gates.runAsync(id);
220
232
  },
221
- "rules.injectable": () => listInjectableRules(artifacts, tasks.active()?.id)
233
+ "rules.injectable": (input) => listInjectableRules(artifacts, tasks.active(taskFilter(input))?.id)
222
234
  .map(({ id, title, body, extra }) => ({ id, title, body, extra })),
223
235
  "tasks.create": (input) => tasks.create({
224
236
  title: string(input, "title"),
@@ -231,6 +243,8 @@ function handlers(
231
243
  templateId: optionalString(input, "template_id") ?? optionalString(input, "templateId"),
232
244
  parentId: optionalString(input, "parent_id") ?? optionalString(input, "parentId"),
233
245
  dependsOn: (input["depends_on"] ?? input["dependsOn"]) as string[] | undefined,
246
+ projectRoot: string(input, "project_root"),
247
+ projectSource: "cwd",
234
248
  }, eventContext(input)),
235
249
  "tasks.list": (input) => tasks.list(taskFilter(input)),
236
250
  "tasks.graph": (input) => tasks.graph(taskFilter(input)),
@@ -241,7 +255,18 @@ function handlers(
241
255
  cursor: optionalNumber(input, "cursor"),
242
256
  direction: optionalString(input, "direction") as TaskEventDirection | undefined,
243
257
  }),
244
- "tasks.active": () => tasks.active(),
258
+ "tasks.scope": (input) => tasks.scopeSelection(string(input, "project_root")),
259
+ "tasks.set_scope": (input) => tasks.setView(
260
+ string(input, "project_root"),
261
+ string(input, "scope") as TaskViewMode,
262
+ optionalString(input, "root_task_id"),
263
+ ),
264
+ "tasks.assign_project": (input) => tasks.assignProject(
265
+ string(input, "id"),
266
+ string(input, "project_root"),
267
+ eventContext(input),
268
+ ),
269
+ "tasks.active": (input) => tasks.active(taskFilter(input)),
245
270
  "tasks.focus": (input) => tasks.focus(string(input, "id")),
246
271
  "tasks.start": (input) => tasks.transition(string(input, "id"), "start", eventContext(input)),
247
272
  "tasks.submit": (input) => tasks.transition(string(input, "id"), "submit", eventContext(input)),
@@ -252,7 +277,7 @@ function handlers(
252
277
  if (typeof input["enabled"] !== "boolean") throw new Error("enabled must be a boolean");
253
278
  return tasks.setAutomation(string(input, "id"), input["enabled"], eventContext(input));
254
279
  },
255
- "tasks.context": () => taskContext(artifacts, tasks.active()?.id),
280
+ "tasks.context": (input) => taskContext(artifacts, tasks.active()?.id, new Set(tasks.list(taskFilter(input)).map((task) => task.id))),
256
281
  "tasks.reject": (input) => tasks.transition(string(input, "id"), "reject", eventContext(input)),
257
282
  "tasks.retry": (input) => tasks.transition(string(input, "id"), "retry", eventContext(input)),
258
283
  "tasks.cancel": (input) => tasks.transition(string(input, "id"), "cancel", eventContext(input)),
@@ -297,10 +322,24 @@ function handlers(
297
322
  "skills.run": (input) => instantiateSkillWorkflow(artifacts, string(input, "id"), {
298
323
  runId: optionalString(input, "run_id") ?? optionalString(input, "runId"),
299
324
  arguments: input["arguments"] as Record<string, unknown> | undefined,
300
- }, { events, context: eventContextFor(input, "skill-run") }),
325
+ }, { events, scopes, projectRoot: string(input, "project_root"), context: eventContextFor(input, "skill-run") }),
301
326
  "skills.enable": (input) => transitionSkill(artifacts, string(input, "id"), "enable"),
302
327
  "skills.disable": (input) => transitionSkill(artifacts, string(input, "id"), "disable"),
303
- "skills.instantiate": (input) => instantiateTemplate(artifacts, string(input, "template_id"), normalizeCreateInput(input)),
328
+ "skills.instantiate": (input) => {
329
+ const templateId = string(input, "template_id");
330
+ const template = artifacts.get(templateId);
331
+ if (template?.extra["targetKind"] !== "task") return instantiateTemplate(artifacts, templateId, normalizeCreateInput(input));
332
+ return tasks.create({
333
+ title: optionalString(input, "title") as string,
334
+ body: optionalString(input, "body"),
335
+ status: optionalString(input, "status") as TaskStatus | undefined,
336
+ labels: input["labels"] as string[] | undefined,
337
+ extra: input["extra"] as Record<string, unknown> | undefined,
338
+ templateId,
339
+ projectRoot: string(input, "project_root"),
340
+ projectSource: "cwd",
341
+ }, eventContextFor(input, "template-instantiation"));
342
+ },
304
343
  };
305
344
  }
306
345
 
@@ -310,9 +349,10 @@ export function createPapyrusService(path: string, options: { automation?: TaskA
310
349
  const gates = new SQLiteGateRunner(db);
311
350
  const focus = new SQLiteTaskFocusStore(db);
312
351
  const events = new SQLiteTaskEventStore(db);
313
- const tasks = new Tasks(artifacts, gates, focus, events);
352
+ const scopes = new SQLiteTaskScopeStore(db);
353
+ const tasks = new Tasks(artifacts, gates, focus, events, scopes);
314
354
  const automation = new TaskAutomationReconciler(tasks, options.automation ?? taskAutomationSettings({}));
315
- const registry = handlers(artifacts, gates, tasks, automation, events, () => migrateDb(db));
355
+ const registry = handlers(artifacts, gates, tasks, automation, events, scopes, () => migrateDb(db));
316
356
  const state = (): SchemaState => {
317
357
  const current = schemaVersion(db);
318
358
  return { current, required: SQLITE_SCHEMA_VERSION, migrationRequired: current !== SQLITE_SCHEMA_VERSION };
@@ -324,7 +364,7 @@ export function createPapyrusService(path: string, options: { automation?: TaskA
324
364
  const handler = registry[operation as OperationName];
325
365
  if (!handler) throw new UnknownOperationError(`unknown operation "${operation}"`);
326
366
  if (operation !== "system.migrate" && operation !== "automation.status" && state().migrationRequired) {
327
- throw new MigrationRequiredError("database migration required; run `papyrus migrate task-history`");
367
+ throw new MigrationRequiredError("database migration required; run `papyrus migrate task-scope`");
328
368
  }
329
369
  return handler(input);
330
370
  },
@@ -11,6 +11,8 @@ import {
11
11
  import type { ArtifactStore } from "./ports/artifact-store.ts";
12
12
  import type { TaskEventContext } from "./domain/task-event.ts";
13
13
  import type { TaskEventStore } from "./ports/task-event-store.ts";
14
+ import type { TaskScopeStore } from "./ports/task-scope-store.ts";
15
+ import { normalizeProjectRoot } from "./domain/task-scope.ts";
14
16
  import { requireAtomicArtifactStore } from "./ports/atomic-artifact-store.ts";
15
17
  import { projectTaskExecution, type TaskExecutionPlan } from "./task-execution.ts";
16
18
  import type { TaskGraph, TaskNode, TaskStatus } from "./task-service.ts";
@@ -117,9 +119,10 @@ export function instantiateSkillWorkflow(
117
119
  artifacts: ArtifactStore,
118
120
  skillId: string,
119
121
  input: InstantiateSkillWorkflowInput = {},
120
- history?: { events: TaskEventStore; context?: TaskEventContext },
122
+ history?: { events: TaskEventStore; scopes: TaskScopeStore; projectRoot: string; context?: TaskEventContext },
121
123
  ): SkillWorkflowRunResult {
122
124
  const { definition } = requireWorkflowSkill(artifacts, skillId);
125
+ const projectRoot = history ? normalizeProjectRoot(history.projectRoot) : undefined;
123
126
  const arguments_ = resolveSkillArguments(definition, input.arguments);
124
127
  const rendered = renderDefinition(definition, arguments_);
125
128
  const runId = normalizeRunId(skillId, input.runId);
@@ -175,15 +178,18 @@ export function instantiateSkillWorkflow(
175
178
  labels: withRunLabel(blueprint.labels, runId),
176
179
  extra: { ...(blueprint.extra ?? {}), skillRun: { id: runId, skillId, ref: blueprint.ref } },
177
180
  });
178
- if (history) history.events.append({
179
- taskId: task.id,
180
- type: "created",
181
- actor: history.context?.actor ?? "system",
182
- source: history.context?.source ?? "skill-run",
183
- toStatus: task.status as TaskStatus,
184
- ...(history.context?.sessionId === undefined ? {} : { sessionId: history.context.sessionId }),
185
- ...(history.context?.reason === undefined ? {} : { reason: history.context.reason }),
186
- });
181
+ if (history) {
182
+ history.scopes.assign(task.id, projectRoot, "cwd");
183
+ history.events.append({
184
+ taskId: task.id,
185
+ type: "created",
186
+ actor: history.context?.actor ?? "system",
187
+ source: history.context?.source ?? "skill-run",
188
+ toStatus: task.status as TaskStatus,
189
+ ...(history.context?.sessionId === undefined ? {} : { sessionId: history.context.sessionId }),
190
+ ...(history.context?.reason === undefined ? {} : { reason: history.context.reason }),
191
+ });
192
+ }
187
193
  return task;
188
194
  });
189
195
 
@@ -34,8 +34,10 @@ function renderCurrent(task: Artifact): string[] {
34
34
  ];
35
35
  }
36
36
 
37
- export function taskContext(artifacts: ArtifactStore, activeTaskId?: string): string | null {
38
- const tasks = artifacts.query({ kind: "task" }).sort((left, right) => left.updated_at.localeCompare(right.updated_at));
37
+ export function taskContext(artifacts: ArtifactStore, activeTaskId?: string, taskIds?: Set<string>): string | null {
38
+ const tasks = artifacts.query({ kind: "task" })
39
+ .filter((task) => taskIds === undefined || taskIds.has(task.id))
40
+ .sort((left, right) => left.updated_at.localeCompare(right.updated_at));
39
41
  const open = tasks.filter((task) => task.status !== "done" && task.status !== "canceled");
40
42
  if (open.length === 0) return null;
41
43
 
@@ -1,18 +1,23 @@
1
- import { TASK_EXECUTION_MAX_DEGREE, TASK_EXECUTION_MAX_EDGES, TASK_EXECUTION_MAX_NODES } from "./constants.ts";
1
+ import { TASK_EXECUTION_MAX_DEGREE, TASK_EXECUTION_MAX_EDGES, TASK_EXECUTION_MAX_NODES, TASK_SCOPE_MAX_TASKS } from "./constants.ts";
2
2
  import type { Artifact } from "./domain/artifact.ts";
3
3
  import { checklistEntries, validateChecklist, type Checklist, type ProofReference } from "./domain/checklist.ts";
4
4
  import type { Gate, GateResult } from "./domain/gate.ts";
5
5
  import type { AppendTaskEvent, TaskEventContext, TaskHistoryPage, TaskHistoryQuery, TaskLifecycleStatus } from "./domain/task-event.ts";
6
+ import { normalizeProjectRoot, taskScopeLabel, type TaskScopeSource, type TaskViewMode, type TaskViewSelection } from "./domain/task-scope.ts";
6
7
  import type { ArtifactStore } from "./ports/artifact-store.ts";
7
8
  import type { GateRunner } from "./ports/gate-runner.ts";
8
9
  import { InMemoryTaskFocusStore, type TaskFocusStore } from "./ports/task-focus-store.ts";
9
10
  import { InMemoryTaskEventStore, type TaskEventStore } from "./ports/task-event-store.ts";
11
+ import { InMemoryTaskScopeStore, type TaskScopeStore } from "./ports/task-scope-store.ts";
10
12
  import { assertDependencyEdgeAllowed } from "./task-execution.ts";
11
13
 
12
14
  export interface TaskFilter {
13
15
  status?: string;
14
16
  text?: string;
15
17
  limit?: number;
18
+ projectRoot?: string;
19
+ scope?: TaskViewMode;
20
+ rootTaskId?: string;
16
21
  }
17
22
 
18
23
  export type TaskStatus = TaskLifecycleStatus;
@@ -30,6 +35,8 @@ export interface CreateTaskInput {
30
35
  templateId?: string;
31
36
  parentId?: string;
32
37
  dependsOn?: string[];
38
+ projectRoot?: string;
39
+ projectSource?: TaskScopeSource;
33
40
  }
34
41
 
35
42
  export type TaskTransition = "start" | "submit" | "reject" | "retry" | "cancel";
@@ -71,6 +78,7 @@ export interface TaskNode {
71
78
  export interface TaskGraph {
72
79
  nodes: TaskNode[];
73
80
  rootIds: string[];
81
+ scope?: TaskViewSelection;
74
82
  }
75
83
 
76
84
  const TASK_TRANSITIONS: Record<TaskTransition, { from: TaskStatus[]; to: TaskStatus }> = {
@@ -87,6 +95,7 @@ export class Tasks {
87
95
  private readonly gates: GateRunner,
88
96
  private readonly focusStore: TaskFocusStore = new InMemoryTaskFocusStore(),
89
97
  private readonly events: TaskEventStore = new InMemoryTaskEventStore(),
98
+ private readonly scopes: TaskScopeStore = new InMemoryTaskScopeStore(),
90
99
  ) {}
91
100
 
92
101
  private require(id: string): Artifact {
@@ -106,6 +115,10 @@ export class Tasks {
106
115
  const extra: Record<string, unknown> = { ...(input.extra ?? {}) };
107
116
  if (input.gates !== undefined) extra["gates"] = input.gates;
108
117
  if (input.checklist !== undefined) extra["checklist"] = validateChecklist(input.checklist);
118
+ const projectRoot = input.projectRoot === undefined ? undefined : normalizeProjectRoot(input.projectRoot);
119
+ if (input.parentId && this.scopes.get(input.parentId)?.projectRoot !== projectRoot) {
120
+ throw new Error(`parent task "${input.parentId}" is outside project scope`);
121
+ }
109
122
  const task = this.artifacts.create({
110
123
  id: input.id,
111
124
  kind: "task",
@@ -117,6 +130,7 @@ export class Tasks {
117
130
  extra,
118
131
  templateId: input.templateId,
119
132
  });
133
+ this.scopes.assign(task.id, projectRoot, input.projectSource ?? (projectRoot ? "explicit" : "unscoped"));
120
134
  if (input.parentId) this.contain(input.parentId, task.id);
121
135
  for (const dependency of input.dependsOn ?? []) this.depend(task.id, dependency);
122
136
  this.appendEvent({ taskId: task.id, type: "created", toStatus: task.status as TaskStatus }, context);
@@ -125,10 +139,62 @@ export class Tasks {
125
139
  }
126
140
 
127
141
  list(filter: TaskFilter = {}): Artifact[] {
128
- return this.artifacts.query({ kind: "task", ...filter });
142
+ const selection = this.scopeSelection(filter.projectRoot, filter.scope, filter.rootTaskId);
143
+ const limit = filter.limit ?? TASK_SCOPE_MAX_TASKS;
144
+ if (!Number.isInteger(limit) || limit < 1 || limit > TASK_SCOPE_MAX_TASKS + 1) {
145
+ throw new Error(`task list limit must be between 1 and ${TASK_SCOPE_MAX_TASKS + 1}`);
146
+ }
147
+ if (selection.mode === "all") {
148
+ return this.artifacts.query({ kind: "task", status: filter.status, text: filter.text, limit });
149
+ }
150
+ const ids = this.scopes.taskIds(selection.projectRoot, TASK_SCOPE_MAX_TASKS + 1);
151
+ if (ids.length > TASK_SCOPE_MAX_TASKS) throw new Error(`task project scope exceeds ${TASK_SCOPE_MAX_TASKS} tasks`);
152
+ const selectedIds = selection.mode === "graph" ? this.descendantIds(selection.rootTaskId!, ids) : new Set(ids);
153
+ const text = filter.text?.toLowerCase();
154
+ return [...selectedIds]
155
+ .map((id) => this.artifacts.get(id))
156
+ .filter((task): task is Artifact => task?.kind === "task")
157
+ .filter((task) => filter.status === undefined || task.status === filter.status)
158
+ .filter((task) => text === undefined || task.title.toLowerCase().includes(text) || task.body.toLowerCase().includes(text))
159
+ .sort((left, right) => right.updated_at.localeCompare(left.updated_at) || left.id.localeCompare(right.id))
160
+ .slice(0, limit);
161
+ }
162
+
163
+ scopeSelection(projectRoot?: string, mode?: TaskViewMode, rootTaskId?: string): TaskViewSelection {
164
+ if (mode !== undefined && mode !== "project" && mode !== "graph" && mode !== "all") throw new Error("task scope must be project, graph, or all");
165
+ if (projectRoot === undefined) return { mode: "all", label: taskScopeLabel("all") };
166
+ const normalized = normalizeProjectRoot(projectRoot);
167
+ const persisted = this.scopes.view(normalized);
168
+ const selectedMode = mode ?? persisted.mode;
169
+ const selectedRoot = rootTaskId ?? (selectedMode === "graph" ? persisted.rootTaskId : undefined);
170
+ if (selectedMode === "graph" && !selectedRoot) throw new Error("graph scope requires root_task_id");
171
+ const root = selectedRoot ? this.require(selectedRoot) : undefined;
172
+ if (root && this.scopes.get(root.id)?.projectRoot !== normalized) throw new Error(`task "${root.id}" is outside project scope`);
173
+ return {
174
+ mode: selectedMode,
175
+ label: taskScopeLabel(selectedMode, normalized, root?.title),
176
+ projectRoot: normalized,
177
+ ...(selectedRoot === undefined ? {} : { rootTaskId: selectedRoot }),
178
+ };
179
+ }
180
+
181
+ setView(projectRoot: string, mode: TaskViewMode, rootTaskId?: string): TaskViewSelection {
182
+ const selection = this.scopeSelection(projectRoot, mode, rootTaskId);
183
+ this.scopes.setView(selection.projectRoot!, selection.mode, selection.rootTaskId);
184
+ return selection;
185
+ }
186
+
187
+ assignProject(id: string, projectRoot: string, context: TaskEventContext = {}): Artifact {
188
+ return this.events.atomic(() => {
189
+ const task = this.require(id);
190
+ this.scopes.assign(id, normalizeProjectRoot(projectRoot), "explicit");
191
+ this.appendEvent({ taskId: id, type: "project_assigned", reason: context.reason }, context);
192
+ return task;
193
+ });
129
194
  }
130
195
 
131
196
  graph(filter: TaskFilter = {}): TaskGraph {
197
+ const scope = this.scopeSelection(filter.projectRoot, filter.scope, filter.rootTaskId);
132
198
  const requestedLimit = filter.limit ?? TASK_EXECUTION_MAX_NODES + 1;
133
199
  if (!Number.isInteger(requestedLimit) || requestedLimit < 1 || requestedLimit > TASK_EXECUTION_MAX_NODES + 1) {
134
200
  throw new Error(`task graph limit must be between 1 and ${TASK_EXECUTION_MAX_NODES + 1}`);
@@ -172,6 +238,7 @@ export class Tasks {
172
238
  return {
173
239
  nodes: tasks.map((task) => nodes.get(task.id)!),
174
240
  rootIds: tasks.filter((task) => nodes.get(task.id)!.parentIds.length === 0).map((task) => task.id),
241
+ scope,
175
242
  };
176
243
  }
177
244
 
@@ -180,7 +247,7 @@ export class Tasks {
180
247
  return this.artifacts.get(id, { tree: true })!;
181
248
  }
182
249
 
183
- active(): Artifact | null {
250
+ active(filter?: TaskFilter): Artifact | null {
184
251
  const id = this.focusStore.get();
185
252
  if (!id) return null;
186
253
  const task = this.artifacts.get(id);
@@ -188,6 +255,7 @@ export class Tasks {
188
255
  this.focusStore.clear(id);
189
256
  return null;
190
257
  }
258
+ if (filter?.projectRoot && !this.list(filter).some((candidate) => candidate.id === task.id)) return null;
191
259
  return task;
192
260
  }
193
261
 
@@ -295,6 +363,36 @@ export class Tasks {
295
363
  return this.show(parentId);
296
364
  }
297
365
 
366
+ private descendantIds(rootTaskId: string, projectTaskIds: string[]): Set<string> {
367
+ const allowed = new Set(projectTaskIds);
368
+ if (!allowed.has(rootTaskId)) throw new Error(`task "${rootTaskId}" is outside project scope`);
369
+ const relationships = this.artifacts.relationships({
370
+ kind: "task",
371
+ artifactIds: projectTaskIds,
372
+ limit: TASK_EXECUTION_MAX_EDGES + 1,
373
+ });
374
+ if (relationships.length > TASK_EXECUTION_MAX_EDGES) throw new Error(`task project scope exceeds ${TASK_EXECUTION_MAX_EDGES} relationships`);
375
+ const children = new Map<string, string[]>();
376
+ for (const edge of relationships) {
377
+ const parentId = edge.relation === "contains" ? edge.from : edge.relation === "part_of" ? edge.to : undefined;
378
+ const childId = edge.relation === "contains" ? edge.to : edge.relation === "part_of" ? edge.from : undefined;
379
+ if (!parentId || !childId || !allowed.has(parentId) || !allowed.has(childId)) continue;
380
+ const values = children.get(parentId) ?? [];
381
+ if (!values.includes(childId)) values.push(childId);
382
+ children.set(parentId, values);
383
+ }
384
+ const selected = new Set<string>();
385
+ const pending = [rootTaskId];
386
+ while (pending.length > 0) {
387
+ const id = pending.shift()!;
388
+ if (selected.has(id)) continue;
389
+ if (selected.size >= TASK_SCOPE_MAX_TASKS) throw new Error(`focused task graph exceeds ${TASK_SCOPE_MAX_TASKS} tasks`);
390
+ selected.add(id);
391
+ pending.push(...(children.get(id) ?? []));
392
+ }
393
+ return selected;
394
+ }
395
+
298
396
  private relationships(id: string) {
299
397
  const relationships = this.artifacts.relationships({
300
398
  kind: "task",