@danypops/papyrus 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -5
- package/extension/src/domain-tools.ts +23 -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 +56 -9
- package/package.json +1 -1
- package/src/adapters/sqlite-gate-runner.ts +3 -3
- package/src/adapters/sqlite-task-scope-store.ts +59 -0
- package/src/cli.ts +78 -11
- package/src/constants.ts +18 -1
- package/src/daemon.ts +20 -4
- package/src/db.ts +38 -1
- package/src/domain/gate.ts +5 -0
- package/src/domain/task-event.ts +3 -0
- package/src/domain/task-scope.ts +39 -0
- package/src/log.ts +6 -0
- package/src/ops.ts +18 -7
- package/src/ports/gate-runner.ts +2 -2
- package/src/ports/task-scope-store.ts +40 -0
- package/src/service.ts +62 -10
- package/src/skill-execution.ts +16 -10
- package/src/task-automation.ts +188 -0
- package/src/task-context.ts +4 -2
- package/src/task-service.ts +128 -11
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";
|
|
@@ -46,6 +53,11 @@ export interface ChecklistReview {
|
|
|
46
53
|
reason?: string;
|
|
47
54
|
}
|
|
48
55
|
|
|
56
|
+
export interface TaskCompletionOptions {
|
|
57
|
+
focusSuccessor?: boolean;
|
|
58
|
+
gateDeadlineMs?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
49
61
|
export interface TaskCompletion {
|
|
50
62
|
artifact: Artifact;
|
|
51
63
|
gates: GateResult[];
|
|
@@ -66,6 +78,7 @@ export interface TaskNode {
|
|
|
66
78
|
export interface TaskGraph {
|
|
67
79
|
nodes: TaskNode[];
|
|
68
80
|
rootIds: string[];
|
|
81
|
+
scope?: TaskViewSelection;
|
|
69
82
|
}
|
|
70
83
|
|
|
71
84
|
const TASK_TRANSITIONS: Record<TaskTransition, { from: TaskStatus[]; to: TaskStatus }> = {
|
|
@@ -82,6 +95,7 @@ export class Tasks {
|
|
|
82
95
|
private readonly gates: GateRunner,
|
|
83
96
|
private readonly focusStore: TaskFocusStore = new InMemoryTaskFocusStore(),
|
|
84
97
|
private readonly events: TaskEventStore = new InMemoryTaskEventStore(),
|
|
98
|
+
private readonly scopes: TaskScopeStore = new InMemoryTaskScopeStore(),
|
|
85
99
|
) {}
|
|
86
100
|
|
|
87
101
|
private require(id: string): Artifact {
|
|
@@ -101,6 +115,10 @@ export class Tasks {
|
|
|
101
115
|
const extra: Record<string, unknown> = { ...(input.extra ?? {}) };
|
|
102
116
|
if (input.gates !== undefined) extra["gates"] = input.gates;
|
|
103
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
|
+
}
|
|
104
122
|
const task = this.artifacts.create({
|
|
105
123
|
id: input.id,
|
|
106
124
|
kind: "task",
|
|
@@ -112,6 +130,7 @@ export class Tasks {
|
|
|
112
130
|
extra,
|
|
113
131
|
templateId: input.templateId,
|
|
114
132
|
});
|
|
133
|
+
this.scopes.assign(task.id, projectRoot, input.projectSource ?? (projectRoot ? "explicit" : "unscoped"));
|
|
115
134
|
if (input.parentId) this.contain(input.parentId, task.id);
|
|
116
135
|
for (const dependency of input.dependsOn ?? []) this.depend(task.id, dependency);
|
|
117
136
|
this.appendEvent({ taskId: task.id, type: "created", toStatus: task.status as TaskStatus }, context);
|
|
@@ -120,10 +139,62 @@ export class Tasks {
|
|
|
120
139
|
}
|
|
121
140
|
|
|
122
141
|
list(filter: TaskFilter = {}): Artifact[] {
|
|
123
|
-
|
|
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
|
+
});
|
|
124
194
|
}
|
|
125
195
|
|
|
126
196
|
graph(filter: TaskFilter = {}): TaskGraph {
|
|
197
|
+
const scope = this.scopeSelection(filter.projectRoot, filter.scope, filter.rootTaskId);
|
|
127
198
|
const requestedLimit = filter.limit ?? TASK_EXECUTION_MAX_NODES + 1;
|
|
128
199
|
if (!Number.isInteger(requestedLimit) || requestedLimit < 1 || requestedLimit > TASK_EXECUTION_MAX_NODES + 1) {
|
|
129
200
|
throw new Error(`task graph limit must be between 1 and ${TASK_EXECUTION_MAX_NODES + 1}`);
|
|
@@ -167,6 +238,7 @@ export class Tasks {
|
|
|
167
238
|
return {
|
|
168
239
|
nodes: tasks.map((task) => nodes.get(task.id)!),
|
|
169
240
|
rootIds: tasks.filter((task) => nodes.get(task.id)!.parentIds.length === 0).map((task) => task.id),
|
|
241
|
+
scope,
|
|
170
242
|
};
|
|
171
243
|
}
|
|
172
244
|
|
|
@@ -175,7 +247,7 @@ export class Tasks {
|
|
|
175
247
|
return this.artifacts.get(id, { tree: true })!;
|
|
176
248
|
}
|
|
177
249
|
|
|
178
|
-
active(): Artifact | null {
|
|
250
|
+
active(filter?: TaskFilter): Artifact | null {
|
|
179
251
|
const id = this.focusStore.get();
|
|
180
252
|
if (!id) return null;
|
|
181
253
|
const task = this.artifacts.get(id);
|
|
@@ -183,6 +255,7 @@ export class Tasks {
|
|
|
183
255
|
this.focusStore.clear(id);
|
|
184
256
|
return null;
|
|
185
257
|
}
|
|
258
|
+
if (filter?.projectRoot && !this.list(filter).some((candidate) => candidate.id === task.id)) return null;
|
|
186
259
|
return task;
|
|
187
260
|
}
|
|
188
261
|
|
|
@@ -215,23 +288,23 @@ export class Tasks {
|
|
|
215
288
|
});
|
|
216
289
|
}
|
|
217
290
|
|
|
218
|
-
complete(id: string, context: TaskEventContext = {}): TaskCompletion {
|
|
291
|
+
complete(id: string, context: TaskEventContext = {}, options: TaskCompletionOptions = {}): TaskCompletion {
|
|
219
292
|
const task = this.requireReview(id);
|
|
220
293
|
const attemptId = crypto.randomUUID();
|
|
221
294
|
this.events.atomic(() => this.appendEvent({ taskId: id, type: "completion_attempted", fromStatus: "review", toStatus: "review", attemptId }, context));
|
|
222
295
|
const checklist = this.reviewChecklist(task);
|
|
223
296
|
const results = this.gates.run(id);
|
|
224
|
-
return this.resolveCompletion(id, attemptId, results, checklist, context);
|
|
297
|
+
return this.resolveCompletion(id, attemptId, results, checklist, context, options);
|
|
225
298
|
}
|
|
226
299
|
|
|
227
|
-
async completeAsync(id: string, context: TaskEventContext = {}): Promise<TaskCompletion> {
|
|
300
|
+
async completeAsync(id: string, context: TaskEventContext = {}, options: TaskCompletionOptions = {}): Promise<TaskCompletion> {
|
|
228
301
|
const task = this.requireReview(id);
|
|
229
302
|
const attemptId = crypto.randomUUID();
|
|
230
303
|
this.events.atomic(() => this.appendEvent({ taskId: id, type: "completion_attempted", fromStatus: "review", toStatus: "review", attemptId }, context));
|
|
231
304
|
const checklist = this.reviewChecklist(task);
|
|
232
|
-
const results = await this.gates.runAsync(id);
|
|
305
|
+
const results = await this.gates.runAsync(id, { deadlineMs: options.gateDeadlineMs });
|
|
233
306
|
this.requireReview(id);
|
|
234
|
-
return this.resolveCompletion(id, attemptId, results, checklist, context);
|
|
307
|
+
return this.resolveCompletion(id, attemptId, results, checklist, context, options);
|
|
235
308
|
}
|
|
236
309
|
|
|
237
310
|
async runGates(id: string, context: TaskEventContext = {}): Promise<GateResult[]> {
|
|
@@ -251,6 +324,19 @@ export class Tasks {
|
|
|
251
324
|
return this.artifacts.setExtra(id, { ...task.extra, checklist: validateChecklist(checklist) })!;
|
|
252
325
|
}
|
|
253
326
|
|
|
327
|
+
setAutomation(id: string, enabled: boolean, context: TaskEventContext = {}): Artifact {
|
|
328
|
+
return this.events.atomic(() => {
|
|
329
|
+
const task = this.require(id);
|
|
330
|
+
const current = task.extra["automation"];
|
|
331
|
+
const automation = typeof current === "object" && current !== null && !Array.isArray(current)
|
|
332
|
+
? current as Record<string, unknown>
|
|
333
|
+
: {};
|
|
334
|
+
const updated = this.artifacts.setExtra(id, { ...task.extra, automation: { ...automation, enabled } })!;
|
|
335
|
+
this.appendEvent({ taskId: id, type: enabled ? "automation_enabled" : "automation_disabled" }, context);
|
|
336
|
+
return updated;
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
254
340
|
depend(id: string, dependencyId: string): Artifact {
|
|
255
341
|
this.require(id);
|
|
256
342
|
this.require(dependencyId);
|
|
@@ -277,6 +363,36 @@ export class Tasks {
|
|
|
277
363
|
return this.show(parentId);
|
|
278
364
|
}
|
|
279
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
|
+
|
|
280
396
|
private relationships(id: string) {
|
|
281
397
|
const relationships = this.artifacts.relationships({
|
|
282
398
|
kind: "task",
|
|
@@ -345,6 +461,7 @@ export class Tasks {
|
|
|
345
461
|
gates: GateResult[],
|
|
346
462
|
checklist: ChecklistReview[],
|
|
347
463
|
context: TaskEventContext,
|
|
464
|
+
options: TaskCompletionOptions,
|
|
348
465
|
): TaskCompletion {
|
|
349
466
|
const failed = gates.some((gate) => !gate.passed) || checklist.some((item) => !item.accepted);
|
|
350
467
|
if (failed) {
|
|
@@ -361,10 +478,10 @@ export class Tasks {
|
|
|
361
478
|
return { artifact, gates, checklist, completed: false, focused: this.active(), blocked: [] };
|
|
362
479
|
});
|
|
363
480
|
}
|
|
364
|
-
return this.events.atomic(() => this.finish(id, attemptId, gates, checklist, context));
|
|
481
|
+
return this.events.atomic(() => this.finish(id, attemptId, gates, checklist, context, options));
|
|
365
482
|
}
|
|
366
483
|
|
|
367
|
-
private finish(id: string, attemptId: string, gates: GateResult[], checklist: ChecklistReview[], context: TaskEventContext): TaskCompletion {
|
|
484
|
+
private finish(id: string, attemptId: string, gates: GateResult[], checklist: ChecklistReview[], context: TaskEventContext, options: TaskCompletionOptions): TaskCompletion {
|
|
368
485
|
const successorIds = this.relationships(id)
|
|
369
486
|
.filter((edge) => edge.relation === "depends_on" && edge.to === id)
|
|
370
487
|
.map((edge) => edge.from);
|
|
@@ -392,7 +509,7 @@ export class Tasks {
|
|
|
392
509
|
blocked.push({ artifact: successor, dependencyIds });
|
|
393
510
|
continue;
|
|
394
511
|
}
|
|
395
|
-
if (!focused) {
|
|
512
|
+
if (options.focusSuccessor !== false && !focused) {
|
|
396
513
|
this.focusStore.set(successor.id);
|
|
397
514
|
focused = successor;
|
|
398
515
|
}
|