@tashks/core 0.1.0 → 0.1.2

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,18 @@
1
+ import * as Effect from "effect/Effect";
2
+ import { type Task } from "./schema.js";
3
+ export type HookEvent = "create" | "modify" | "complete" | "delete";
4
+ export interface HookDiscoveryOptions {
5
+ readonly hooksDir?: string;
6
+ readonly env?: NodeJS.ProcessEnv;
7
+ }
8
+ export interface HookRuntimeOptions extends HookDiscoveryOptions {
9
+ readonly dataDir: string;
10
+ }
11
+ export declare const defaultHooksDir: (env?: NodeJS.ProcessEnv) => string;
12
+ export declare const discoverHooksForEvent: (event: HookEvent, options?: HookDiscoveryOptions) => Effect.Effect<Array<string>, string>;
13
+ export declare const buildHookEnv: (event: HookEvent, taskId: string, options: HookRuntimeOptions) => NodeJS.ProcessEnv;
14
+ export declare const runHookExecutable: (hookPath: string, stdin: string, env: NodeJS.ProcessEnv) => Effect.Effect<string, string>;
15
+ export declare const runCreateHooks: (task: Task, options: HookRuntimeOptions) => Effect.Effect<Task, string>;
16
+ export declare const runModifyHooks: (oldTask: Task, newTask: Task, options: HookRuntimeOptions) => Effect.Effect<Task, string>;
17
+ export declare const runNonMutatingHooks: (event: "complete" | "delete", task: Task, options: HookRuntimeOptions) => Effect.Effect<void, never>;
18
+ //# sourceMappingURL=hooks.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../../src/hooks.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAExC,OAAO,EAAsB,KAAK,IAAI,EAAE,MAAM,aAAa,CAAC;AAO5D,MAAM,MAAM,SAAS,GAAG,QAAQ,GAAG,QAAQ,GAAG,UAAU,GAAG,QAAQ,CAAC;AAEpE,MAAM,WAAW,oBAAoB;IACpC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;CACjC;AAED,MAAM,WAAW,kBAAmB,SAAQ,oBAAoB;IAC/D,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CACzB;AAID,eAAO,MAAM,eAAe,GAC3B,MAAK,MAAM,CAAC,UAAwB,KAClC,MAUF,CAAC;AAgCF,eAAO,MAAM,qBAAqB,GACjC,OAAO,SAAS,EAChB,UAAS,oBAAyB,KAChC,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,MAAM,CAyCrC,CAAC;AAaF,eAAO,MAAM,YAAY,GACxB,OAAO,SAAS,EAChB,QAAQ,MAAM,EACd,SAAS,kBAAkB,KACzB,MAAM,CAAC,UAMR,CAAC;AAEH,eAAO,MAAM,iBAAiB,GAC7B,UAAU,MAAM,EAChB,OAAO,MAAM,EACb,KAAK,MAAM,CAAC,UAAU,KACpB,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAqC5B,CAAC;AAEJ,eAAO,MAAM,cAAc,GAC1B,MAAM,IAAI,EACV,SAAS,kBAAkB,KACzB,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,MAAM,CAoB1B,CAAC;AAEJ,eAAO,MAAM,cAAc,GAC1B,SAAS,IAAI,EACb,SAAS,IAAI,EACb,SAAS,kBAAkB,KACzB,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,MAAM,CA8B1B,CAAC;AAEJ,eAAO,MAAM,mBAAmB,GAC/B,OAAO,UAAU,GAAG,QAAQ,EAC5B,MAAM,IAAI,EACV,SAAS,kBAAkB,KACzB,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,KAAK,CAazB,CAAC"}
@@ -0,0 +1,140 @@
1
+ import { constants as fsConstants } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ import { access, readdir } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import * as Effect from "effect/Effect";
6
+ import * as Schema from "effect/Schema";
7
+ import { Task as TaskSchema } from "./schema.js";
8
+ const decodeTask = Schema.decodeUnknownSync(TaskSchema);
9
+ const toErrorMessage = (error) => error instanceof Error ? error.message : String(error);
10
+ export const defaultHooksDir = (env = process.env) => {
11
+ const xdgConfigHome = env.XDG_CONFIG_HOME;
12
+ if (xdgConfigHome !== undefined && xdgConfigHome.length > 0) {
13
+ return join(xdgConfigHome, "tashks", "hooks");
14
+ }
15
+ const home = env.HOME;
16
+ return home !== undefined && home.length > 0
17
+ ? join(home, ".config", "tashks", "hooks")
18
+ : join(".config", "tashks", "hooks");
19
+ };
20
+ const hookNamePattern = (event) => new RegExp(`^on-${event}(?:\\..+)?$`);
21
+ const isHookCandidate = (event, fileName) => hookNamePattern(event).test(fileName);
22
+ const isExecutableFile = (path) => Effect.tryPromise({
23
+ try: async () => {
24
+ try {
25
+ await access(path, fsConstants.X_OK);
26
+ return true;
27
+ }
28
+ catch (error) {
29
+ if (error !== null &&
30
+ typeof error === "object" &&
31
+ "code" in error &&
32
+ (error.code === "EACCES" ||
33
+ error.code === "EPERM" ||
34
+ error.code === "ENOENT")) {
35
+ return false;
36
+ }
37
+ throw error;
38
+ }
39
+ },
40
+ catch: (error) => `TaskRepository failed to inspect hook executable bit for ${path}: ${toErrorMessage(error)}`,
41
+ });
42
+ export const discoverHooksForEvent = (event, options = {}) => {
43
+ const hooksDir = options.hooksDir ?? defaultHooksDir(options.env);
44
+ return Effect.gen(function* () {
45
+ const entries = yield* Effect.tryPromise({
46
+ try: () => readdir(hooksDir, { withFileTypes: true }).catch((error) => {
47
+ if (error !== null &&
48
+ typeof error === "object" &&
49
+ "code" in error &&
50
+ error.code === "ENOENT") {
51
+ return [];
52
+ }
53
+ throw error;
54
+ }),
55
+ catch: (error) => `TaskRepository failed to read hooks directory ${hooksDir}: ${toErrorMessage(error)}`,
56
+ });
57
+ const candidatePaths = entries
58
+ .filter((entry) => (entry.isFile() || entry.isSymbolicLink()) &&
59
+ isHookCandidate(event, entry.name))
60
+ .map((entry) => join(hooksDir, entry.name))
61
+ .sort((a, b) => a.localeCompare(b));
62
+ const discovered = [];
63
+ for (const candidatePath of candidatePaths) {
64
+ const executable = yield* isExecutableFile(candidatePath);
65
+ if (executable) {
66
+ discovered.push(candidatePath);
67
+ }
68
+ }
69
+ return discovered;
70
+ });
71
+ };
72
+ const parseTaskFromHookStdout = (event, hookPath, stdout) => Effect.try({
73
+ try: () => decodeTask(JSON.parse(stdout)),
74
+ catch: (error) => `TaskRepository hook ${hookPath} returned invalid JSON for on-${event}: ${toErrorMessage(error)}`,
75
+ });
76
+ export const buildHookEnv = (event, taskId, options) => ({
77
+ ...process.env,
78
+ ...options.env,
79
+ TASHKS_EVENT: event,
80
+ TASHKS_ID: taskId,
81
+ TASHKS_DATA_DIR: options.dataDir,
82
+ });
83
+ export const runHookExecutable = (hookPath, stdin, env) => Effect.gen(function* () {
84
+ const result = yield* Effect.try({
85
+ try: () => spawnSync(hookPath, [], {
86
+ input: stdin,
87
+ encoding: "utf8",
88
+ stdio: ["pipe", "pipe", "pipe"],
89
+ env,
90
+ }),
91
+ catch: (error) => `TaskRepository failed to execute hook ${hookPath}: ${toErrorMessage(error)}`,
92
+ });
93
+ if (result.error !== undefined) {
94
+ return yield* Effect.fail(`TaskRepository failed to execute hook ${hookPath}: ${toErrorMessage(result.error)}`);
95
+ }
96
+ if (result.status !== 0) {
97
+ const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
98
+ const signal = result.signal !== null ? `terminated by signal ${result.signal}` : null;
99
+ const status = result.status === null
100
+ ? "unknown"
101
+ : `exited with code ${result.status}`;
102
+ const details = stderr.length > 0 ? stderr : (signal ?? status);
103
+ return yield* Effect.fail(`TaskRepository hook ${hookPath} failed: ${details}`);
104
+ }
105
+ return typeof result.stdout === "string" ? result.stdout : "";
106
+ });
107
+ export const runCreateHooks = (task, options) => Effect.gen(function* () {
108
+ const hooks = yield* discoverHooksForEvent("create", options);
109
+ let currentTask = task;
110
+ for (const hookPath of hooks) {
111
+ const stdout = yield* runHookExecutable(hookPath, JSON.stringify(currentTask), buildHookEnv("create", currentTask.id, options));
112
+ if (stdout.trim().length === 0) {
113
+ continue;
114
+ }
115
+ currentTask = yield* parseTaskFromHookStdout("create", hookPath, stdout);
116
+ }
117
+ return currentTask;
118
+ });
119
+ export const runModifyHooks = (oldTask, newTask, options) => Effect.gen(function* () {
120
+ const hooks = yield* discoverHooksForEvent("modify", options);
121
+ let currentTask = newTask;
122
+ for (const hookPath of hooks) {
123
+ const stdout = yield* runHookExecutable(hookPath, JSON.stringify({ old: oldTask, new: currentTask }), buildHookEnv("modify", currentTask.id, options));
124
+ if (stdout.trim().length === 0) {
125
+ continue;
126
+ }
127
+ const hookedTask = yield* parseTaskFromHookStdout("modify", hookPath, stdout);
128
+ if (hookedTask.id !== oldTask.id) {
129
+ return yield* Effect.fail(`TaskRepository hook ${hookPath} failed: on-modify hooks cannot change task id`);
130
+ }
131
+ currentTask = hookedTask;
132
+ }
133
+ return currentTask;
134
+ });
135
+ export const runNonMutatingHooks = (event, task, options) => Effect.gen(function* () {
136
+ const hooks = yield* discoverHooksForEvent(event, options).pipe(Effect.catchAll(() => Effect.succeed([])));
137
+ for (const hookPath of hooks) {
138
+ yield* runHookExecutable(hookPath, JSON.stringify(task), buildHookEnv(event, task.id, options)).pipe(Effect.ignore);
139
+ }
140
+ });
@@ -0,0 +1,2 @@
1
+ export declare const generateTaskId: (title: string) => string;
2
+ //# sourceMappingURL=id.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"id.d.ts","sourceRoot":"","sources":["../../src/id.ts"],"names":[],"mappings":"AAwBA,eAAO,MAAM,cAAc,GAAI,OAAO,MAAM,KAAG,MACF,CAAC"}
package/dist/src/id.js ADDED
@@ -0,0 +1,17 @@
1
+ import { randomBytes } from "node:crypto";
2
+ const idSuffixAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
3
+ const idSuffixLength = 6;
4
+ const slugifyTitle = (title) => {
5
+ const slug = title
6
+ .toLowerCase()
7
+ .normalize("NFKD")
8
+ .replace(/[\u0300-\u036f]/g, "")
9
+ .replace(/[^a-z0-9]+/g, "-")
10
+ .replace(/^-+|-+$/g, "");
11
+ return slug.length > 0 ? slug : "task";
12
+ };
13
+ const randomIdSuffix = () => {
14
+ const random = randomBytes(idSuffixLength);
15
+ return Array.from(random, (value) => idSuffixAlphabet[value % idSuffixAlphabet.length]).join("");
16
+ };
17
+ export const generateTaskId = (title) => `${slugifyTitle(title)}-${randomIdSuffix()}`;
@@ -0,0 +1,12 @@
1
+ import * as Layer from "effect/Layer";
2
+ import { TaskRepository } from "./repository.js";
3
+ export interface ProseqlRepositoryOptions {
4
+ readonly tasksFile: string;
5
+ readonly workLogFile: string;
6
+ readonly tasksFormat?: string;
7
+ readonly workLogFormat?: string;
8
+ readonly hooksDir?: string;
9
+ readonly hookEnv?: NodeJS.ProcessEnv;
10
+ }
11
+ export declare const ProseqlRepositoryLive: (options: ProseqlRepositoryOptions) => Layer.Layer<TaskRepository, string>;
12
+ //# sourceMappingURL=proseql-repository.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"proseql-repository.d.ts","sourceRoot":"","sources":["../../src/proseql-repository.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,KAAK,MAAM,cAAc,CAAC;AA4BtC,OAAO,EACN,cAAc,EAMd,MAAM,iBAAiB,CAAC;AA2CzB,MAAM,WAAW,wBAAwB;IACxC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;CACrC;AAyWD,eAAO,MAAM,qBAAqB,GACjC,SAAS,wBAAwB,KAC/B,KAAK,CAAC,KAAK,CAAC,cAAc,EAAE,MAAM,CACwB,CAAC"}
@@ -0,0 +1,255 @@
1
+ import { dirname, join } from "node:path";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import * as Effect from "effect/Effect";
4
+ import * as Layer from "effect/Layer";
5
+ import * as Schema from "effect/Schema";
6
+ import { createNodeDatabase } from "@proseql/node";
7
+ import YAML from "yaml";
8
+ import { Task as TaskSchema, WorkLogCreateInput as WorkLogCreateInputSchema, WorkLogEntry as WorkLogEntrySchema, } from "./schema.js";
9
+ import { byUpdatedDescThenTitle, isStalerThan, } from "./query.js";
10
+ import { runCreateHooks, runModifyHooks, runNonMutatingHooks, } from "./hooks.js";
11
+ import { buildCompletionRecurrenceTask, buildNextClockRecurrenceTask, isClockRecurrenceDue, } from "./recurrence.js";
12
+ import { TaskRepository, applyListTaskFilters, todayIso, applyTaskPatch, applyWorkLogPatch, createTaskFromInput, } from "./repository.js";
13
+ const decodeTask = Schema.decodeUnknownSync(TaskSchema);
14
+ const decodeWorkLogCreateInput = Schema.decodeUnknownSync(WorkLogCreateInputSchema);
15
+ const decodeWorkLogEntry = Schema.decodeUnknownSync(WorkLogEntrySchema);
16
+ const toErrorMessage = (error) => {
17
+ if (error !== null && typeof error === "object" && "_tag" in error) {
18
+ const tagged = error;
19
+ return tagged.message ?? tagged._tag;
20
+ }
21
+ return error instanceof Error ? error.message : String(error);
22
+ };
23
+ const toWorkLogTimestamp = (startedAt) => Effect.try({
24
+ try: () => {
25
+ const parsed = new Date(startedAt);
26
+ if (Number.isNaN(parsed.getTime())) {
27
+ throw new Error(`Invalid started_at: ${startedAt}`);
28
+ }
29
+ const iso = parsed.toISOString();
30
+ return `${iso.slice(0, 4)}${iso.slice(5, 7)}${iso.slice(8, 10)}T${iso.slice(11, 13)}${iso.slice(14, 16)}`;
31
+ },
32
+ catch: (error) => `TaskRepository failed to derive work log timestamp: ${toErrorMessage(error)}`,
33
+ });
34
+ const toWorkLogDate = (startedAt) => Effect.try({
35
+ try: () => {
36
+ const parsed = new Date(startedAt);
37
+ if (Number.isNaN(parsed.getTime())) {
38
+ throw new Error(`Invalid started_at: ${startedAt}`);
39
+ }
40
+ return parsed.toISOString().slice(0, 10);
41
+ },
42
+ catch: (error) => `TaskRepository failed to derive work log date: ${toErrorMessage(error)}`,
43
+ });
44
+ const makeDbConfig = (options) => ({
45
+ tasks: {
46
+ schema: TaskSchema,
47
+ file: options.tasksFile,
48
+ ...(options.tasksFormat !== undefined
49
+ ? { format: options.tasksFormat }
50
+ : {}),
51
+ relationships: {},
52
+ },
53
+ workLog: {
54
+ schema: WorkLogEntrySchema,
55
+ file: options.workLogFile,
56
+ ...(options.workLogFormat !== undefined
57
+ ? { format: options.workLogFormat }
58
+ : {}),
59
+ relationships: {},
60
+ },
61
+ });
62
+ const makeProseqlRepository = (options) => Effect.gen(function* () {
63
+ const config = makeDbConfig(options);
64
+ const db = yield* createNodeDatabase(config).pipe(Effect.mapError((error) => `ProseqlRepository failed to initialize database: ${toErrorMessage(error)}`));
65
+ const dataDir = dirname(options.tasksFile);
66
+ const hookRuntimeOptions = {
67
+ hooksDir: options.hooksDir,
68
+ env: options.hookEnv,
69
+ dataDir,
70
+ };
71
+ const collectTasks = () => Effect.tryPromise({
72
+ try: () => db.tasks.query().runPromise.then((results) => results),
73
+ catch: (error) => `ProseqlRepository.listTasks failed: ${toErrorMessage(error)}`,
74
+ });
75
+ const collectWorkLog = () => Effect.tryPromise({
76
+ try: () => db.workLog.query().runPromise.then((results) => results),
77
+ catch: (error) => `ProseqlRepository.listWorkLog failed: ${toErrorMessage(error)}`,
78
+ });
79
+ const findTask = (id) => Effect.tryPromise({
80
+ try: () => db.tasks.findById(id).runPromise,
81
+ catch: (error) => `ProseqlRepository failed to read task ${id}: ${toErrorMessage(error)}`,
82
+ });
83
+ const findWorkLogEntry = (id) => Effect.tryPromise({
84
+ try: () => db.workLog.findById(id).runPromise,
85
+ catch: (error) => `ProseqlRepository failed to read work log entry ${id}: ${toErrorMessage(error)}`,
86
+ });
87
+ const saveTask = (task) => Effect.tryPromise({
88
+ try: () => db.tasks
89
+ .upsert({
90
+ where: { id: task.id },
91
+ create: task,
92
+ update: task,
93
+ })
94
+ .runPromise.then((r) => r),
95
+ catch: (error) => `ProseqlRepository failed to write task ${task.id}: ${toErrorMessage(error)}`,
96
+ });
97
+ const removeTask = (id) => Effect.tryPromise({
98
+ try: () => db.tasks.delete(id).runPromise,
99
+ catch: (error) => `ProseqlRepository failed to delete task ${id}: ${toErrorMessage(error)}`,
100
+ });
101
+ const saveWorkLogEntry = (entry) => Effect.tryPromise({
102
+ try: () => db.workLog
103
+ .upsert({
104
+ where: { id: entry.id },
105
+ create: entry,
106
+ update: entry,
107
+ })
108
+ .runPromise.then((r) => r),
109
+ catch: (error) => `ProseqlRepository failed to write work log entry ${entry.id}: ${toErrorMessage(error)}`,
110
+ });
111
+ const removeWorkLogEntry = (id) => Effect.tryPromise({
112
+ try: () => db.workLog.delete(id).runPromise,
113
+ catch: (error) => `ProseqlRepository failed to delete work log entry ${id}: ${toErrorMessage(error)}`,
114
+ });
115
+ const writeDailyHighlight = (id) => Effect.tryPromise({
116
+ try: async () => {
117
+ const highlightPath = join(dataDir, "daily-highlight.yaml");
118
+ await mkdir(dataDir, { recursive: true });
119
+ await writeFile(highlightPath, YAML.stringify({ id }), "utf8");
120
+ },
121
+ catch: (error) => `ProseqlRepository failed to write daily highlight ${id}: ${toErrorMessage(error)}`,
122
+ });
123
+ const byStartedAtDescThenId = (a, b) => {
124
+ const byStartedAtDesc = b.started_at.localeCompare(a.started_at);
125
+ if (byStartedAtDesc !== 0) {
126
+ return byStartedAtDesc;
127
+ }
128
+ return a.id.localeCompare(b.id);
129
+ };
130
+ const service = {
131
+ listTasks: (filters) => Effect.map(collectTasks(), (tasks) => applyListTaskFilters(tasks, filters)),
132
+ getTask: (id) => findTask(id),
133
+ createTask: (input) => Effect.gen(function* () {
134
+ const created = createTaskFromInput(input);
135
+ const taskFromHooks = yield* runCreateHooks(created, hookRuntimeOptions);
136
+ yield* saveTask(taskFromHooks);
137
+ return taskFromHooks;
138
+ }),
139
+ updateTask: (id, patch) => Effect.gen(function* () {
140
+ const existing = yield* findTask(id);
141
+ const updated = applyTaskPatch(existing, patch);
142
+ const taskFromHooks = yield* runModifyHooks(existing, updated, hookRuntimeOptions);
143
+ yield* saveTask(taskFromHooks);
144
+ return taskFromHooks;
145
+ }),
146
+ completeTask: (id) => Effect.gen(function* () {
147
+ const existing = yield* findTask(id);
148
+ const completedAt = new Date().toISOString();
149
+ const completedDate = completedAt.slice(0, 10);
150
+ const completedTask = decodeTask({
151
+ ...existing,
152
+ status: "done",
153
+ updated: completedDate,
154
+ completed_at: completedAt,
155
+ });
156
+ const nextRecurringTask = yield* buildCompletionRecurrenceTask(completedTask, completedAt);
157
+ yield* saveTask(completedTask);
158
+ yield* runNonMutatingHooks("complete", completedTask, hookRuntimeOptions);
159
+ if (nextRecurringTask !== null) {
160
+ yield* saveTask(nextRecurringTask);
161
+ }
162
+ return completedTask;
163
+ }),
164
+ generateNextRecurrence: (id) => Effect.gen(function* () {
165
+ const existing = yield* findTask(id);
166
+ const result = yield* buildNextClockRecurrenceTask(existing, new Date());
167
+ if (result.updatedCurrent !== null) {
168
+ yield* saveTask(result.updatedCurrent);
169
+ }
170
+ yield* saveTask(result.nextTask);
171
+ return result.nextTask;
172
+ }),
173
+ processDueRecurrences: (now) => Effect.gen(function* () {
174
+ const tasks = yield* collectTasks();
175
+ const recurringTasks = tasks.filter((task) => task.recurrence !== null &&
176
+ task.recurrence_trigger === "clock" &&
177
+ task.status !== "done" &&
178
+ task.status !== "dropped");
179
+ const created = [];
180
+ const replaced = [];
181
+ for (const task of recurringTasks) {
182
+ const due = yield* isClockRecurrenceDue(task, now);
183
+ if (!due) {
184
+ continue;
185
+ }
186
+ const result = yield* buildNextClockRecurrenceTask(task, now);
187
+ if (result.updatedCurrent !== null) {
188
+ yield* saveTask(result.updatedCurrent);
189
+ }
190
+ yield* saveTask(result.nextTask);
191
+ created.push(result.nextTask);
192
+ if (result.shouldReplaceCurrent) {
193
+ replaced.push(task.id);
194
+ }
195
+ }
196
+ return { created, replaced };
197
+ }),
198
+ deleteTask: (id) => Effect.gen(function* () {
199
+ const existing = yield* findTask(id);
200
+ yield* removeTask(id);
201
+ yield* runNonMutatingHooks("delete", existing, hookRuntimeOptions);
202
+ return { deleted: true };
203
+ }),
204
+ setDailyHighlight: (id) => Effect.gen(function* () {
205
+ const existing = yield* findTask(id);
206
+ yield* writeDailyHighlight(id);
207
+ return existing;
208
+ }),
209
+ listStale: (days) => Effect.map(collectTasks(), (tasks) => {
210
+ const stalePredicate = isStalerThan(days, todayIso());
211
+ return tasks
212
+ .filter((task) => task.status === "active" && stalePredicate(task))
213
+ .sort(byUpdatedDescThenTitle);
214
+ }),
215
+ listWorkLog: (filters) => Effect.map(collectWorkLog(), (entries) => entries
216
+ .filter((entry) => filters?.date !== undefined
217
+ ? entry.date === filters.date
218
+ : true)
219
+ .sort(byStartedAtDescThenId)),
220
+ createWorkLogEntry: (input) => Effect.gen(function* () {
221
+ const normalizedInput = decodeWorkLogCreateInput(input);
222
+ const timestamp = yield* toWorkLogTimestamp(normalizedInput.started_at);
223
+ const date = yield* toWorkLogDate(normalizedInput.started_at);
224
+ const created = decodeWorkLogEntry({
225
+ id: `${normalizedInput.task_id}-${timestamp}`,
226
+ task_id: normalizedInput.task_id,
227
+ started_at: normalizedInput.started_at,
228
+ ended_at: normalizedInput.ended_at,
229
+ date,
230
+ });
231
+ yield* saveWorkLogEntry(created);
232
+ return created;
233
+ }),
234
+ updateWorkLogEntry: (id, patch) => Effect.gen(function* () {
235
+ const existing = yield* findWorkLogEntry(id);
236
+ const updated = applyWorkLogPatch(existing, patch);
237
+ yield* saveWorkLogEntry(updated);
238
+ return updated;
239
+ }),
240
+ deleteWorkLogEntry: (id) => Effect.gen(function* () {
241
+ yield* removeWorkLogEntry(id);
242
+ return { deleted: true };
243
+ }),
244
+ importTask: (task) => Effect.gen(function* () {
245
+ yield* saveTask(task);
246
+ return task;
247
+ }),
248
+ importWorkLogEntry: (entry) => Effect.gen(function* () {
249
+ yield* saveWorkLogEntry(entry);
250
+ return entry;
251
+ }),
252
+ };
253
+ return service;
254
+ });
255
+ export const ProseqlRepositoryLive = (options) => Layer.scoped(TaskRepository, makeProseqlRepository(options));