@tashks/core 0.1.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.
@@ -0,0 +1,742 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { spawnSync } from "node:child_process";
3
+ import { constants as fsConstants } from "node:fs";
4
+ import { access, mkdir, readdir, readFile, rm, writeFile, } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import * as Context from "effect/Context";
7
+ import * as Either from "effect/Either";
8
+ import * as Effect from "effect/Effect";
9
+ import * as Layer from "effect/Layer";
10
+ import * as Schema from "effect/Schema";
11
+ import { Frequency, RRule, rrulestr } from "rrule";
12
+ import YAML from "yaml";
13
+ import { Task as TaskSchema, TaskCreateInput as TaskCreateInputSchema, TaskPatch as TaskPatchSchema, WorkLogCreateInput as WorkLogCreateInputSchema, WorkLogEntry as WorkLogEntrySchema, WorkLogPatch as WorkLogPatchSchema, } from "./schema.js";
14
+ import { byUpdatedDescThenTitle, isDueBefore, isStalerThan, isUnblocked, } from "./query.js";
15
+ const idSuffixAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
16
+ const idSuffixLength = 6;
17
+ const slugifyTitle = (title) => {
18
+ const slug = title
19
+ .toLowerCase()
20
+ .normalize("NFKD")
21
+ .replace(/[\u0300-\u036f]/g, "")
22
+ .replace(/[^a-z0-9]+/g, "-")
23
+ .replace(/^-+|-+$/g, "");
24
+ return slug.length > 0 ? slug : "task";
25
+ };
26
+ const randomIdSuffix = () => {
27
+ const random = randomBytes(idSuffixLength);
28
+ return Array.from(random, (value) => idSuffixAlphabet[value % idSuffixAlphabet.length]).join("");
29
+ };
30
+ const decodeTask = Schema.decodeUnknownSync(TaskSchema);
31
+ const decodeTaskEither = Schema.decodeUnknownEither(TaskSchema);
32
+ const decodeTaskCreateInput = Schema.decodeUnknownSync(TaskCreateInputSchema);
33
+ const decodeTaskPatch = Schema.decodeUnknownSync(TaskPatchSchema);
34
+ const decodeWorkLogCreateInput = Schema.decodeUnknownSync(WorkLogCreateInputSchema);
35
+ const decodeWorkLogEntry = Schema.decodeUnknownSync(WorkLogEntrySchema);
36
+ const decodeWorkLogEntryEither = Schema.decodeUnknownEither(WorkLogEntrySchema);
37
+ const decodeWorkLogPatch = Schema.decodeUnknownSync(WorkLogPatchSchema);
38
+ const toErrorMessage = (error) => error instanceof Error ? error.message : String(error);
39
+ const taskFilePath = (dataDir, id) => join(dataDir, "tasks", `${id}.yaml`);
40
+ const legacyTaskFilePath = (dataDir, id) => join(dataDir, "tasks", `${id}.yml`);
41
+ const workLogFilePath = (dataDir, id) => join(dataDir, "work-log", `${id}.yaml`);
42
+ const legacyWorkLogFilePath = (dataDir, id) => join(dataDir, "work-log", `${id}.yml`);
43
+ const dailyHighlightFilePath = (dataDir) => join(dataDir, "daily-highlight.yaml");
44
+ const ensureTasksDir = (dataDir) => Effect.tryPromise({
45
+ try: () => mkdir(join(dataDir, "tasks"), { recursive: true }),
46
+ catch: (error) => `TaskRepository failed to create tasks directory: ${toErrorMessage(error)}`,
47
+ });
48
+ const ensureWorkLogDir = (dataDir) => Effect.tryPromise({
49
+ try: () => mkdir(join(dataDir, "work-log"), { recursive: true }),
50
+ catch: (error) => `TaskRepository failed to create work-log directory: ${toErrorMessage(error)}`,
51
+ });
52
+ const writeDailyHighlightToDisk = (dataDir, id) => Effect.tryPromise({
53
+ try: async () => {
54
+ await mkdir(dataDir, { recursive: true });
55
+ await writeFile(dailyHighlightFilePath(dataDir), YAML.stringify({ id }), "utf8");
56
+ },
57
+ catch: (error) => `TaskRepository failed to write daily highlight ${id}: ${toErrorMessage(error)}`,
58
+ });
59
+ const readTaskByIdFromDisk = (dataDir, id) => Effect.tryPromise({
60
+ try: async () => {
61
+ const candidatePaths = [
62
+ taskFilePath(dataDir, id),
63
+ legacyTaskFilePath(dataDir, id),
64
+ ];
65
+ for (const path of candidatePaths) {
66
+ const source = await readFile(path, "utf8").catch((error) => {
67
+ if (error !== null &&
68
+ typeof error === "object" &&
69
+ "code" in error &&
70
+ error.code === "ENOENT") {
71
+ return null;
72
+ }
73
+ throw error;
74
+ });
75
+ if (source === null) {
76
+ continue;
77
+ }
78
+ const parsed = YAML.parse(source);
79
+ const task = parseTaskRecord(parsed);
80
+ if (task === null) {
81
+ throw new Error(`Invalid task record in ${path}`);
82
+ }
83
+ return { path, task };
84
+ }
85
+ throw new Error(`Task not found: ${id}`);
86
+ },
87
+ catch: (error) => `TaskRepository failed to read task ${id}: ${toErrorMessage(error)}`,
88
+ });
89
+ const writeTaskToDisk = (path, task) => Effect.tryPromise({
90
+ try: () => writeFile(path, YAML.stringify(task), "utf8"),
91
+ catch: (error) => `TaskRepository failed to write task ${task.id}: ${toErrorMessage(error)}`,
92
+ });
93
+ const deleteTaskFromDisk = (path, id) => Effect.tryPromise({
94
+ try: () => rm(path),
95
+ catch: (error) => `TaskRepository failed to delete task ${id}: ${toErrorMessage(error)}`,
96
+ });
97
+ const readTasksFromDisk = (dataDir) => Effect.tryPromise({
98
+ try: async () => {
99
+ const tasksDir = join(dataDir, "tasks");
100
+ const entries = await readdir(tasksDir, { withFileTypes: true }).catch((error) => {
101
+ if (error !== null &&
102
+ typeof error === "object" &&
103
+ "code" in error &&
104
+ error.code === "ENOENT") {
105
+ return [];
106
+ }
107
+ throw error;
108
+ });
109
+ const taskFiles = entries
110
+ .filter((entry) => entry.isFile() &&
111
+ (entry.name.endsWith(".yaml") || entry.name.endsWith(".yml")))
112
+ .map((entry) => entry.name);
113
+ const tasks = [];
114
+ for (const fileName of taskFiles) {
115
+ const filePath = join(tasksDir, fileName);
116
+ const source = await readFile(filePath, "utf8");
117
+ const parsed = YAML.parse(source);
118
+ const task = parseTaskRecord(parsed);
119
+ if (task === null) {
120
+ throw new Error(`Invalid task record in ${filePath}`);
121
+ }
122
+ tasks.push(task);
123
+ }
124
+ return tasks;
125
+ },
126
+ catch: (error) => `TaskRepository.listTasks failed to read task files: ${toErrorMessage(error)}`,
127
+ });
128
+ const readWorkLogEntryByIdFromDisk = (dataDir, id) => Effect.tryPromise({
129
+ try: async () => {
130
+ const candidatePaths = [
131
+ workLogFilePath(dataDir, id),
132
+ legacyWorkLogFilePath(dataDir, id),
133
+ ];
134
+ for (const path of candidatePaths) {
135
+ const source = await readFile(path, "utf8").catch((error) => {
136
+ if (error !== null &&
137
+ typeof error === "object" &&
138
+ "code" in error &&
139
+ error.code === "ENOENT") {
140
+ return null;
141
+ }
142
+ throw error;
143
+ });
144
+ if (source === null) {
145
+ continue;
146
+ }
147
+ const parsed = YAML.parse(source);
148
+ const entry = parseWorkLogRecord(parsed);
149
+ if (entry === null) {
150
+ throw new Error(`Invalid work log record in ${path}`);
151
+ }
152
+ return { path, entry };
153
+ }
154
+ throw new Error(`Work log entry not found: ${id}`);
155
+ },
156
+ catch: (error) => `TaskRepository failed to read work log entry ${id}: ${toErrorMessage(error)}`,
157
+ });
158
+ const readWorkLogEntriesFromDisk = (dataDir) => Effect.tryPromise({
159
+ try: async () => {
160
+ const workLogDir = join(dataDir, "work-log");
161
+ const entries = await readdir(workLogDir, { withFileTypes: true }).catch((error) => {
162
+ if (error !== null &&
163
+ typeof error === "object" &&
164
+ "code" in error &&
165
+ error.code === "ENOENT") {
166
+ return [];
167
+ }
168
+ throw error;
169
+ });
170
+ const workLogFiles = entries
171
+ .filter((entry) => entry.isFile() &&
172
+ (entry.name.endsWith(".yaml") || entry.name.endsWith(".yml")))
173
+ .map((entry) => entry.name);
174
+ const workLogEntries = [];
175
+ for (const fileName of workLogFiles) {
176
+ const filePath = join(workLogDir, fileName);
177
+ const source = await readFile(filePath, "utf8");
178
+ const parsed = YAML.parse(source);
179
+ const workLogEntry = parseWorkLogRecord(parsed);
180
+ if (workLogEntry === null) {
181
+ throw new Error(`Invalid work log record in ${filePath}`);
182
+ }
183
+ workLogEntries.push(workLogEntry);
184
+ }
185
+ return workLogEntries;
186
+ },
187
+ catch: (error) => `TaskRepository.listWorkLog failed to read work log files: ${toErrorMessage(error)}`,
188
+ });
189
+ const writeWorkLogEntryToDisk = (path, entry) => Effect.tryPromise({
190
+ try: () => writeFile(path, YAML.stringify(entry), "utf8"),
191
+ catch: (error) => `TaskRepository failed to write work log entry ${entry.id}: ${toErrorMessage(error)}`,
192
+ });
193
+ const deleteWorkLogEntryFromDisk = (path, id) => Effect.tryPromise({
194
+ try: () => rm(path),
195
+ catch: (error) => `TaskRepository failed to delete work log entry ${id}: ${toErrorMessage(error)}`,
196
+ });
197
+ const toWorkLogTimestamp = (startedAt) => Effect.try({
198
+ try: () => {
199
+ const parsed = new Date(startedAt);
200
+ if (Number.isNaN(parsed.getTime())) {
201
+ throw new Error(`Invalid started_at: ${startedAt}`);
202
+ }
203
+ const iso = parsed.toISOString();
204
+ return `${iso.slice(0, 4)}${iso.slice(5, 7)}${iso.slice(8, 10)}T${iso.slice(11, 13)}${iso.slice(14, 16)}`;
205
+ },
206
+ catch: (error) => `TaskRepository failed to derive work log timestamp: ${toErrorMessage(error)}`,
207
+ });
208
+ const toWorkLogDate = (startedAt) => Effect.try({
209
+ try: () => {
210
+ const parsed = new Date(startedAt);
211
+ if (Number.isNaN(parsed.getTime())) {
212
+ throw new Error(`Invalid started_at: ${startedAt}`);
213
+ }
214
+ return parsed.toISOString().slice(0, 10);
215
+ },
216
+ catch: (error) => `TaskRepository failed to derive work log date: ${toErrorMessage(error)}`,
217
+ });
218
+ const completionRecurrenceFrequencies = new Map([
219
+ [Frequency.DAILY, "DAILY"],
220
+ [Frequency.WEEKLY, "WEEKLY"],
221
+ [Frequency.MONTHLY, "MONTHLY"],
222
+ [Frequency.YEARLY, "YEARLY"],
223
+ ]);
224
+ const parseCompletionRecurrenceInterval = (recurrence) => Effect.try({
225
+ try: () => {
226
+ const parsedRule = rrulestr(recurrence, { forceset: false });
227
+ if (!(parsedRule instanceof RRule)) {
228
+ throw new Error("Unsupported completion recurrence: expected a single RRULE");
229
+ }
230
+ const frequency = completionRecurrenceFrequencies.get(parsedRule.options.freq);
231
+ if (frequency === undefined) {
232
+ const frequencyLabel = Frequency[parsedRule.options.freq] ?? String(parsedRule.options.freq);
233
+ throw new Error(`Unsupported completion recurrence frequency: ${frequencyLabel}`);
234
+ }
235
+ const interval = parsedRule.options.interval;
236
+ if (!Number.isFinite(interval) || interval < 1) {
237
+ throw new Error(`Invalid recurrence interval: ${String(interval)}`);
238
+ }
239
+ return { frequency, interval };
240
+ },
241
+ catch: (error) => `TaskRepository failed to parse recurrence interval: ${toErrorMessage(error)}`,
242
+ });
243
+ const addRecurrenceInterval = (date, recurrenceInterval) => {
244
+ const next = new Date(date.getTime());
245
+ switch (recurrenceInterval.frequency) {
246
+ case "DAILY":
247
+ next.setUTCDate(next.getUTCDate() + recurrenceInterval.interval);
248
+ break;
249
+ case "WEEKLY":
250
+ next.setUTCDate(next.getUTCDate() + recurrenceInterval.interval * 7);
251
+ break;
252
+ case "MONTHLY":
253
+ next.setUTCMonth(next.getUTCMonth() + recurrenceInterval.interval);
254
+ break;
255
+ case "YEARLY":
256
+ next.setUTCFullYear(next.getUTCFullYear() + recurrenceInterval.interval);
257
+ break;
258
+ }
259
+ return next;
260
+ };
261
+ const shiftIsoDateByRecurrenceInterval = (date, recurrenceInterval) => Effect.try({
262
+ try: () => {
263
+ const parsed = new Date(`${date}T00:00:00.000Z`);
264
+ if (Number.isNaN(parsed.getTime())) {
265
+ throw new Error(`Invalid ISO date: ${date}`);
266
+ }
267
+ return addRecurrenceInterval(parsed, recurrenceInterval)
268
+ .toISOString()
269
+ .slice(0, 10);
270
+ },
271
+ catch: (error) => `TaskRepository failed to shift ISO date: ${toErrorMessage(error)}`,
272
+ });
273
+ const shiftIsoDateTimeToDateByRecurrenceInterval = (dateTime, recurrenceInterval) => Effect.try({
274
+ try: () => {
275
+ const parsed = new Date(dateTime);
276
+ if (Number.isNaN(parsed.getTime())) {
277
+ throw new Error(`Invalid ISO datetime: ${dateTime}`);
278
+ }
279
+ return addRecurrenceInterval(parsed, recurrenceInterval)
280
+ .toISOString()
281
+ .slice(0, 10);
282
+ },
283
+ catch: (error) => `TaskRepository failed to shift ISO datetime: ${toErrorMessage(error)}`,
284
+ });
285
+ const buildCompletionRecurrenceTask = (completedTask, completedAt) => {
286
+ const recurrence = completedTask.recurrence;
287
+ if (recurrence === null ||
288
+ completedTask.recurrence_trigger !== "completion") {
289
+ return Effect.succeed(null);
290
+ }
291
+ return Effect.gen(function* () {
292
+ const recurrenceInterval = yield* parseCompletionRecurrenceInterval(recurrence);
293
+ const deferUntil = yield* shiftIsoDateTimeToDateByRecurrenceInterval(completedAt, recurrenceInterval);
294
+ const shiftedDue = completedTask.due === null
295
+ ? null
296
+ : yield* shiftIsoDateByRecurrenceInterval(completedTask.due, recurrenceInterval);
297
+ const completedDate = completedAt.slice(0, 10);
298
+ return decodeTask({
299
+ ...completedTask,
300
+ id: generateTaskId(completedTask.title),
301
+ status: "active",
302
+ created: completedDate,
303
+ updated: completedDate,
304
+ due: shiftedDue,
305
+ actual_minutes: null,
306
+ completed_at: null,
307
+ last_surfaced: null,
308
+ defer_until: deferUntil,
309
+ nudge_count: 0,
310
+ recurrence_last_generated: completedAt,
311
+ });
312
+ });
313
+ };
314
+ const toIsoDateTime = (value, label) => Effect.try({
315
+ try: () => {
316
+ const timestamp = value.getTime();
317
+ if (Number.isNaN(timestamp)) {
318
+ throw new Error(`Invalid ${label} datetime`);
319
+ }
320
+ return value.toISOString();
321
+ },
322
+ catch: (error) => `TaskRepository failed to normalize ${label} datetime: ${toErrorMessage(error)}`,
323
+ });
324
+ const parseIsoDateToUtcStart = (value, label) => Effect.try({
325
+ try: () => {
326
+ const parsed = new Date(`${value}T00:00:00.000Z`);
327
+ if (Number.isNaN(parsed.getTime())) {
328
+ throw new Error(`Invalid ISO date: ${value}`);
329
+ }
330
+ return parsed;
331
+ },
332
+ catch: (error) => `TaskRepository failed to parse ${label} date: ${toErrorMessage(error)}`,
333
+ });
334
+ const parseIsoDateTime = (value, label) => Effect.try({
335
+ try: () => {
336
+ const parsed = new Date(value);
337
+ if (Number.isNaN(parsed.getTime())) {
338
+ throw new Error(`Invalid ISO datetime: ${value}`);
339
+ }
340
+ return parsed;
341
+ },
342
+ catch: (error) => `TaskRepository failed to parse ${label} datetime: ${toErrorMessage(error)}`,
343
+ });
344
+ const isClockRecurrenceDue = (task, now) => Effect.gen(function* () {
345
+ const recurrence = task.recurrence;
346
+ if (recurrence === null || task.recurrence_trigger !== "clock") {
347
+ return false;
348
+ }
349
+ const nowIso = yield* toIsoDateTime(now, "recurrence check");
350
+ const nowDate = new Date(nowIso);
351
+ const createdAt = yield* parseIsoDateToUtcStart(task.created, "task created");
352
+ const lastGeneratedAt = task.recurrence_last_generated === null
353
+ ? createdAt
354
+ : yield* parseIsoDateTime(task.recurrence_last_generated, "recurrence_last_generated");
355
+ const rule = yield* Effect.try({
356
+ try: () => rrulestr(recurrence, { dtstart: createdAt, forceset: false }),
357
+ catch: (error) => `TaskRepository failed to parse recurrence for ${task.id}: ${toErrorMessage(error)}`,
358
+ });
359
+ const nextOccurrence = rule.after(lastGeneratedAt, false);
360
+ return (nextOccurrence !== null && nextOccurrence.getTime() <= nowDate.getTime());
361
+ });
362
+ const generateNextClockRecurrence = (dataDir, existing, generatedAt) => Effect.gen(function* () {
363
+ const recurrence = existing.task.recurrence;
364
+ if (recurrence === null) {
365
+ return yield* Effect.fail(`TaskRepository failed to generate next recurrence for ${existing.task.id}: task is not recurring`);
366
+ }
367
+ const generatedAtIso = yield* toIsoDateTime(generatedAt, "recurrence generation");
368
+ const generatedDate = generatedAtIso.slice(0, 10);
369
+ const nextTask = decodeTask({
370
+ ...existing.task,
371
+ id: generateTaskId(existing.task.title),
372
+ status: "active",
373
+ created: generatedDate,
374
+ updated: generatedDate,
375
+ actual_minutes: null,
376
+ completed_at: null,
377
+ last_surfaced: null,
378
+ defer_until: null,
379
+ nudge_count: 0,
380
+ recurrence_last_generated: generatedAtIso,
381
+ });
382
+ let replacedId = null;
383
+ if (existing.task.recurrence_strategy === "replace" &&
384
+ existing.task.status !== "done" &&
385
+ existing.task.status !== "dropped") {
386
+ const droppedTask = decodeTask({
387
+ ...existing.task,
388
+ status: "dropped",
389
+ updated: generatedDate,
390
+ recurrence_last_generated: generatedAtIso,
391
+ });
392
+ yield* writeTaskToDisk(existing.path, droppedTask);
393
+ replacedId = existing.task.id;
394
+ }
395
+ if (existing.task.recurrence_strategy === "accumulate") {
396
+ const updatedCurrent = decodeTask({
397
+ ...existing.task,
398
+ updated: generatedDate,
399
+ recurrence_last_generated: generatedAtIso,
400
+ });
401
+ yield* writeTaskToDisk(existing.path, updatedCurrent);
402
+ }
403
+ yield* ensureTasksDir(dataDir);
404
+ yield* writeTaskToDisk(taskFilePath(dataDir, nextTask.id), nextTask);
405
+ return { nextTask, replacedId };
406
+ });
407
+ const byStartedAtDescThenId = (a, b) => {
408
+ const byStartedAtDesc = b.started_at.localeCompare(a.started_at);
409
+ if (byStartedAtDesc !== 0) {
410
+ return byStartedAtDesc;
411
+ }
412
+ return a.id.localeCompare(b.id);
413
+ };
414
+ const applyListTaskFilters = (tasks, filters = {}) => {
415
+ const dueBeforePredicate = filters.due_before !== undefined ? isDueBefore(filters.due_before) : null;
416
+ return tasks
417
+ .filter((task) => {
418
+ if (filters.status !== undefined && task.status !== filters.status) {
419
+ return false;
420
+ }
421
+ if (filters.area !== undefined && task.area !== filters.area) {
422
+ return false;
423
+ }
424
+ if (filters.project !== undefined && task.project !== filters.project) {
425
+ return false;
426
+ }
427
+ if (filters.tags !== undefined &&
428
+ filters.tags.length > 0 &&
429
+ !filters.tags.some((tag) => task.tags.includes(tag))) {
430
+ return false;
431
+ }
432
+ if (dueBeforePredicate !== null && !dueBeforePredicate(task)) {
433
+ return false;
434
+ }
435
+ if (filters.due_after !== undefined &&
436
+ (task.due === null || task.due < filters.due_after)) {
437
+ return false;
438
+ }
439
+ if (filters.date !== undefined &&
440
+ task.defer_until !== null &&
441
+ task.defer_until > filters.date) {
442
+ return false;
443
+ }
444
+ if (filters.unblocked_only === true && !isUnblocked(task, tasks)) {
445
+ return false;
446
+ }
447
+ return true;
448
+ })
449
+ .sort(byUpdatedDescThenTitle);
450
+ };
451
+ const defaultHooksDir = (env = process.env) => {
452
+ const xdgConfigHome = env.XDG_CONFIG_HOME;
453
+ if (xdgConfigHome !== undefined && xdgConfigHome.length > 0) {
454
+ return join(xdgConfigHome, "tashks", "hooks");
455
+ }
456
+ const home = env.HOME;
457
+ return home !== undefined && home.length > 0
458
+ ? join(home, ".config", "tashks", "hooks")
459
+ : join(".config", "tashks", "hooks");
460
+ };
461
+ const hookNamePattern = (event) => new RegExp(`^on-${event}(?:\\..+)?$`);
462
+ const isHookCandidate = (event, fileName) => hookNamePattern(event).test(fileName);
463
+ const isExecutableFile = (path) => Effect.tryPromise({
464
+ try: async () => {
465
+ try {
466
+ await access(path, fsConstants.X_OK);
467
+ return true;
468
+ }
469
+ catch (error) {
470
+ if (error !== null &&
471
+ typeof error === "object" &&
472
+ "code" in error &&
473
+ (error.code === "EACCES" ||
474
+ error.code === "EPERM" ||
475
+ error.code === "ENOENT")) {
476
+ return false;
477
+ }
478
+ throw error;
479
+ }
480
+ },
481
+ catch: (error) => `TaskRepository failed to inspect hook executable bit for ${path}: ${toErrorMessage(error)}`,
482
+ });
483
+ export const discoverHooksForEvent = (event, options = {}) => {
484
+ const hooksDir = options.hooksDir ?? defaultHooksDir(options.env);
485
+ return Effect.gen(function* () {
486
+ const entries = yield* Effect.tryPromise({
487
+ try: () => readdir(hooksDir, { withFileTypes: true }).catch((error) => {
488
+ if (error !== null &&
489
+ typeof error === "object" &&
490
+ "code" in error &&
491
+ error.code === "ENOENT") {
492
+ return [];
493
+ }
494
+ throw error;
495
+ }),
496
+ catch: (error) => `TaskRepository failed to read hooks directory ${hooksDir}: ${toErrorMessage(error)}`,
497
+ });
498
+ const candidatePaths = entries
499
+ .filter((entry) => (entry.isFile() || entry.isSymbolicLink()) &&
500
+ isHookCandidate(event, entry.name))
501
+ .map((entry) => join(hooksDir, entry.name))
502
+ .sort((a, b) => a.localeCompare(b));
503
+ const discovered = [];
504
+ for (const candidatePath of candidatePaths) {
505
+ const executable = yield* isExecutableFile(candidatePath);
506
+ if (executable) {
507
+ discovered.push(candidatePath);
508
+ }
509
+ }
510
+ return discovered;
511
+ });
512
+ };
513
+ const parseTaskFromHookStdout = (event, hookPath, stdout) => Effect.try({
514
+ try: () => decodeTask(JSON.parse(stdout)),
515
+ catch: (error) => `TaskRepository hook ${hookPath} returned invalid JSON for on-${event}: ${toErrorMessage(error)}`,
516
+ });
517
+ const buildHookEnv = (event, taskId, options) => ({
518
+ ...process.env,
519
+ ...options.env,
520
+ TASHKS_EVENT: event,
521
+ TASHKS_ID: taskId,
522
+ TASHKS_DATA_DIR: options.dataDir,
523
+ });
524
+ const runHookExecutable = (hookPath, stdin, env) => Effect.gen(function* () {
525
+ const result = yield* Effect.try({
526
+ try: () => spawnSync(hookPath, [], {
527
+ input: stdin,
528
+ encoding: "utf8",
529
+ stdio: ["pipe", "pipe", "pipe"],
530
+ env,
531
+ }),
532
+ catch: (error) => `TaskRepository failed to execute hook ${hookPath}: ${toErrorMessage(error)}`,
533
+ });
534
+ if (result.error !== undefined) {
535
+ return yield* Effect.fail(`TaskRepository failed to execute hook ${hookPath}: ${toErrorMessage(result.error)}`);
536
+ }
537
+ if (result.status !== 0) {
538
+ const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
539
+ const signal = result.signal !== null ? `terminated by signal ${result.signal}` : null;
540
+ const status = result.status === null
541
+ ? "unknown"
542
+ : `exited with code ${result.status}`;
543
+ const details = stderr.length > 0 ? stderr : (signal ?? status);
544
+ return yield* Effect.fail(`TaskRepository hook ${hookPath} failed: ${details}`);
545
+ }
546
+ return typeof result.stdout === "string" ? result.stdout : "";
547
+ });
548
+ const runCreateHooks = (task, options) => Effect.gen(function* () {
549
+ const hooks = yield* discoverHooksForEvent("create", options);
550
+ let currentTask = task;
551
+ for (const hookPath of hooks) {
552
+ const stdout = yield* runHookExecutable(hookPath, JSON.stringify(currentTask), buildHookEnv("create", currentTask.id, options));
553
+ if (stdout.trim().length === 0) {
554
+ continue;
555
+ }
556
+ currentTask = yield* parseTaskFromHookStdout("create", hookPath, stdout);
557
+ }
558
+ return currentTask;
559
+ });
560
+ const runModifyHooks = (oldTask, newTask, options) => Effect.gen(function* () {
561
+ const hooks = yield* discoverHooksForEvent("modify", options);
562
+ let currentTask = newTask;
563
+ for (const hookPath of hooks) {
564
+ const stdout = yield* runHookExecutable(hookPath, JSON.stringify({ old: oldTask, new: currentTask }), buildHookEnv("modify", currentTask.id, options));
565
+ if (stdout.trim().length === 0) {
566
+ continue;
567
+ }
568
+ const hookedTask = yield* parseTaskFromHookStdout("modify", hookPath, stdout);
569
+ if (hookedTask.id !== oldTask.id) {
570
+ return yield* Effect.fail(`TaskRepository hook ${hookPath} failed: on-modify hooks cannot change task id`);
571
+ }
572
+ currentTask = hookedTask;
573
+ }
574
+ return currentTask;
575
+ });
576
+ const runNonMutatingHooks = (event, task, options) => Effect.gen(function* () {
577
+ const hooks = yield* discoverHooksForEvent(event, options).pipe(Effect.catchAll(() => Effect.succeed([])));
578
+ for (const hookPath of hooks) {
579
+ yield* runHookExecutable(hookPath, JSON.stringify(task), buildHookEnv(event, task.id, options)).pipe(Effect.ignore);
580
+ }
581
+ });
582
+ export class TaskRepository extends Context.Tag("TaskRepository")() {
583
+ }
584
+ const defaultDataDir = () => {
585
+ const home = process.env.HOME;
586
+ return home !== undefined && home.length > 0
587
+ ? `${home}/.local/share/tashks`
588
+ : ".local/share/tashks";
589
+ };
590
+ const makeTaskRepositoryLive = (options = {}) => {
591
+ const dataDir = options.dataDir ?? defaultDataDir();
592
+ const hookRuntimeOptions = {
593
+ hooksDir: options.hooksDir,
594
+ env: options.hookEnv,
595
+ dataDir,
596
+ };
597
+ return {
598
+ listTasks: (filters) => Effect.map(readTasksFromDisk(dataDir), (tasks) => applyListTaskFilters(tasks, filters)),
599
+ getTask: (id) => Effect.map(readTaskByIdFromDisk(dataDir, id), (result) => result.task),
600
+ createTask: (input) => Effect.gen(function* () {
601
+ yield* ensureTasksDir(dataDir);
602
+ const created = createTaskFromInput(input);
603
+ const taskFromHooks = yield* runCreateHooks(created, hookRuntimeOptions);
604
+ yield* writeTaskToDisk(taskFilePath(dataDir, taskFromHooks.id), taskFromHooks);
605
+ return taskFromHooks;
606
+ }),
607
+ updateTask: (id, patch) => Effect.gen(function* () {
608
+ const existing = yield* readTaskByIdFromDisk(dataDir, id);
609
+ const updated = applyTaskPatch(existing.task, patch);
610
+ const taskFromHooks = yield* runModifyHooks(existing.task, updated, hookRuntimeOptions);
611
+ yield* writeTaskToDisk(existing.path, taskFromHooks);
612
+ return taskFromHooks;
613
+ }),
614
+ completeTask: (id) => Effect.gen(function* () {
615
+ const existing = yield* readTaskByIdFromDisk(dataDir, id);
616
+ const completedAt = new Date().toISOString();
617
+ const completedDate = completedAt.slice(0, 10);
618
+ const completedTask = decodeTask({
619
+ ...existing.task,
620
+ status: "done",
621
+ updated: completedDate,
622
+ completed_at: completedAt,
623
+ });
624
+ const nextRecurringTask = yield* buildCompletionRecurrenceTask(completedTask, completedAt);
625
+ yield* writeTaskToDisk(existing.path, completedTask);
626
+ yield* runNonMutatingHooks("complete", completedTask, hookRuntimeOptions);
627
+ if (nextRecurringTask !== null) {
628
+ yield* ensureTasksDir(dataDir);
629
+ yield* writeTaskToDisk(taskFilePath(dataDir, nextRecurringTask.id), nextRecurringTask);
630
+ }
631
+ return completedTask;
632
+ }),
633
+ generateNextRecurrence: (id) => Effect.gen(function* () {
634
+ const existing = yield* readTaskByIdFromDisk(dataDir, id);
635
+ const generated = yield* generateNextClockRecurrence(dataDir, existing, new Date());
636
+ return generated.nextTask;
637
+ }),
638
+ processDueRecurrences: (now) => Effect.gen(function* () {
639
+ const tasks = yield* readTasksFromDisk(dataDir);
640
+ const recurringTasks = tasks.filter((task) => task.recurrence !== null &&
641
+ task.recurrence_trigger === "clock" &&
642
+ task.status !== "done" &&
643
+ task.status !== "dropped");
644
+ const created = [];
645
+ const replaced = [];
646
+ for (const task of recurringTasks) {
647
+ const due = yield* isClockRecurrenceDue(task, now);
648
+ if (!due) {
649
+ continue;
650
+ }
651
+ const existing = yield* readTaskByIdFromDisk(dataDir, task.id);
652
+ const generated = yield* generateNextClockRecurrence(dataDir, existing, now);
653
+ created.push(generated.nextTask);
654
+ if (generated.replacedId !== null) {
655
+ replaced.push(generated.replacedId);
656
+ }
657
+ }
658
+ return { created, replaced };
659
+ }),
660
+ deleteTask: (id) => Effect.gen(function* () {
661
+ const existing = yield* readTaskByIdFromDisk(dataDir, id);
662
+ yield* deleteTaskFromDisk(existing.path, id);
663
+ yield* runNonMutatingHooks("delete", existing.task, hookRuntimeOptions);
664
+ return { deleted: true };
665
+ }),
666
+ setDailyHighlight: (id) => Effect.gen(function* () {
667
+ const existing = yield* readTaskByIdFromDisk(dataDir, id);
668
+ yield* writeDailyHighlightToDisk(dataDir, id);
669
+ return existing.task;
670
+ }),
671
+ listStale: (days) => Effect.map(readTasksFromDisk(dataDir), (tasks) => {
672
+ const stalePredicate = isStalerThan(days, todayIso());
673
+ return tasks
674
+ .filter((task) => task.status === "active" && stalePredicate(task))
675
+ .sort(byUpdatedDescThenTitle);
676
+ }),
677
+ listWorkLog: (filters) => Effect.map(readWorkLogEntriesFromDisk(dataDir), (entries) => entries
678
+ .filter((entry) => filters?.date !== undefined ? entry.date === filters.date : true)
679
+ .sort(byStartedAtDescThenId)),
680
+ createWorkLogEntry: (input) => Effect.gen(function* () {
681
+ yield* ensureWorkLogDir(dataDir);
682
+ const normalizedInput = decodeWorkLogCreateInput(input);
683
+ const timestamp = yield* toWorkLogTimestamp(normalizedInput.started_at);
684
+ const date = yield* toWorkLogDate(normalizedInput.started_at);
685
+ const created = decodeWorkLogEntry({
686
+ id: `${normalizedInput.task_id}-${timestamp}`,
687
+ task_id: normalizedInput.task_id,
688
+ started_at: normalizedInput.started_at,
689
+ ended_at: normalizedInput.ended_at,
690
+ date,
691
+ });
692
+ yield* writeWorkLogEntryToDisk(workLogFilePath(dataDir, created.id), created);
693
+ return created;
694
+ }),
695
+ updateWorkLogEntry: (id, patch) => Effect.gen(function* () {
696
+ const existing = yield* readWorkLogEntryByIdFromDisk(dataDir, id);
697
+ const updated = applyWorkLogPatch(existing.entry, patch);
698
+ yield* writeWorkLogEntryToDisk(existing.path, updated);
699
+ return updated;
700
+ }),
701
+ deleteWorkLogEntry: (id) => Effect.gen(function* () {
702
+ const existing = yield* readWorkLogEntryByIdFromDisk(dataDir, id);
703
+ yield* deleteWorkLogEntryFromDisk(existing.path, id);
704
+ return { deleted: true };
705
+ }),
706
+ };
707
+ };
708
+ export const TaskRepositoryLive = (options = {}) => Layer.succeed(TaskRepository, makeTaskRepositoryLive(options));
709
+ export const generateTaskId = (title) => `${slugifyTitle(title)}-${randomIdSuffix()}`;
710
+ export const todayIso = () => new Date().toISOString().slice(0, 10);
711
+ export const parseTaskRecord = (record) => {
712
+ const result = decodeTaskEither(record);
713
+ return Either.isRight(result) ? result.right : null;
714
+ };
715
+ export const parseWorkLogRecord = (record) => {
716
+ const result = decodeWorkLogEntryEither(record);
717
+ return Either.isRight(result) ? result.right : null;
718
+ };
719
+ export const createTaskFromInput = (input) => {
720
+ const normalizedInput = decodeTaskCreateInput(input);
721
+ return decodeTask({
722
+ ...normalizedInput,
723
+ id: generateTaskId(normalizedInput.title),
724
+ });
725
+ };
726
+ export const applyTaskPatch = (task, patch) => {
727
+ const normalizedTask = decodeTask(task);
728
+ const normalizedPatch = decodeTaskPatch(patch);
729
+ return decodeTask({
730
+ ...normalizedTask,
731
+ ...normalizedPatch,
732
+ updated: todayIso(),
733
+ });
734
+ };
735
+ export const applyWorkLogPatch = (entry, patch) => {
736
+ const normalizedEntry = decodeWorkLogEntry(entry);
737
+ const normalizedPatch = decodeWorkLogPatch(patch);
738
+ return decodeWorkLogEntry({
739
+ ...normalizedEntry,
740
+ ...normalizedPatch,
741
+ });
742
+ };