@tomkapa/tayto 0.1.2 → 0.2.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.
@@ -98,10 +98,29 @@ var TaskStatus = {
98
98
  Cancelled: "cancelled"
99
99
  };
100
100
  var TaskType = {
101
+ Epic: "epic",
101
102
  Story: "story",
102
103
  TechDebt: "tech-debt",
103
104
  Bug: "bug"
104
105
  };
106
+ var TaskLevel = {
107
+ Epic: 1,
108
+ Work: 2
109
+ };
110
+ var TYPE_TO_LEVEL = {
111
+ [TaskType.Epic]: TaskLevel.Epic,
112
+ [TaskType.Story]: TaskLevel.Work,
113
+ [TaskType.TechDebt]: TaskLevel.Work,
114
+ [TaskType.Bug]: TaskLevel.Work
115
+ };
116
+ function getTaskLevel(type) {
117
+ return TYPE_TO_LEVEL[type] ?? TaskLevel.Work;
118
+ }
119
+ var WORK_TYPES = /* @__PURE__ */ new Set([
120
+ TaskType.Story,
121
+ TaskType.TechDebt,
122
+ TaskType.Bug
123
+ ]);
105
124
  var DependencyType = {
106
125
  Blocks: "blocks",
107
126
  RelatesTo: "relates-to",
@@ -124,10 +143,13 @@ export {
124
143
  logger,
125
144
  TaskStatus,
126
145
  TaskType,
146
+ TaskLevel,
147
+ getTaskLevel,
148
+ WORK_TYPES,
127
149
  DependencyType,
128
150
  UIDependencyType,
129
151
  RANK_GAP,
130
152
  TERMINAL_STATUSES,
131
153
  isTerminalStatus
132
154
  };
133
- //# sourceMappingURL=chunk-6NQOFUIQ.js.map
155
+ //# sourceMappingURL=chunk-FUNYPBWJ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/logging/logger.ts","../src/types/enums.ts"],"sourcesContent":["import { appendFileSync, readdirSync, unlinkSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { trace, type Span, SpanStatusCode } from '@opentelemetry/api';\n\nconst tracer = trace.getTracer('task');\nconst LOG_RETENTION_DAYS = 7;\n\nexport interface LogAttributes {\n [key: string]: string | number | boolean;\n}\n\ntype LogLevel = 'INFO' | 'WARN' | 'ERROR';\n\nfunction formatTimestamp(): string {\n return new Date().toISOString();\n}\n\nfunction formatAttrs(attrs?: LogAttributes): string {\n if (!attrs || Object.keys(attrs).length === 0) return '';\n return ' ' + JSON.stringify(attrs);\n}\n\nclass Logger {\n private logFilePath: string | null = null;\n\n init(logDir: string): void {\n const date = new Date().toISOString().slice(0, 10); // YYYY-MM-DD\n this.logFilePath = join(logDir, `task-${date}.log`);\n this.pruneOldLogs(logDir);\n }\n\n info(message: string, attrs?: LogAttributes): void {\n this.write('INFO', message, attrs);\n const span = trace.getActiveSpan();\n if (span) {\n span.addEvent(message, attrs);\n }\n }\n\n warn(message: string, attrs?: LogAttributes): void {\n this.write('WARN', message, attrs);\n const span = trace.getActiveSpan();\n if (span) {\n span.addEvent(`WARN: ${message}`, attrs);\n }\n }\n\n error(message: string, error?: unknown, attrs?: LogAttributes): void {\n const errorDetail = error instanceof Error ? ` | ${error.stack ?? error.message}` : '';\n this.write('ERROR', `${message}${errorDetail}`, attrs);\n const span = trace.getActiveSpan();\n if (span) {\n span.addEvent(`ERROR: ${message}`, attrs);\n if (error instanceof Error) {\n span.recordException(error);\n }\n span.setStatus({ code: SpanStatusCode.ERROR, message });\n }\n }\n\n startSpan<T>(name: string, fn: (span: Span) => T): T {\n return tracer.startActiveSpan(name, (span) => {\n try {\n const result = fn(span);\n span.end();\n return result;\n } catch (e) {\n if (e instanceof Error) {\n span.recordException(e);\n }\n span.setStatus({ code: SpanStatusCode.ERROR });\n span.end();\n throw e;\n }\n });\n }\n\n private write(level: LogLevel, message: string, attrs?: LogAttributes): void {\n if (!this.logFilePath) return;\n const line = `${formatTimestamp()} [${level}] ${message}${formatAttrs(attrs)}\\n`;\n try {\n appendFileSync(this.logFilePath, line);\n } catch {\n // Swallowing here is intentional: logging must never crash the app.\n // If the log file is unwritable, the OTel span still captures the event.\n }\n }\n\n private pruneOldLogs(logDir: string): void {\n try {\n const cutoff = Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;\n const files = readdirSync(logDir).filter((f) => f.startsWith('task-') && f.endsWith('.log'));\n for (const file of files) {\n const dateStr = file.slice('task-'.length, -'.log'.length);\n const fileDate = new Date(dateStr).getTime();\n if (!isNaN(fileDate) && fileDate < cutoff) {\n unlinkSync(join(logDir, file));\n }\n }\n } catch {\n // Best-effort cleanup — don't crash if pruning fails\n }\n }\n}\n\nexport const logger = new Logger();\n","export const TaskStatus = {\n Backlog: 'backlog',\n Todo: 'todo',\n InProgress: 'in-progress',\n Review: 'review',\n Done: 'done',\n Cancelled: 'cancelled',\n} as const;\nexport type TaskStatus = (typeof TaskStatus)[keyof typeof TaskStatus];\n\nexport const TaskType = {\n Epic: 'epic',\n Story: 'story',\n TechDebt: 'tech-debt',\n Bug: 'bug',\n} as const;\nexport type TaskType = (typeof TaskType)[keyof typeof TaskType];\n\n/**\n * Task level derived from type.\n * Level 1: epics (grouping/planning layer)\n * Level 2: stories, tech-debt, bugs (execution layer)\n */\nexport const TaskLevel = {\n Epic: 1,\n Work: 2,\n} as const;\nexport type TaskLevel = (typeof TaskLevel)[keyof typeof TaskLevel];\n\nconst TYPE_TO_LEVEL: Record<string, TaskLevel> = {\n [TaskType.Epic]: TaskLevel.Epic,\n [TaskType.Story]: TaskLevel.Work,\n [TaskType.TechDebt]: TaskLevel.Work,\n [TaskType.Bug]: TaskLevel.Work,\n};\n\nexport function getTaskLevel(type: string): TaskLevel {\n return TYPE_TO_LEVEL[type] ?? TaskLevel.Work;\n}\n\n/** Types that belong to the work (level 2) execution layer. */\nexport const WORK_TYPES: ReadonlySet<string> = new Set([\n TaskType.Story,\n TaskType.TechDebt,\n TaskType.Bug,\n]);\n\n/** Types stored in the database. */\nexport const DependencyType = {\n Blocks: 'blocks',\n RelatesTo: 'relates-to',\n Duplicates: 'duplicates',\n} as const;\nexport type DependencyType = (typeof DependencyType)[keyof typeof DependencyType];\n\n/**\n * UI-level dependency types — includes BlockedBy which is a reverse-Blocks\n * relationship resolved before persisting to the database.\n */\nexport const UIDependencyType = {\n ...DependencyType,\n BlockedBy: 'blocked-by',\n} as const;\nexport type UIDependencyType = (typeof UIDependencyType)[keyof typeof UIDependencyType];\n\n/** Gap between consecutive rank values, used for insertion between neighbors. */\nexport const RANK_GAP = 1000.0;\n\n/** Statuses that represent terminal/completed task states. */\nexport const TERMINAL_STATUSES: ReadonlySet<string> = new Set([\n TaskStatus.Done,\n TaskStatus.Cancelled,\n]);\n\nexport function isTerminalStatus(status: string): boolean {\n return TERMINAL_STATUSES.has(status);\n}\n"],"mappings":"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","names":[]}
package/dist/index.js CHANGED
@@ -3,12 +3,15 @@ import {
3
3
  DependencyType,
4
4
  RANK_GAP,
5
5
  TERMINAL_STATUSES,
6
+ TaskLevel,
6
7
  TaskStatus,
7
8
  TaskType,
8
9
  UIDependencyType,
10
+ WORK_TYPES,
11
+ getTaskLevel,
9
12
  isTerminalStatus,
10
13
  logger
11
- } from "./chunk-6NQOFUIQ.js";
14
+ } from "./chunk-FUNYPBWJ.js";
12
15
 
