trekoon 0.3.0 → 0.3.1

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.
@@ -46,7 +46,7 @@ const SHOW_OPTIONS = ["view", "all"] as const;
46
46
  const SEARCH_OPTIONS = ["fields", "preview"] as const;
47
47
  const REPLACE_OPTIONS = ["search", "replace", "fields", "preview", "apply"] as const;
48
48
  const CREATE_MANY_OPTIONS = ["epic", "e", "task"] as const;
49
- const UPDATE_OPTIONS = ["all", "ids", "append", "description", "d", "status", "s", "title", "t"] as const;
49
+ const UPDATE_OPTIONS = ["all", "ids", "append", "description", "d", "status", "s", "title", "t", "owner"] as const;
50
50
  const STATUS_CASCADE_UPDATE_STATUSES = ["done", "todo"] as const;
51
51
 
52
52
  function parseIdsOption(rawIds: string | undefined): string[] {
@@ -1071,7 +1071,8 @@ export async function runTask(context: CliContext): Promise<CliResult> {
1071
1071
  readMissingOptionValue(parsed.missingOptionValues, "ids") ??
1072
1072
  readMissingOptionValue(parsed.missingOptionValues, "append") ??
1073
1073
  readMissingOptionValue(parsed.missingOptionValues, "description", "d") ??
1074
- readMissingOptionValue(parsed.missingOptionValues, "status", "s");
1074
+ readMissingOptionValue(parsed.missingOptionValues, "status", "s") ??
1075
+ readMissingOptionValue(parsed.missingOptionValues, "owner");
1075
1076
  if (missingUpdateOption !== undefined) {
1076
1077
  return failMissingOptionValue("task.update", missingUpdateOption);
1077
1078
  }
@@ -1084,6 +1085,7 @@ export async function runTask(context: CliContext): Promise<CliResult> {
1084
1085
  const description: string | undefined = readOption(parsed.options, "description", "d");
1085
1086
  const append: string | undefined = readOption(parsed.options, "append");
1086
1087
  const status: string | undefined = readOption(parsed.options, "status", "s");
1088
+ const owner: string | undefined = readOption(parsed.options, "owner");
1087
1089
 
1088
1090
  if (updateAll && ids.length > 0) {
1089
1091
  return failResult({
@@ -1210,7 +1212,7 @@ export async function runTask(context: CliContext): Promise<CliResult> {
1210
1212
  append === undefined
1211
1213
  ? description
1212
1214
  : appendLine(domain.getTaskOrThrow(taskId).description, append);
1213
- const task = mutations.updateTask(taskId, { title, description: nextDescription, status });
1215
+ const task = mutations.updateTask(taskId, { title, description: nextDescription, status, owner });
1214
1216
 
1215
1217
  return okResult({
1216
1218
  command: "task.update",
@@ -1257,10 +1259,50 @@ export async function runTask(context: CliContext): Promise<CliResult> {
1257
1259
  });
1258
1260
  }
1259
1261
 
1262
+ // Check for open subtasks (lenient: warn but allow completion)
1263
+ const openSubtasks = domain.getOpenSubtasks(taskId);
1264
+ const openSubtaskCount = openSubtasks.length;
1265
+ const openSubtaskIds = openSubtasks.map((s) => s.id);
1266
+
1267
+ // Snapshot blocked reverse deps before marking done (lightweight: no full readiness rebuild).
1268
+ // Only direct task-level reverse deps are tracked here; subtask reverse deps are excluded
1269
+ // because subtasks are children within a task, not independent workflow items.
1270
+ const reverseDeps = domain.listReverseDependencies(taskId);
1271
+ const directRevDepTaskIds = reverseDeps
1272
+ .filter((rd) => rd.isDirect && rd.kind === "task")
1273
+ .map((rd) => rd.id);
1274
+ const preDepStatuses = domain.batchResolveDependencyStatuses(directRevDepTaskIds);
1275
+ const preBlockedIds = new Set(
1276
+ directRevDepTaskIds.filter((id) => {
1277
+ const resolved = preDepStatuses.get(id);
1278
+ return resolved !== undefined && resolved.blockers.length > 0;
1279
+ }),
1280
+ );
1281
+
1282
+ // Auto-transition through in_progress when current status is todo or blocked.
1283
+ // Note: this emits two sync events (→in_progress, →done) because each
1284
+ // updateTask call appends its own event. This is intentional — the status
1285
+ // machine requires the intermediate step, and event consumers should treat
1286
+ // a rapid in_progress→done pair from `task done` as a single logical completion.
1287
+ if (existingTask.status === "todo" || existingTask.status === "blocked") {
1288
+ mutations.updateTask(taskId, { status: "in_progress" });
1289
+ }
1290
+
1260
1291
  const completed = mutations.updateTask(taskId, { status: "done" });
1261
1292
  const readiness = buildTaskReadiness(domain, completed.epicId);
1262
1293
  const nextCandidate = readiness.candidates[0] ?? null;
1263
1294
 
1295
+ // Diff: tasks that were blocked before but are now ready
1296
+ const unblockedTasks = readiness.candidates
1297
+ .filter((item) => preBlockedIds.has(item.task.id))
1298
+ .map((item) => ({
1299
+ id: item.task.id,
1300
+ kind: "task" as const,
1301
+ title: item.task.title,
1302
+ status: item.task.status,
1303
+ wasBlockedBy: [taskId],
1304
+ }));
1305
+
1264
1306
  const nextTree = nextCandidate !== null ? domain.buildTaskTreeDetailed(nextCandidate.task.id) : null;
1265
1307
  const nextDeps = nextCandidate?.blockerSummary.blockedBy ?? [];
1266
1308
 
@@ -1269,7 +1311,17 @@ export async function runTask(context: CliContext): Promise<CliResult> {
1269
1311
  blockedCount: readiness.summary.blockedCount,
1270
1312
  };
1271
1313
 
1314
+ const subtaskWarning = openSubtaskCount > 0
1315
+ ? `Warning: ${openSubtaskCount} subtask(s) still open.`
1316
+ : null;
1317
+
1272
1318
  let human = `Task ${completed.title} marked done.`;
1319
+ if (subtaskWarning !== null) {
1320
+ human += `\n${subtaskWarning}`;
1321
+ }
1322
+ if (unblockedTasks.length > 0) {
1323
+ human += `\nUnblocked: ${unblockedTasks.map((t) => t.title).join(", ")}`;
1324
+ }
1273
1325
  if (nextTree !== null && nextCandidate !== null) {
1274
1326
  human += `\nNext: ${formatTask(nextCandidate.task)}`;
1275
1327
  }
@@ -1280,6 +1332,10 @@ export async function runTask(context: CliContext): Promise<CliResult> {
1280
1332
  human,
1281
1333
  data: {
1282
1334
  completed,
1335
+ openSubtaskCount,
1336
+ openSubtaskIds,
1337
+ warning: subtaskWarning,
1338
+ unblocked: unblockedTasks,
1283
1339
  next: nextTree,
1284
1340
  nextDeps,
1285
1341
  readiness: readinessStats,
@@ -1,8 +1,9 @@
1
1
  import { type Database } from "bun:sqlite";
2
2
 
3
+ import { writeTransaction } from "../storage/database";
3
4
  import { appendEventWithGitContext } from "../sync/event-writes";
4
5
  import { ENTITY_OPERATIONS } from "./mutation-operations";
5
- import { TrackerDomain } from "./tracker-domain";
6
+ import { TrackerDomain, validateStatusTransition } from "./tracker-domain";
6
7
  import {
7
8
  type CompactEpicCreateResult,
8
9
  type CompactEpicExpandResult,
@@ -104,7 +105,7 @@ export class MutationService {
104
105
  }
105
106
 
106
107
  createEpic(input: { title: string; description: string; status?: string | undefined }): EpicRecord {
107
- return this.#db.transaction((): EpicRecord => {
108
+ return writeTransaction(this.#db, (): EpicRecord => {
108
109
  const epic = this.#domain.createEpic(input);
109
110
  this.#appendEntityEvent("epic", epic.id, ENTITY_OPERATIONS.epic.created, {
110
111
  title: epic.title,
@@ -112,7 +113,7 @@ export class MutationService {
112
113
  status: epic.status,
113
114
  });
114
115
  return epic;
115
- })();
116
+ });
116
117
  }
117
118
 
118
119
  createEpicGraph(input: {
@@ -123,7 +124,7 @@ export class MutationService {
123
124
  subtaskSpecs: readonly CompactSubtaskSpec[];
124
125
  dependencySpecs: readonly CompactDependencySpec[];
125
126
  }): CompactEpicCreateResult {
126
- return this.#db.transaction((): CompactEpicCreateResult => {
127
+ return writeTransaction(this.#db, (): CompactEpicCreateResult => {
127
128
  const epic = this.#domain.createEpic(input);
128
129
  const created = this.#domain.expandEpic({
129
130
  epicId: epic.id,
@@ -172,14 +173,18 @@ export class MutationService {
172
173
  dependencies: created.dependencies,
173
174
  result: created.result,
174
175
  };
175
- })();
176
+ });
176
177
  }
177
178
 
178
179
  updateEpic(
179
180
  id: string,
180
181
  input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
181
182
  ): EpicRecord {
182
- return this.#db.transaction((): EpicRecord => {
183
+ return writeTransaction(this.#db, (): EpicRecord => {
184
+ if (input.status !== undefined) {
185
+ const existing = this.#domain.getEpicOrThrow(id);
186
+ validateStatusTransition(existing.status, input.status, "epic", id);
187
+ }
183
188
  const epic = this.#domain.updateEpic(id, input);
184
189
  this.#appendEntityEvent("epic", epic.id, ENTITY_OPERATIONS.epic.updated, {
185
190
  title: epic.title,
@@ -187,27 +192,27 @@ export class MutationService {
187
192
  status: epic.status,
188
193
  });
189
194
  return epic;
190
- })();
195
+ });
191
196
  }
