@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/src/service.ts CHANGED
@@ -5,14 +5,18 @@ 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";
19
+ import { TaskAutomationReconciler, taskAutomationSettings, type TaskAutomationSettings } from "./task-automation.ts";
16
20
  import {
17
21
  createArtifactTemplate,
18
22
  createDocument,
@@ -40,6 +44,8 @@ import { instantiateSkillWorkflow } from "./skill-execution.ts";
40
44
 
41
45
  export const EXPECTED_OPERATION_NAMES = [
42
46
  "system.migrate",
47
+ "automation.status",
48
+ "automation.reconcile",
43
49
  "artifact.create",
44
50
  "artifact.query",
45
51
  "artifact.show",
@@ -54,6 +60,9 @@ export const EXPECTED_OPERATION_NAMES = [
54
60
  "tasks.plan",
55
61
  "tasks.show",
56
62
  "tasks.history",
63
+ "tasks.scope",
64
+ "tasks.set_scope",
65
+ "tasks.assign_project",
57
66
  "tasks.active",
58
67
  "tasks.focus",
59
68
  "tasks.start",
@@ -61,6 +70,7 @@ export const EXPECTED_OPERATION_NAMES = [
61
70
  "tasks.complete",
62
71
  "tasks.run_gates",
63
72
  "tasks.set_checklist",
73
+ "tasks.set_automation",
64
74
  "tasks.context",
65
75
  "tasks.reject",
66
76
  "tasks.retry",
@@ -144,7 +154,9 @@ function handlers(
144
154
  artifacts: ArtifactStore,
145
155
  gates: GateRunner,
146
156
  tasks: Tasks,
157
+ automation: TaskAutomationReconciler,
147
158
  events: TaskEventStore,
159
+ scopes: TaskScopeStore,
148
160
  migrate: () => unknown,
149
161
  ): Record<OperationName, OperationHandler> {
150
162
  const eventContext = (input: OperationInput): TaskEventContext => ({
@@ -161,9 +173,14 @@ function handlers(
161
173
  status: optionalString(input, "status"),
162
174
  text: optionalString(input, "text"),
163
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"),
164
179
  });
165
180
  return {
166
181
  "system.migrate": () => migrate(),
182
+ "automation.status": () => automation.status(),
183
+ "automation.reconcile": () => automation.reconcile(),
167
184
  "artifact.create": (input) => {
168
185
  const normalized = normalizeCreateInput(input);
169
186
  if (normalized.kind !== "task") return artifacts.create(normalized);
@@ -176,6 +193,8 @@ function handlers(
176
193
  labels: normalized.labels,
177
194
  extra: normalized.extra,
178
195
  templateId: normalized.templateId,
196
+ projectRoot: string(input, "project_root"),
197
+ projectSource: "cwd",
179
198
  }, eventContextFor(input, "artifact-api"));
180
199
  },
181
200
  "artifact.query": (input) => artifacts.query(input),
@@ -211,7 +230,7 @@ function handlers(
211
230
  ? tasks.runGates(id, eventContextFor(input, "gates-api"))
212
231
  : gates.runAsync(id);
213
232
  },
214
- "rules.injectable": () => listInjectableRules(artifacts, tasks.active()?.id)
233
+ "rules.injectable": (input) => listInjectableRules(artifacts, tasks.active(taskFilter(input))?.id)
215
234
  .map(({ id, title, body, extra }) => ({ id, title, body, extra })),
216
235
  "tasks.create": (input) => tasks.create({
217
236
  title: string(input, "title"),
@@ -224,6 +243,8 @@ function handlers(
224
243
  templateId: optionalString(input, "template_id") ?? optionalString(input, "templateId"),
225
244
  parentId: optionalString(input, "parent_id") ?? optionalString(input, "parentId"),
226
245
  dependsOn: (input["depends_on"] ?? input["dependsOn"]) as string[] | undefined,
246
+ projectRoot: string(input, "project_root"),
247
+ projectSource: "cwd",
227
248
  }, eventContext(input)),
228
249
  "tasks.list": (input) => tasks.list(taskFilter(input)),
229
250
  "tasks.graph": (input) => tasks.graph(taskFilter(input)),
@@ -234,14 +255,29 @@ function handlers(
234
255
  cursor: optionalNumber(input, "cursor"),
235
256
  direction: optionalString(input, "direction") as TaskEventDirection | undefined,
236
257
  }),
237
- "tasks.active": () => tasks.active(),
258
+ "tasks.scope": (input) => tasks.scopeSelection(string(input, "project_root")),
259
+ "tasks.set_scope": (input) => tasks.setView(
260
+ string(input, "project_root"),
261
+ string(input, "scope") as TaskViewMode,
262
+ optionalString(input, "root_task_id"),
263
+ ),
264
+ "tasks.assign_project": (input) => tasks.assignProject(
265
+ string(input, "id"),
266
+ string(input, "project_root"),
267
+ eventContext(input),
268
+ ),
269
+ "tasks.active": (input) => tasks.active(taskFilter(input)),
238
270
  "tasks.focus": (input) => tasks.focus(string(input, "id")),
239
271
  "tasks.start": (input) => tasks.transition(string(input, "id"), "start", eventContext(input)),
240
272
  "tasks.submit": (input) => tasks.transition(string(input, "id"), "submit", eventContext(input)),
241
273
  "tasks.complete": (input) => tasks.completeAsync(string(input, "id"), eventContext(input)),
242
274
  "tasks.run_gates": (input) => tasks.runGates(string(input, "id"), eventContext(input)),
243
275
  "tasks.set_checklist": (input) => tasks.setChecklist(string(input, "id"), input["checklist"] as Checklist),
244
- "tasks.context": () => taskContext(artifacts, tasks.active()?.id),
276
+ "tasks.set_automation": (input) => {
277
+ if (typeof input["enabled"] !== "boolean") throw new Error("enabled must be a boolean");
278
+ return tasks.setAutomation(string(input, "id"), input["enabled"], eventContext(input));
279
+ },
280
+ "tasks.context": (input) => taskContext(artifacts, tasks.active()?.id, new Set(tasks.list(taskFilter(input)).map((task) => task.id))),
245
281
  "tasks.reject": (input) => tasks.transition(string(input, "id"), "reject", eventContext(input)),
246
282
  "tasks.retry": (input) => tasks.transition(string(input, "id"), "retry", eventContext(input)),
247
283
  "tasks.cancel": (input) => tasks.transition(string(input, "id"), "cancel", eventContext(input)),
@@ -286,21 +322,37 @@ function handlers(
286
322
  "skills.run": (input) => instantiateSkillWorkflow(artifacts, string(input, "id"), {
287
323
  runId: optionalString(input, "run_id") ?? optionalString(input, "runId"),
288
324
  arguments: input["arguments"] as Record<string, unknown> | undefined,
289
- }, { events, context: eventContextFor(input, "skill-run") }),
325
+ }, { events, scopes, projectRoot: string(input, "project_root"), context: eventContextFor(input, "skill-run") }),
290
326
  "skills.enable": (input) => transitionSkill(artifacts, string(input, "id"), "enable"),
291
327
  "skills.disable": (input) => transitionSkill(artifacts, string(input, "id"), "disable"),
292
- "skills.instantiate": (input) => instantiateTemplate(artifacts, string(input, "template_id"), normalizeCreateInput(input)),
328
+ "skills.instantiate": (input) => {
329
+ const templateId = string(input, "template_id");
330
+ const template = artifacts.get(templateId);
331
+ if (template?.extra["targetKind"] !== "task") return instantiateTemplate(artifacts, templateId, normalizeCreateInput(input));
332
+ return tasks.create({
333
+ title: optionalString(input, "title") as string,
334
+ body: optionalString(input, "body"),
335
+ status: optionalString(input, "status") as TaskStatus | undefined,
336
+ labels: input["labels"] as string[] | undefined,
337
+ extra: input["extra"] as Record<string, unknown> | undefined,
338
+ templateId,
339
+ projectRoot: string(input, "project_root"),
340
+ projectSource: "cwd",
341
+ }, eventContextFor(input, "template-instantiation"));
342
+ },
293
343
  };
294
344
  }
295
345
 
296
- export function createPapyrusService(path: string): PapyrusService {
346
+ export function createPapyrusService(path: string, options: { automation?: TaskAutomationSettings } = {}): PapyrusService {
297
347
  const db = openDb(path);
298
348
  const artifacts = new SQLiteArtifactStore(db);
299
349
  const gates = new SQLiteGateRunner(db);
300
350
  const focus = new SQLiteTaskFocusStore(db);
301
351
  const events = new SQLiteTaskEventStore(db);
302
- const tasks = new Tasks(artifacts, gates, focus, events);
303
- const registry = handlers(artifacts, gates, tasks, events, () => migrateDb(db));
352
+ const scopes = new SQLiteTaskScopeStore(db);
353
+ const tasks = new Tasks(artifacts, gates, focus, events, scopes);
354
+ const automation = new TaskAutomationReconciler(tasks, options.automation ?? taskAutomationSettings({}));
355
+ const registry = handlers(artifacts, gates, tasks, automation, events, scopes, () => migrateDb(db));
304
356
  const state = (): SchemaState => {
305
357
  const current = schemaVersion(db);
306
358
  return { current, required: SQLITE_SCHEMA_VERSION, migrationRequired: current !== SQLITE_SCHEMA_VERSION };
@@ -311,8 +363,8 @@ export function createPapyrusService(path: string): PapyrusService {
311
363
  async execute(operation, input = {}) {
312
364
  const handler = registry[operation as OperationName];
313
365
  if (!handler) throw new UnknownOperationError(`unknown operation "${operation}"`);
314
- if (operation !== "system.migrate" && state().migrationRequired) {
315
- throw new MigrationRequiredError("database migration required; run `papyrus migrate task-history`");
366
+ if (operation !== "system.migrate" && operation !== "automation.status" && state().migrationRequired) {
367
+ throw new MigrationRequiredError("database migration required; run `papyrus migrate task-scope`");
316
368
  }
317
369
  return handler(input);
318
370
  },
@@ -11,6 +11,8 @@ import {
11
11
  import type { ArtifactStore } from "./ports/artifact-store.ts";
12
12
  import type { TaskEventContext } from "./domain/task-event.ts";
13
13
  import type { TaskEventStore } from "./ports/task-event-store.ts";
14
+ import type { TaskScopeStore } from "./ports/task-scope-store.ts";
15
+ import { normalizeProjectRoot } from "./domain/task-scope.ts";
14
16
  import { requireAtomicArtifactStore } from "./ports/atomic-artifact-store.ts";
15
17
  import { projectTaskExecution, type TaskExecutionPlan } from "./task-execution.ts";
16
18
  import type { TaskGraph, TaskNode, TaskStatus } from "./task-service.ts";
@@ -117,9 +119,10 @@ export function instantiateSkillWorkflow(
117
119
  artifacts: ArtifactStore,
118
120
  skillId: string,
119
121
  input: InstantiateSkillWorkflowInput = {},
120
- history?: { events: TaskEventStore; context?: TaskEventContext },
122
+ history?: { events: TaskEventStore; scopes: TaskScopeStore; projectRoot: string; context?: TaskEventContext },
121
123
  ): SkillWorkflowRunResult {
122
124
  const { definition } = requireWorkflowSkill(artifacts, skillId);
125
+ const projectRoot = history ? normalizeProjectRoot(history.projectRoot) : undefined;
123
126
  const arguments_ = resolveSkillArguments(definition, input.arguments);
124
127
  const rendered = renderDefinition(definition, arguments_);
125
128
  const runId = normalizeRunId(skillId, input.runId);
@@ -175,15 +178,18 @@ export function instantiateSkillWorkflow(
175
178
  labels: withRunLabel(blueprint.labels, runId),
176
179
  extra: { ...(blueprint.extra ?? {}), skillRun: { id: runId, skillId, ref: blueprint.ref } },
177
180
  });
178
- if (history) history.events.append({
179
- taskId: task.id,
180
- type: "created",
181
- actor: history.context?.actor ?? "system",
182
- source: history.context?.source ?? "skill-run",
183
- toStatus: task.status as TaskStatus,
184
- ...(history.context?.sessionId === undefined ? {} : { sessionId: history.context.sessionId }),
185
- ...(history.context?.reason === undefined ? {} : { reason: history.context.reason }),
186
- });
181
+ if (history) {
182
+ history.scopes.assign(task.id, projectRoot, "cwd");
183
+ history.events.append({
184
+ taskId: task.id,
185
+ type: "created",
186
+ actor: history.context?.actor ?? "system",
187
+ source: history.context?.source ?? "skill-run",
188
+ toStatus: task.status as TaskStatus,
189
+ ...(history.context?.sessionId === undefined ? {} : { sessionId: history.context.sessionId }),
190
+ ...(history.context?.reason === undefined ? {} : { reason: history.context.reason }),
191
+ });
192
+ }
187
193
  return task;
188
194
  });
189
195
 
@@ -0,0 +1,188 @@
1
+ import {
2
+ TASK_AUTOMATION_ERROR_ID_MAX_LENGTH,
3
+ TASK_AUTOMATION_ERROR_MESSAGE_MAX_LENGTH,
4
+ TASK_AUTOMATION_GATE_CONCURRENCY,
5
+ TASK_AUTOMATION_HARD_MAX_RUNTIME_MS,
6
+ TASK_AUTOMATION_HARD_MAX_TASKS_PER_SWEEP,
7
+ TASK_AUTOMATION_INTERVAL_MS,
8
+ TASK_AUTOMATION_MAX_CANDIDATE_SCAN,
9
+ TASK_AUTOMATION_MAX_GATE_CONCURRENCY,
10
+ TASK_AUTOMATION_MAX_INTERVAL_MS,
11
+ TASK_AUTOMATION_MAX_RUNTIME_MS,
12
+ TASK_AUTOMATION_MAX_TASKS_PER_SWEEP,
13
+ TASK_AUTOMATION_MIN_INTERVAL_MS,
14
+ } from "./constants.ts";
15
+ import type { Artifact } from "./domain/artifact.ts";
16
+ import { projectTaskExecution } from "./task-execution.ts";
17
+ import type { Tasks } from "./task-service.ts";
18
+
19
+ export interface TaskAutomationSettings {
20
+ enabled: boolean;
21
+ intervalMs: number;
22
+ maxTasksPerSweep: number;
23
+ gateConcurrency: number;
24
+ maxRuntimeMs: number;
25
+ }
26
+
27
+ export interface TaskAutomationResult {
28
+ skipped?: "disabled" | "in-flight";
29
+ examined: number;
30
+ completed: number;
31
+ rejected: number;
32
+ started: number;
33
+ errors: Array<{ taskId: string; message: string }>;
34
+ timedOut: boolean;
35
+ }
36
+
37
+ function boundedInteger(
38
+ env: Record<string, string | undefined>,
39
+ name: string,
40
+ fallback: number,
41
+ minimum: number,
42
+ maximum: number,
43
+ ): number {
44
+ const source = env[name];
45
+ if (source === undefined || source === "") return fallback;
46
+ const value = Number(source);
47
+ if (!Number.isInteger(value) || value < minimum || value > maximum) {
48
+ throw new Error(`${name} must be an integer between ${minimum} and ${maximum}`);
49
+ }
50
+ return value;
51
+ }
52
+
53
+ export function taskAutomationSettings(env: Record<string, string | undefined> = process.env): TaskAutomationSettings {
54
+ const enabled = env["PAPYRUS_AUTOMATION_ENABLED"] === "1";
55
+ if (env["PAPYRUS_AUTOMATION_ENABLED"] !== undefined && env["PAPYRUS_AUTOMATION_ENABLED"] !== "0" && !enabled) {
56
+ throw new Error("PAPYRUS_AUTOMATION_ENABLED must be 0 or 1");
57
+ }
58
+ return {
59
+ enabled,
60
+ intervalMs: boundedInteger(env, "PAPYRUS_AUTOMATION_INTERVAL_MS", TASK_AUTOMATION_INTERVAL_MS, TASK_AUTOMATION_MIN_INTERVAL_MS, TASK_AUTOMATION_MAX_INTERVAL_MS),
61
+ maxTasksPerSweep: boundedInteger(env, "PAPYRUS_AUTOMATION_MAX_TASKS", TASK_AUTOMATION_MAX_TASKS_PER_SWEEP, 1, TASK_AUTOMATION_HARD_MAX_TASKS_PER_SWEEP),
62
+ gateConcurrency: boundedInteger(env, "PAPYRUS_AUTOMATION_GATE_CONCURRENCY", TASK_AUTOMATION_GATE_CONCURRENCY, 1, TASK_AUTOMATION_MAX_GATE_CONCURRENCY),
63
+ maxRuntimeMs: boundedInteger(env, "PAPYRUS_AUTOMATION_MAX_RUNTIME_MS", TASK_AUTOMATION_MAX_RUNTIME_MS, 1, TASK_AUTOMATION_HARD_MAX_RUNTIME_MS),
64
+ };
65
+ }
66
+
67
+ function automationEnabled(task: Artifact): boolean {
68
+ const automation = task.extra["automation"];
69
+ return typeof automation === "object"
70
+ && automation !== null
71
+ && !Array.isArray(automation)
72
+ && (automation as Record<string, unknown>)["enabled"] === true;
73
+ }
74
+
75
+ function emptyResult(skipped?: TaskAutomationResult["skipped"]): TaskAutomationResult {
76
+ return { ...(skipped ? { skipped } : {}), examined: 0, completed: 0, rejected: 0, started: 0, errors: [], timedOut: false };
77
+ }
78
+
79
+ function boundedError(taskId: string, error: unknown): TaskAutomationResult["errors"][number] {
80
+ const message = error instanceof Error ? error.message : String(error);
81
+ return {
82
+ taskId: taskId.slice(0, TASK_AUTOMATION_ERROR_ID_MAX_LENGTH),
83
+ message: message.slice(0, TASK_AUTOMATION_ERROR_MESSAGE_MAX_LENGTH),
84
+ };
85
+ }
86
+
87
+ export interface TaskAutomationScheduler {
88
+ setInterval(callback: () => void, intervalMs: number): unknown;
89
+ clearInterval(handle: unknown): void;
90
+ }
91
+
92
+ const SYSTEM_SCHEDULER: TaskAutomationScheduler = {
93
+ setInterval: (callback, intervalMs) => setInterval(callback, intervalMs),
94
+ clearInterval: (handle) => clearInterval(handle as ReturnType<typeof setInterval>),
95
+ };
96
+
97
+ export function scheduleTaskAutomation(
98
+ settings: TaskAutomationSettings,
99
+ sweep: () => Promise<unknown>,
100
+ onError: (error: unknown) => void,
101
+ scheduler: TaskAutomationScheduler = SYSTEM_SCHEDULER,
102
+ ): () => void {
103
+ if (!settings.enabled) return () => {};
104
+ const handle = scheduler.setInterval(() => { void sweep().catch(onError); }, settings.intervalMs);
105
+ return () => scheduler.clearInterval(handle);
106
+ }
107
+
108
+ export class TaskAutomationReconciler {
109
+ private inFlight = false;
110
+
111
+ constructor(
112
+ private readonly tasks: Tasks,
113
+ private readonly settings: TaskAutomationSettings,
114
+ private readonly now: () => number = () => Date.now(),
115
+ ) {}
116
+
117
+ status(): TaskAutomationSettings & { inFlight: boolean } {
118
+ return { ...this.settings, inFlight: this.inFlight };
119
+ }
120
+
121
+ async reconcile(): Promise<TaskAutomationResult> {
122
+ if (!this.settings.enabled) return emptyResult("disabled");
123
+ if (this.inFlight) return emptyResult("in-flight");
124
+ this.inFlight = true;
125
+ try { return await this.runSweep(); }
126
+ finally { this.inFlight = false; }
127
+ }
128
+
129
+ private async runSweep(): Promise<TaskAutomationResult> {
130
+ const result = emptyResult();
131
+ const deadline = this.now() + this.settings.maxRuntimeMs;
132
+ const candidates = this.tasks.list({ status: "review", limit: TASK_AUTOMATION_MAX_CANDIDATE_SCAN })
133
+ .filter(automationEnabled)
134
+ .sort((left, right) => left.id.localeCompare(right.id))
135
+ .slice(0, this.settings.maxTasksPerSweep);
136
+ const completedIds = new Set<string>();
137
+
138
+ for (let offset = 0; offset < candidates.length; offset += this.settings.gateConcurrency) {
139
+ if (this.now() >= deadline) { result.timedOut = true; break; }
140
+ const batch = candidates.slice(offset, offset + this.settings.gateConcurrency);
141
+ await Promise.all(batch.map(async (task) => {
142
+ result.examined += 1;
143
+ try {
144
+ const completion = await this.tasks.completeAsync(task.id, {
145
+ actor: "daemon",
146
+ source: "automation-reconciler",
147
+ reason: "automation-enabled review reconciliation",
148
+ }, { focusSuccessor: false, gateDeadlineMs: deadline });
149
+ if (completion.completed) {
150
+ result.completed += 1;
151
+ completedIds.add(task.id);
152
+ } else result.rejected += 1;
153
+ } catch (error) {
154
+ result.errors.push(boundedError(task.id, error));
155
+ }
156
+ }));
157
+ }
158
+
159
+ let remaining = Math.max(0, this.settings.maxTasksPerSweep - result.examined);
160
+ if (remaining > 0 && completedIds.size > 0 && this.now() < deadline) {
161
+ let graph: ReturnType<Tasks["graph"]>;
162
+ try { graph = this.tasks.graph(); }
163
+ catch (error) {
164
+ result.errors.push(boundedError("graph", error));
165
+ return result;
166
+ }
167
+ const stateById = new Map(projectTaskExecution(graph).nodes.map((node) => [node.id, node.state]));
168
+ for (const node of [...graph.nodes].sort((left, right) => left.task.id.localeCompare(right.task.id))) {
169
+ if (remaining === 0 || this.now() >= deadline) break;
170
+ if (node.task.status !== "todo" || !automationEnabled(node.task) || stateById.get(node.task.id) !== "ready") continue;
171
+ if (!node.dependencyIds.some((id) => completedIds.has(id))) continue;
172
+ try {
173
+ this.tasks.transition(node.task.id, "start", {
174
+ actor: "daemon",
175
+ source: "automation-reconciler",
176
+ reason: "automation-enabled successor became ready",
177
+ });
178
+ result.started += 1;
179
+ remaining -= 1;
180
+ } catch (error) {
181
+ result.errors.push(boundedError(node.task.id, error));
182
+ }
183
+ }
184
+ }
185
+ if (this.now() >= deadline) result.timedOut = true;
186
+ return result;
187
+ }
188
+ }
@@ -34,8 +34,10 @@ function renderCurrent(task: Artifact): string[] {
34
34
  ];
35
35
  }
36
36
 
37
- export function taskContext(artifacts: ArtifactStore, activeTaskId?: string): string | null {
38
- const tasks = artifacts.query({ kind: "task" }).sort((left, right) => left.updated_at.localeCompare(right.updated_at));
37
+ export function taskContext(artifacts: ArtifactStore, activeTaskId?: string, taskIds?: Set<string>): string | null {
38
+ const tasks = artifacts.query({ kind: "task" })
39
+ .filter((task) => taskIds === undefined || taskIds.has(task.id))
40
+ .sort((left, right) => left.updated_at.localeCompare(right.updated_at));
39
41
  const open = tasks.filter((task) => task.status !== "done" && task.status !== "canceled");
40
42
  if (open.length === 0) return null;
41
43