13
16
  // src/config/index.ts
14
17
  import { mkdirSync } from "fs";
@@ -317,10 +320,11 @@ var SqliteTaskRepository = class {
317
320
  return logger.startSpan("TaskRepository.insert", () => {
318
321
  try {
319
322
  const now = (/* @__PURE__ */ new Date()).toISOString();
320
- const maxActiveResult = this.getMaxActiveRank(input.projectId);
323
+ const level = getTaskLevel(input.type);
324
+ const maxActiveResult = this.getMaxActiveRankByLevel(input.projectId, level);
321
325
  if (!maxActiveResult.ok) return maxActiveResult;
322
326
  const maxActiveRank = maxActiveResult.value;
323
- const minTerminalResult = this.getMinTerminalRank(input.projectId);
327
+ const minTerminalResult = this.getMinTerminalRankByLevel(input.projectId, level);
324
328
  if (!minTerminalResult.ok) return minTerminalResult;
325
329
  const minTerminalRank = minTerminalResult.value;
326
330
  let rank;
@@ -380,10 +384,21 @@ var SqliteTaskRepository = class {
380
384
  conditions.push("type = ?");
381
385
  params.push(filter.type);
382
386
  }
387
+ if (filter.level !== void 0) {
388
+ const typesForLevel = this.getTypesForLevel(filter.level);
389
+ const placeholders = typesForLevel.map(() => "?").join(", ");
390
+ conditions.push(`type IN (${placeholders})`);
391
+ params.push(...typesForLevel);
392
+ }
383
393
  if (filter.parentId) {
384
394
  conditions.push("parent_id = ?");
385
395
  params.push(filter.parentId);
386
396
  }
397
+ if (filter.parentIds && filter.parentIds.length > 0) {
398
+ const placeholders = filter.parentIds.map(() => "?").join(", ");
399
+ conditions.push(`parent_id IN (${placeholders})`);
400
+ params.push(...filter.parentIds);
401
+ }
387
402
  if (filter.search) {
388
403
  const ftsQuery = filter.search.trim().split(/\s+/).map((term) => `"${term.replace(/"/g, '""')}"*`).join(" ");
389
404
  conditions.push(`id IN (SELECT id FROM tasks_fts WHERE tasks_fts MATCH ?)`);
@@ -528,6 +543,65 @@ _${now}_
528
543
  return err(new AppError("DB_ERROR", "Failed to get ranked tasks", e));
529
544
  }
530
545
  }
546
+ getTypesForLevel(level) {
547
+ if (level === 1) return ["epic"];
548
+ return [...WORK_TYPES];
549
+ }
550
+ getMaxRankByLevel(projectId, level) {
551
+ try {
552
+ const types = this.getTypesForLevel(level);
553
+ const placeholders = types.map(() => "?").join(", ");
554
+ const row = this.db.prepare(
555
+ `SELECT MAX(rank) as max_rank FROM tasks WHERE project_id = ? AND ${NOT_DELETED} AND type IN (${placeholders})`
556
+ ).get(projectId, ...types);
557
+ return ok(row?.max_rank ?? 0);
558
+ } catch (e) {
559
+ return err(new AppError("DB_ERROR", "Failed to get max rank by level", e));
560
+ }
561
+ }
562
+ getMaxActiveRankByLevel(projectId, level) {
563
+ try {
564
+ const types = this.getTypesForLevel(level);
565
+ const typePlaceholders = types.map(() => "?").join(", ");
566
+ const row = this.db.prepare(
567
+ `SELECT MAX(rank) as max_rank FROM tasks WHERE project_id = ? AND ${NOT_DELETED} AND type IN (${typePlaceholders}) AND status NOT IN (${TERMINAL_PLACEHOLDERS})`
568
+ ).get(projectId, ...types, ...TERMINAL_STATUS_ARRAY);
569
+ return ok(row?.max_rank ?? 0);
570
+ } catch (e) {
571
+ return err(new AppError("DB_ERROR", "Failed to get max active rank by level", e));
572
+ }
573
+ }
574
+ getMinTerminalRankByLevel(projectId, level) {
575
+ try {
576
+ const types = this.getTypesForLevel(level);
577
+ const typePlaceholders = types.map(() => "?").join(", ");
578
+ const row = this.db.prepare(
579
+ `SELECT MIN(rank) as min_rank FROM tasks WHERE project_id = ? AND ${NOT_DELETED} AND type IN (${typePlaceholders}) AND status IN (${TERMINAL_PLACEHOLDERS})`
580
+ ).get(projectId, ...types, ...TERMINAL_STATUS_ARRAY);
581
+ return ok(row?.min_rank ?? null);
582
+ } catch (e) {
583
+ return err(new AppError("DB_ERROR", "Failed to get min terminal rank by level", e));
584
+ }
585
+ }
586
+ getRankedTasksByLevel(projectId, level, status) {
587
+ try {
588
+ const types = this.getTypesForLevel(level);
589
+ const typePlaceholders = types.map(() => "?").join(", ");
590
+ let sql;
591
+ let params;
592
+ if (status) {
593
+ sql = `SELECT * FROM tasks WHERE project_id = ? AND ${NOT_DELETED} AND type IN (${typePlaceholders}) AND status = ? ORDER BY rank ASC`;
594
+ params = [projectId, ...types, status];
595
+ } else {
596
+ sql = `SELECT * FROM tasks WHERE project_id = ? AND ${NOT_DELETED} AND type IN (${typePlaceholders}) ORDER BY rank ASC`;
597
+ params = [projectId, ...types];
598
+ }
599
+ const rows = this.db.prepare(sql).all(...params);
600
+ return ok(rows.map(rowToTask));
601
+ } catch (e) {
602
+ return err(new AppError("DB_ERROR", "Failed to get ranked tasks by level", e));
603
+ }
604
+ }
531
605
  search(query, projectId) {
532
606
  return logger.startSpan("TaskRepository.search", () => {
533
607
  try {
@@ -877,7 +951,10 @@ var TaskFilterSchema = z2.object({
877
951
  projectId: z2.string().optional(),
878
952
  status: z2.enum(taskStatusValues).optional(),
879
953
  type: z2.enum(taskTypeValues).optional(),
954
+ level: z2.number().int().min(1).max(2).optional(),
880
955
  parentId: z2.string().optional(),
956
+ /** Multi-select filter: show tasks whose parentId is in this list. */
957
+ parentIds: z2.array(z2.string()).optional(),
881
958
  search: z2.string().optional()
882
959
  });
883
960
  var RerankTaskSchema = z2.object({
@@ -906,12 +983,19 @@ var TaskServiceImpl = class {
906
983
  const projectRef = parsed.data.projectId ?? projectIdOrName;
907
984
  const projectResult = this.projectService.resolveProject(projectRef);
908
985
  if (!projectResult.ok) return projectResult;
986
+ const taskLevel = getTaskLevel(parsed.data.type);
987
+ if (taskLevel === TaskLevel.Epic && parsed.data.parentId) {
988
+ return err(new AppError("VALIDATION", "Epic tasks cannot have a parent"));
989
+ }
909
990
  if (parsed.data.parentId) {
910
991
  const parentResult = this.repo.findById(parsed.data.parentId);
911
992
  if (!parentResult.ok) return parentResult;
912
993
  if (!parentResult.value) {
913
994
  return err(new AppError("NOT_FOUND", `Parent task not found: ${parsed.data.parentId}`));
914
995
  }
996
+ if (getTaskLevel(parentResult.value.type) !== TaskLevel.Epic) {
997
+ return err(new AppError("VALIDATION", "Tasks can only be children of epic-level tasks"));
998
+ }
915
999
  }
916
1000
  const project = projectResult.value;
917
1001
  const taskIdResult = this.projectService.nextTaskId(project);
@@ -970,28 +1054,79 @@ var TaskServiceImpl = class {
970
1054
  if (!blockersResult.ok) return blockersResult;
971
1055
  const hasNonTerminalBlocker = blockersResult.value.some((b) => !isTerminalStatus(b.status));
972
1056
  if (hasNonTerminalBlocker) {
973
- return err(
974
- new AppError("VALIDATION", "Task is blocked by unfinished dependencies")
975
- );
1057
+ return err(new AppError("VALIDATION", "Task is blocked by unfinished dependencies"));
976
1058
  }
977
1059
  }
978
- if (parsed.data.status && isTerminalStatus(parsed.data.status)) {
979
- const existingResult = this.repo.findById(id);
980
- if (!existingResult.ok) return existingResult;
981
- if (!existingResult.value) {
982
- return err(new AppError("NOT_FOUND", `Task not found: ${id}`));
1060
+ const existingResult = this.repo.findById(id);
1061
+ if (!existingResult.ok) return existingResult;
1062
+ if (!existingResult.value) {
1063
+ return err(new AppError("NOT_FOUND", `Task not found: ${id}`));
1064
+ }
1065
+ const existing = existingResult.value;
1066
+ if (parsed.data.type) {
1067
+ const newLevel = getTaskLevel(parsed.data.type);
1068
+ const oldLevel = getTaskLevel(existing.type);
1069
+ if (newLevel !== oldLevel) {
1070
+ if (oldLevel === TaskLevel.Epic) {
1071
+ const childrenResult = this.repo.findMany({
1072
+ projectId: existing.projectId,
1073
+ parentId: id
1074
+ });
1075
+ if (childrenResult.ok && childrenResult.value.length > 0) {
1076
+ return err(
1077
+ new AppError(
1078
+ "VALIDATION",
1079
+ "Cannot change type from epic: task has children. Remove children first."
1080
+ )
1081
+ );
1082
+ }
1083
+ }
1084
+ if (newLevel === TaskLevel.Epic && existing.parentId) {
1085
+ return err(new AppError("VALIDATION", "Cannot change type to epic: task has a parent"));
1086
+ }
1087
+ }
1088
+ }
1089
+ if (parsed.data.parentId !== void 0) {
1090
+ const effectiveType = parsed.data.type ?? existing.type;
1091
+ const effectiveLevel = getTaskLevel(effectiveType);
1092
+ if (effectiveLevel === TaskLevel.Epic && parsed.data.parentId) {
1093
+ return err(new AppError("VALIDATION", "Epic tasks cannot have a parent"));
1094
+ }
1095
+ if (parsed.data.parentId) {
1096
+ const parentResult = this.repo.findById(parsed.data.parentId);
1097
+ if (!parentResult.ok) return parentResult;
1098
+ if (!parentResult.value) {
1099
+ return err(new AppError("NOT_FOUND", `Parent task not found: ${parsed.data.parentId}`));
1100
+ }
1101
+ if (getTaskLevel(parentResult.value.type) !== TaskLevel.Epic) {
1102
+ return err(
1103
+ new AppError("VALIDATION", "Tasks can only be children of epic-level tasks")
1104
+ );
1105
+ }
983
1106
  }
984
- const existing = existingResult.value;
985
- const updateResult = this.repo.update(id, parsed.data);
986
- if (!updateResult.ok) return updateResult;
1107
+ }
1108
+ if (parsed.data.status && isTerminalStatus(parsed.data.status)) {
1109
+ const effectiveType = parsed.data.type ?? existing.type;
1110
+ const level = getTaskLevel(effectiveType);
1111
+ const updateResult2 = this.repo.update(id, parsed.data);
1112
+ if (!updateResult2.ok) return updateResult2;
987
1113
  if (!isTerminalStatus(existing.status)) {
988
- const maxRankResult = this.repo.getMaxRank(existing.projectId);
1114
+ const maxRankResult = this.repo.getMaxRankByLevel(existing.projectId, level);
989
1115
  if (!maxRankResult.ok) return maxRankResult;
990
- return this.repo.rerank(id, maxRankResult.value + RANK_GAP);
1116
+ const rerankResult = this.repo.rerank(id, maxRankResult.value + RANK_GAP);
1117
+ if (!rerankResult.ok) return rerankResult;
1118
+ this.propagateParentStatus(existing);
1119
+ return rerankResult;
991
1120
  }
992
- return updateResult;
1121
+ this.propagateParentStatus(existing);
1122
+ return updateResult2;
993
1123
  }
994
- return this.repo.update(id, parsed.data);
1124
+ const updateResult = this.repo.update(id, parsed.data);
1125
+ if (!updateResult.ok) return updateResult;
1126
+ if (parsed.data.status && parsed.data.status !== existing.status) {
1127
+ this.propagateParentStatus(existing);
1128
+ }
1129
+ return updateResult;
995
1130
  });
996
1131
  }
997
1132
  deleteTask(id) {
@@ -1005,6 +1140,9 @@ var TaskServiceImpl = class {
1005
1140
  return err(new AppError("NOT_FOUND", `Parent task not found: ${parentId}`));
1006
1141
  }
1007
1142
  const parent = parentResult.value;
1143
+ if (getTaskLevel(parent.type) !== TaskLevel.Epic) {
1144
+ return err(new AppError("VALIDATION", "Breakdown parent must be an epic-level task"));
1145
+ }
1008
1146
  const projectResult = this.projectService.resolveProject(parent.projectId);
1009
1147
  if (!projectResult.ok) return projectResult;
1010
1148
  const project = projectResult.value;
@@ -1014,6 +1152,9 @@ var TaskServiceImpl = class {
1014
1152
  if (!parsed.success) {
1015
1153
  return err(new AppError("VALIDATION", `Invalid subtask: ${parsed.error.message}`));
1016
1154
  }
1155
+ if (getTaskLevel(parsed.data.type) === TaskLevel.Epic) {
1156
+ return err(new AppError("VALIDATION", `Subtask "${parsed.data.name}" cannot be an epic`));
1157
+ }
1017
1158
  const taskIdResult = this.projectService.nextTaskId(project);
1018
1159
  if (!taskIdResult.ok) return taskIdResult;
1019
1160
  const result = this.repo.insert(taskIdResult.value, {
@@ -1067,11 +1208,16 @@ var TaskServiceImpl = class {
1067
1208
  )
1068
1209
  );
1069
1210
  }
1211
+ const taskLevel = getTaskLevel(task.type);
1070
1212
  const projectRef = projectIdOrName ?? task.projectId;
1071
1213
  const projectResult = this.projectService.resolveProject(projectRef);
1072
1214
  if (!projectResult.ok) return projectResult;
1073
1215
  const projectId = projectResult.value.id;
1074
- const rankedResult = this.repo.getRankedTasks(projectId, TaskStatus.Backlog);
1216
+ const rankedResult = this.repo.getRankedTasksByLevel(
1217
+ projectId,
1218
+ taskLevel,
1219
+ TaskStatus.Backlog
1220
+ );
1075
1221
  if (!rankedResult.ok) return rankedResult;
1076
1222
  const ranked = rankedResult.value.filter((t) => t.id !== taskId);
1077
1223
  let newRank;
@@ -1155,6 +1301,42 @@ var TaskServiceImpl = class {
1155
1301
  return this.repo.search(query, projectId);
1156
1302
  });
1157
1303
  }
1304
+ /**
1305
+ * Auto-propagate status to the parent task after a child status change.
1306
+ * - If a child moves to in-progress and parent is backlog/todo → parent becomes in-progress.
1307
+ * - If all children are terminal (done/cancelled) → parent becomes done.
1308
+ */
1309
+ propagateParentStatus(child) {
1310
+ if (!child.parentId) return;
1311
+ const parentResult = this.repo.findById(child.parentId);
1312
+ if (!parentResult.ok || !parentResult.value) return;
1313
+ const parent = parentResult.value;
1314
+ const updatedChildResult = this.repo.findById(child.id);
1315
+ if (!updatedChildResult.ok || !updatedChildResult.value) return;
1316
+ const updatedChild = updatedChildResult.value;
1317
+ if (updatedChild.status === TaskStatus.InProgress && (parent.status === TaskStatus.Backlog || parent.status === TaskStatus.Todo)) {
1318
+ this.repo.update(parent.id, { status: TaskStatus.InProgress });
1319
+ return;
1320
+ }
1321
+ if (isTerminalStatus(updatedChild.status)) {
1322
+ const siblingsResult = this.repo.findMany({
1323
+ projectId: parent.projectId,
1324
+ parentId: parent.id
1325
+ });
1326
+ if (!siblingsResult.ok) return;
1327
+ const allTerminal = siblingsResult.value.every((s) => isTerminalStatus(s.status));
1328
+ if (allTerminal && !isTerminalStatus(parent.status)) {
1329
+ const maxRankResult = this.repo.getMaxRankByLevel(
1330
+ parent.projectId,
1331
+ getTaskLevel(parent.type)
1332
+ );
1333
+ this.repo.update(parent.id, { status: TaskStatus.Done });
1334
+ if (maxRankResult.ok) {
1335
+ this.repo.rerank(parent.id, maxRankResult.value + RANK_GAP);
1336
+ }
1337
+ }
1338
+ }
1339
+ }
1158
1340
  };
1159
1341
 
1160
1342
  // src/types/dependency.ts
@@ -1677,7 +1859,7 @@ function registerProjectSetDefault(parent, container) {
1677
1859
 
1678
1860
  // src/cli/commands/task/create.ts
1679
1861
  function registerTaskCreate(parent, container) {
1680
- parent.command("create").description("Create a new task (appended to bottom of backlog)").requiredOption("-n, --name <name>", "Task name").option("-p, --project <project>", "Project id or name").option("-d, --description <description>", "Task description").option("-t, --type <type>", "Task type: story, tech-debt, bug", "story").option("-s, --status <status>", "Task status", "backlog").option("--parent <parentId>", "Parent task id for subtask").option("--technical-notes <notes>", "Technical notes (markdown)").option("--additional-requirements <requirements>", "Additional requirements (markdown)").option("--depends-on <ids...>", "Task ids this task depends on (blocks relationship)").action(
1862
+ parent.command("create").description("Create a new task (appended to bottom of backlog)").requiredOption("-n, --name <name>", "Task name").option("-p, --project <project>", "Project id or name").option("-d, --description <description>", "Task description").option("-t, --type <type>", "Task type: epic, story, tech-debt, bug", "story").option("-s, --status <status>", "Task status", "backlog").option("--parent <parentId>", "Parent task id for subtask").option("--technical-notes <notes>", "Technical notes (markdown)").option("--additional-requirements <requirements>", "Additional requirements (markdown)").option("--depends-on <ids...>", "Task ids this task depends on (blocks relationship)").action(
1681
1863
  (opts) => {
1682
1864
  const result = container.taskService.createTask(
1683
1865
  {
@@ -1699,12 +1881,13 @@ function registerTaskCreate(parent, container) {
1699
1881
 
1700
1882
  // src/cli/commands/task/list.ts
1701
1883
  function registerTaskList(parent, container) {
1702
- parent.command("list").description("List tasks in rank order (defaults to backlog)").option("-p, --project <project>", "Filter by project id or name").option("-s, --status <status>", "Filter by status (default: backlog)").option("-t, --type <type>", "Filter by type").option("--parent <parentId>", "Filter by parent task id").option("--search <text>", "Search in name, description, and notes").action(
1884
+ parent.command("list").description("List tasks in rank order (defaults to level 2 backlog tasks)").option("-p, --project <project>", "Filter by project id or name").option("-s, --status <status>", "Filter by status (default: backlog)").option("-t, --type <type>", "Filter by type (epic, story, tech-debt, bug)").option("-l, --level <level>", "Filter by level (1=epic, 2=work). Default: 2").option("--parent <parentId>", "Filter by parent task id").option("--search <text>", "Search in name, description, and notes").action(
1703
1885
  (opts) => {
1704
1886
  const result = container.taskService.listTasks({
1705
1887
  projectId: opts.project,
1706
1888
  status: opts.status ?? "backlog",
1707
1889
  type: opts.type,
1890
+ level: opts.level ? parseInt(opts.level, 10) : void 0,
1708
1891
  parentId: opts.parent,
1709
1892
  search: opts.search
1710
1893
  });
@@ -1723,7 +1906,7 @@ function registerTaskShow(parent, container) {
1723
1906
 
1724
1907
  // src/cli/commands/task/update.ts
1725
1908
  function registerTaskUpdate(parent, container) {
1726
- parent.command("update <id>").description("Update a task").option("-n, --name <name>", "Task name").option("-d, --description <description>", "Task description").option("-t, --type <type>", "Task type: story, tech-debt, bug").option("-s, --status <status>", "Task status").option("--parent <parentId>", "Parent task id").option("--technical-notes <notes>", "Replace technical notes").option("--additional-requirements <requirements>", "Replace additional requirements").option("--append-notes <notes>", "Append to technical notes").option("--append-requirements <requirements>", "Append to additional requirements").action(
1909
+ parent.command("update <id>").description("Update a task").option("-n, --name <name>", "Task name").option("-d, --description <description>", "Task description").option("-t, --type <type>", "Task type: epic, story, tech-debt, bug").option("-s, --status <status>", "Task status").option("--parent <parentId>", "Parent task id").option("--technical-notes <notes>", "Replace technical notes").option("--additional-requirements <requirements>", "Replace additional requirements").option("--append-notes <notes>", "Append to technical notes").option("--append-requirements <requirements>", "Append to additional requirements").action(
1727
1910
  (id, opts) => {
1728
1911
  const result = container.taskService.updateTask(id, {
1729
1912
  name: opts.name,
@@ -1931,7 +2114,7 @@ function buildCLI(container) {
1931
2114
  registerDepList(dep, container);
1932
2115
  registerDepGraph(dep, container);
1933
2116
  program.command("tui").description("Launch interactive terminal UI").option("-p, --project <project>", "Start with specific project").action(async (opts) => {
1934
- const { launchTUI } = await import("./tui-JNZRBEIQ.js");
2117
+ const { launchTUI } = await import("./tui-FTXYP3HM.js");
1935
2118
  await launchTUI(container, opts.project);
1936
2119
  });
1937
2120
  return program;
@@ -1947,7 +2130,7 @@ async function main() {
1947
2130
  const container = createContainer(db);
1948
2131
  const args = process.argv.slice(2);
1949
2132
  if (args.length === 0) {
1950
- const { launchTUI } = await import("./tui-JNZRBEIQ.js");
2133
+ const { launchTUI } = await import("./tui-FTXYP3HM.js");
1951
2134
  await launchTUI(container);
1952
2135
  } else {
1953
2136
  const program = buildCLI(container);