@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 +10 -5
- package/extension/src/domain-tools.ts +21 -10
- package/extension/src/index.ts +19 -8
- package/extension/src/skills.ts +1 -0
- package/extension/src/task-widget.ts +2 -1
- package/extension/src/tasks.ts +34 -9
- package/package.json +1 -1
- package/src/adapters/sqlite-task-scope-store.ts +59 -0
- package/src/cli.ts +44 -11
- package/src/constants.ts +4 -1
- package/src/db.ts +38 -1
- package/src/domain/task-event.ts +1 -0
- package/src/domain/task-scope.ts +39 -0
- package/src/ports/task-scope-store.ts +40 -0
- package/src/service.ts +48 -8
- package/src/skill-execution.ts +16 -10
- package/src/task-context.ts +4 -2
- package/src/task-service.ts +101 -3
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
|
|
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
|
|
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-
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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}`);
|
package/extension/src/index.ts
CHANGED
|
@@ -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,
|
|
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,
|
|
184
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
179
185
|
try {
|
|
180
|
-
const a = await callService<Record<string, unknown>, Artifact>("artifact.create",
|
|
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,
|
|
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) {
|
package/extension/src/skills.ts
CHANGED
|
@@ -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
|
}
|
package/extension/src/tasks.ts
CHANGED
|
@@ -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", {
|
|
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(
|
|
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
|
@@ -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-
|
|
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-
|
|
114
|
-
throw new Error("migrate requires exactly `task-
|
|
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,
|
|
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,
|
|
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 =
|
|
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
|
}
|
package/src/domain/task-event.ts
CHANGED
|
@@ -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.
|
|
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) =>
|
|
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
|
|
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-
|
|
367
|
+
throw new MigrationRequiredError("database migration required; run `papyrus migrate task-scope`");
|
|
328
368
|
}
|
|
329
369
|
return handler(input);
|
|
330
370
|
},
|
package/src/skill-execution.ts
CHANGED
|
@@ -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)
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
|
package/src/task-context.ts
CHANGED
|
@@ -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" })
|
|
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
|
|
package/src/task-service.ts
CHANGED
|
@@ -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
|
-
|
|
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",
|