192
197
 
193
198
  updateEpicStatusCascade(id: string, status: string): StatusCascadePlan {
194
- return this.#db.transaction((): StatusCascadePlan => {
199
+ return writeTransaction(this.#db, (): StatusCascadePlan => {
195
200
  const plan = this.#domain.planStatusCascade("epic", id, status);
196
201
  this.#assertCascadeNotBlocked(plan);
197
202
  this.#applyStatusCascadePlan(plan);
198
203
  return plan;
199
- })();
204
+ });
200
205
  }
201
206
 
202
207
  deleteEpic(id: string): void {
203
- this.#db.transaction((): void => {
208
+ writeTransaction(this.#db, (): void => {
204
209
  this.#domain.deleteEpic(id);
205
210
  this.#appendEntityEvent("epic", id, ENTITY_OPERATIONS.epic.deleted, {});
206
- })();
211
+ });
207
212
  }
208
213
 
209
214
  createTask(input: { epicId: string; title: string; description: string; status?: string | undefined }): TaskRecord {
210
- return this.#db.transaction((): TaskRecord => {
215
+ return writeTransaction(this.#db, (): TaskRecord => {
211
216
  const task = this.#domain.createTask(input);
212
217
  this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.created, {
213
218
  epic_id: task.epicId,
@@ -216,11 +221,11 @@ export class MutationService {
216
221
  status: task.status,
217
222
  });
218
223
  return task;
219
- })();
224
+ });
220
225
  }
