@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.
@@ -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
- 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
+ });
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
  }