221
226
 
222
227
  createTaskBatch(input: { epicId: string; specs: readonly CompactTaskSpec[] }): CompactTaskBatchCreateResult {
223
- return this.#db.transaction((): CompactTaskBatchCreateResult => {
228
+ return writeTransaction(this.#db, (): CompactTaskBatchCreateResult => {
224
229
  const created = this.#domain.createTaskBatch(input);
225
230
  for (const task of created.tasks) {
226
231
  this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.created, {
@@ -231,7 +236,7 @@ export class MutationService {
231
236
  });
232
237
  }
233
238
  return created;
234
- })();
239
+ });
235
240
  }
236
241
 
237
242
  expandEpic(input: {
@@ -240,7 +245,7 @@ export class MutationService {
240
245
  subtaskSpecs: readonly CompactSubtaskSpec[];
241
246
  dependencySpecs: readonly CompactDependencySpec[];
242
247
  }): CompactEpicExpandResult {
243
- return this.#db.transaction((): CompactEpicExpandResult => {
248
+ return writeTransaction(this.#db, (): CompactEpicExpandResult => {
244
249
  const created = this.#domain.expandEpic(input);
245
250
  for (const task of created.tasks) {
246
251
  this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.created, {
@@ -270,39 +275,44 @@ export class MutationService {
270
275
  }
271
276
 
272
277
  return created;
273
- })();
278
+ });
274
279
  }
275
280
 
276
281
  updateTask(
277
282
  id: string,
278
- input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
283
+ input: { title?: string | undefined; description?: string | undefined; status?: string | undefined; owner?: string | null | undefined },
279
284
  ): TaskRecord {
280
- return this.#db.transaction((): TaskRecord => {
285
+ return writeTransaction(this.#db, (): TaskRecord => {
286
+ if (input.status !== undefined) {
287
+ const existing = this.#domain.getTaskOrThrow(id);
288
+ validateStatusTransition(existing.status, input.status, "task", id);
289
+ }
281
290
  const task = this.#domain.updateTask(id, input);
282
291
  this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.updated, {
283
292
  epic_id: task.epicId,
284
293
  title: task.title,
285
294
  description: task.description,
286
295
  status: task.status,
296
+ owner: task.owner,
287
297
  });
288
298
  return task;
289
- })();
299
+ });
290
300
  }
291
301
 
292
302
  updateTaskStatusCascade(id: string, status: string): StatusCascadePlan {
293
- return this.#db.transaction((): StatusCascadePlan => {
303
+ return writeTransaction(this.#db, (): StatusCascadePlan => {
294
304
  const plan = this.#domain.planStatusCascade("task", id, status);
295
305
  this.#assertCascadeNotBlocked(plan);
296
306
  this.#applyStatusCascadePlan(plan);
297
307
  return plan;
298
- })();
308
+ });
299
309
  }
300
310
 
301
311
  deleteTask(id: string): void {
302
- this.#db.transaction((): void => {
312
+ writeTransaction(this.#db, (): void => {
303
313
  this.#domain.deleteTask(id);
304
314
  this.#appendEntityEvent("task", id, ENTITY_OPERATIONS.task.deleted, {});
305
- })();
315
+ });
306
316
  }
307
317
 
308
318
  createSubtask(input: {
@@ -311,7 +321,7 @@ export class MutationService {
311
321
  description?: string | undefined;
312
322
  status?: string | undefined;
313
323
  }): SubtaskRecord {
314
- return this.#db.transaction((): SubtaskRecord => {
324
+ return writeTransaction(this.#db, (): SubtaskRecord => {
315
325
  const subtask = this.#domain.createSubtask(input);
316
326
  this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.created, {
317
327
  task_id: subtask.taskId,
@@ -320,11 +330,11 @@ export class MutationService {
320
330
  status: subtask.status,
321
331
  });
322
332
  return subtask;
323
- })();
333
+ });
324
334
  }
325
335
 
326
336
  createSubtaskBatch(input: { taskId: string; specs: readonly CompactSubtaskSpec[] }): CompactSubtaskBatchCreateResult {
327
- return this.#db.transaction((): CompactSubtaskBatchCreateResult => {
337
+ return writeTransaction(this.#db, (): CompactSubtaskBatchCreateResult => {
328
338
  const created = this.#domain.createSubtaskBatch(input);
329
339
  for (const subtask of created.subtasks) {
330
340
  this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.created, {
@@ -335,34 +345,39 @@ export class MutationService {
335
345
  });
336
346
  }
337
347
  return created;
338
- })();
348
+ });
339
349
  }
340
350
 
341
351
  updateSubtask(
342
352
  id: string,
343
- input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
353
+ input: { title?: string | undefined; description?: string | undefined; status?: string | undefined; owner?: string | null | undefined },
344
354
  ): SubtaskRecord {
345
- return this.#db.transaction((): SubtaskRecord => {
355
+ return writeTransaction(this.#db, (): SubtaskRecord => {
356
+ if (input.status !== undefined) {
357
+ const existing = this.#domain.getSubtaskOrThrow(id);
358
+ validateStatusTransition(existing.status, input.status, "subtask", id);
359
+ }
346
360
  const subtask = this.#domain.updateSubtask(id, input);
347
361
  this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.updated, {
348
362
  task_id: subtask.taskId,
349
363
  title: subtask.title,
350
364
  description: subtask.description,
351
365
  status: subtask.status,
366
+ owner: subtask.owner,
352
367
  });
353
368
  return subtask;
354
- })();
369
+ });
355
370
  }
356
371
 
357
372
  deleteSubtask(id: string): void {
358
- this.#db.transaction((): void => {
373
+ writeTransaction(this.#db, (): void => {
359
374
  this.#domain.deleteSubtask(id);
360
375
  this.#appendEntityEvent("subtask", id, ENTITY_OPERATIONS.subtask.deleted, {});
361
- })();
376
+ });
362
377
  }
363
378
 
364
379
  addDependency(sourceId: string, dependsOnId: string): DependencyRecord {
365
- return this.#db.transaction((): DependencyRecord => {
380
+ return writeTransaction(this.#db, (): DependencyRecord => {
366
381
  const dependency = this.#domain.addDependency(sourceId, dependsOnId);
367
382
  this.#appendEntityEvent("dependency", dependency.id, ENTITY_OPERATIONS.dependency.added, {
368
383
  source_id: dependency.sourceId,
@@ -371,11 +386,11 @@ export class MutationService {
371
386
  depends_on_kind: dependency.dependsOnKind,
372
387
  });
373
388
  return dependency;
374
- })();
389
+ });
375
390
  }
376
391
 
377
392
  addDependencyBatch(input: { specs: readonly CompactDependencySpec[] }): CompactDependencyBatchAddResult {
378
- return this.#db.transaction((): CompactDependencyBatchAddResult => {
393
+ return writeTransaction(this.#db, (): CompactDependencyBatchAddResult => {
379
394
  const created = this.#domain.addDependencyBatch(input);
380
395
  for (const dependency of created.dependencies) {
381
396
  this.#appendEntityEvent("dependency", dependency.id, ENTITY_OPERATIONS.dependency.added, {
@@ -386,11 +401,11 @@ export class MutationService {
386
401
  });
387
402
  }
388
403
  return created;
389
- })();
404
+ });
390
405
  }
391
406
 
392
407
  removeDependency(sourceId: string, dependsOnId: string): number {
393
- return this.#db.transaction((): number => {
408
+ return writeTransaction(this.#db, (): number => {
394
409
  const removed = this.#domain.removeDependency(sourceId, dependsOnId);
395
410
  if (removed > 0) {
396
411
  this.#appendEntityEvent("dependency", `${sourceId}->${dependsOnId}`, ENTITY_OPERATIONS.dependency.removed, {
@@ -399,11 +414,19 @@ export class MutationService {
399
414
  });
400
415
  }
401
416
  return removed;
402
- })();
417
+ });
403
418
  }
404
419
 
405
420
  describeError(error: unknown): string | undefined {
406
- if (!(error instanceof DomainError) || error.code !== "dependency_blocked") {
421
+ if (!(error instanceof DomainError)) {
422
+ return undefined;
423
+ }
424
+
425
+ if (error.code === "status_transition_invalid") {
426
+ return error.message;
427
+ }
428
+
429
+ if (error.code !== "dependency_blocked") {
407
430
  return undefined;
408
431
  }
409
432
 
@@ -560,6 +583,7 @@ export class MutationService {
560
583
  title: task.title,
561
584
  description: task.description,
562
585
  status: task.status,
586
+ owner: task.owner,
563
587
  });
564
588
  continue;
565
589
  }
@@ -570,6 +594,7 @@ export class MutationService {
570
594
  title: subtask.title,
571
595
  description: subtask.description,
572
596
  status: subtask.status,
597
+ owner: subtask.owner,
573
598
  });
574
599
  }
575
600
  }
@@ -582,7 +607,7 @@ export class MutationService {
582
607
  ): ScopeReplacementResult {
583
608
  const result = this.#buildScopeReplacementResult(nodes, searchText, replacementText, fields, "apply");
584
609
 
585
- this.#db.transaction((): void => {
610
+ writeTransaction(this.#db, (): void => {
586
611
  for (const node of nodes) {
587
612
  const nextTitle = fields.includes("title") ? replaceMatches(node.title, searchText, replacementText) : node.title;
588
613
  const nextDescription = fields.includes("description")
@@ -610,6 +635,7 @@ export class MutationService {
610
635
  title: task.title,
611
636
  description: task.description,
612
637
  status: task.status,
638
+ owner: task.owner,
613
639
  });
614
640
  continue;
615
641
  }
@@ -620,9 +646,10 @@ export class MutationService {
620
646
  title: subtask.title,
621
647
  description: subtask.description,
622
648
  status: subtask.status,
649
+ owner: subtask.owner,
623
650
  });
624
651
  }
625
- })();
652
+ });
626
653
 
627
654
  return result;
628
655
  }