@tomkapa/tayto 0.1.1 → 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.
- package/dist/{chunk-6NQOFUIQ.js → chunk-FUNYPBWJ.js} +23 -1
- package/dist/chunk-FUNYPBWJ.js.map +1 -0
- package/dist/index.js +218 -31
- package/dist/index.js.map +1 -1
- package/dist/migrations/004_remove_check_constraints.sql +63 -0
- package/dist/{tui-JNZRBEIQ.js → tui-FTXYP3HM.js} +619 -100
- package/dist/tui-FTXYP3HM.js.map +1 -0
- package/package.json +1 -3
- package/dist/chunk-6NQOFUIQ.js.map +0 -1
- package/dist/tui-JNZRBEIQ.js.map +0 -1
|
@@ -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-
|
|
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-
|
|
14
|
+
} from "./chunk-FUNYPBWJ.js";
|
|
12
15
|
|
|
13
16
|
// src/config/index.ts
|
|
14
17
|
import { mkdirSync } from "fs";
|
|
@@ -31,11 +34,11 @@ function loadConfig() {
|
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
// src/db/connection.ts
|
|
34
|
-
import
|
|
37
|
+
import { DatabaseSync } from "node:sqlite";
|
|
35
38
|
function createDatabase(dbPath) {
|
|
36
|
-
const db = new
|
|
37
|
-
db.
|
|
38
|
-
db.
|
|
39
|
+
const db = new DatabaseSync(dbPath);
|
|
40
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
41
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
39
42
|
return db;
|
|
40
43
|
}
|
|
41
44
|
|
|
@@ -67,14 +70,18 @@ function runMigrations(db) {
|
|
|
67
70
|
for (const migration of migrations) {
|
|
68
71
|
if (applied.has(migration.name)) continue;
|
|
69
72
|
logger.info(`Applying migration: ${migration.name}`);
|
|
70
|
-
|
|
73
|
+
db.exec("BEGIN");
|
|
74
|
+
try {
|
|
71
75
|
db.exec(migration.sql);
|
|
72
76
|
db.prepare("INSERT INTO _migrations (name, applied_at) VALUES (?, ?)").run(
|
|
73
77
|
migration.name,
|
|
74
78
|
(/* @__PURE__ */ new Date()).toISOString()
|
|
75
79
|
);
|
|
76
|
-
|
|
77
|
-
|
|
80
|
+
db.exec("COMMIT");
|
|
81
|
+
} catch (e) {
|
|
82
|
+
db.exec("ROLLBACK");
|
|
83
|
+
throw e;
|
|
84
|
+
}
|
|
78
85
|
logger.info(`Migration applied: ${migration.name}`);
|
|
79
86
|
}
|
|
80
87
|
}
|
|
@@ -313,10 +320,11 @@ var SqliteTaskRepository = class {
|
|
|
313
320
|
return logger.startSpan("TaskRepository.insert", () => {
|
|
314
321
|
try {
|
|
315
322
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
316
|
-
const
|
|
323
|
+
const level = getTaskLevel(input.type);
|
|
324
|
+
const maxActiveResult = this.getMaxActiveRankByLevel(input.projectId, level);
|
|
317
325
|
if (!maxActiveResult.ok) return maxActiveResult;
|
|
318
326
|
const maxActiveRank = maxActiveResult.value;
|
|
319
|
-
const minTerminalResult = this.
|
|
327
|
+
const minTerminalResult = this.getMinTerminalRankByLevel(input.projectId, level);
|
|
320
328
|
if (!minTerminalResult.ok) return minTerminalResult;
|
|
321
329
|
const minTerminalRank = minTerminalResult.value;
|
|
322
330
|
let rank;
|
|
@@ -376,10 +384,21 @@ var SqliteTaskRepository = class {
|
|
|
376
384
|
conditions.push("type = ?");
|
|
377
385
|
params.push(filter.type);
|
|
378
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
|
+
}
|
|
379
393
|
if (filter.parentId) {
|
|
380
394
|
conditions.push("parent_id = ?");
|
|
381
395
|
params.push(filter.parentId);
|
|
382
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
|
+
}
|
|
383
402
|
if (filter.search) {
|
|
384
403
|
const ftsQuery = filter.search.trim().split(/\s+/).map((term) => `"${term.replace(/"/g, '""')}"*`).join(" ");
|
|
385
404
|
conditions.push(`id IN (SELECT id FROM tasks_fts WHERE tasks_fts MATCH ?)`);
|
|
@@ -524,6 +543,65 @@ _${now}_
|
|
|
524
543
|
return err(new AppError("DB_ERROR", "Failed to get ranked tasks", e));
|
|
525
544
|
}
|
|
526
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
|
+
}
|
|
527
605
|
search(query, projectId) {
|
|
528
606
|
return logger.startSpan("TaskRepository.search", () => {
|
|
529
607
|
try {
|
|
@@ -873,7 +951,10 @@ var TaskFilterSchema = z2.object({
|
|
|
873
951
|
projectId: z2.string().optional(),
|
|
874
952
|
status: z2.enum(taskStatusValues).optional(),
|
|
875
953
|
type: z2.enum(taskTypeValues).optional(),
|
|
954
|
+
level: z2.number().int().min(1).max(2).optional(),
|
|
876
955
|
parentId: z2.string().optional(),
|
|
956
|
+
/** Multi-select filter: show tasks whose parentId is in this list. */
|
|
957
|
+
parentIds: z2.array(z2.string()).optional(),
|
|
877
958
|
search: z2.string().optional()
|
|
878
959
|
});
|
|
879
960
|
var RerankTaskSchema = z2.object({
|
|
@@ -902,12 +983,19 @@ var TaskServiceImpl = class {
|
|
|
902
983
|
const projectRef = parsed.data.projectId ?? projectIdOrName;
|
|
903
984
|
const projectResult = this.projectService.resolveProject(projectRef);
|
|
904
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
|
+
}
|
|
905
990
|
if (parsed.data.parentId) {
|
|
906
991
|
const parentResult = this.repo.findById(parsed.data.parentId);
|
|
907
992
|
if (!parentResult.ok) return parentResult;
|
|
908
993
|
if (!parentResult.value) {
|
|
909
994
|
return err(new AppError("NOT_FOUND", `Parent task not found: ${parsed.data.parentId}`));
|
|
910
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
|
+
}
|
|
911
999
|
}
|
|
912
1000
|
const project = projectResult.value;
|
|
913
1001
|
const taskIdResult = this.projectService.nextTaskId(project);
|
|
@@ -966,28 +1054,79 @@ var TaskServiceImpl = class {
|
|
|
966
1054
|
if (!blockersResult.ok) return blockersResult;
|
|
967
1055
|
const hasNonTerminalBlocker = blockersResult.value.some((b) => !isTerminalStatus(b.status));
|
|
968
1056
|
if (hasNonTerminalBlocker) {
|
|
969
|
-
return err(
|
|
970
|
-
new AppError("VALIDATION", "Task is blocked by unfinished dependencies")
|
|
971
|
-
);
|
|
1057
|
+
return err(new AppError("VALIDATION", "Task is blocked by unfinished dependencies"));
|
|
972
1058
|
}
|
|
973
1059
|
}
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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
|
+
}
|
|
979
1106
|
}
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
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;
|
|
983
1113
|
if (!isTerminalStatus(existing.status)) {
|
|
984
|
-
const maxRankResult = this.repo.
|
|
1114
|
+
const maxRankResult = this.repo.getMaxRankByLevel(existing.projectId, level);
|
|
985
1115
|
if (!maxRankResult.ok) return maxRankResult;
|
|
986
|
-
|
|
1116
|
+
const rerankResult = this.repo.rerank(id, maxRankResult.value + RANK_GAP);
|
|
1117
|
+
if (!rerankResult.ok) return rerankResult;
|
|
1118
|
+
this.propagateParentStatus(existing);
|
|
1119
|
+
return rerankResult;
|
|
987
1120
|
}
|
|
988
|
-
|
|
1121
|
+
this.propagateParentStatus(existing);
|
|
1122
|
+
return updateResult2;
|
|
989
1123
|
}
|
|
990
|
-
|
|
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;
|
|
991
1130
|
});
|
|
992
1131
|
}
|
|
993
1132
|
deleteTask(id) {
|
|
@@ -1001,6 +1140,9 @@ var TaskServiceImpl = class {
|
|
|
1001
1140
|
return err(new AppError("NOT_FOUND", `Parent task not found: ${parentId}`));
|
|
1002
1141
|
}
|
|
1003
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
|
+
}
|
|
1004
1146
|
const projectResult = this.projectService.resolveProject(parent.projectId);
|
|
1005
1147
|
if (!projectResult.ok) return projectResult;
|
|
1006
1148
|
const project = projectResult.value;
|
|
@@ -1010,6 +1152,9 @@ var TaskServiceImpl = class {
|
|
|
1010
1152
|
if (!parsed.success) {
|
|
1011
1153
|
return err(new AppError("VALIDATION", `Invalid subtask: ${parsed.error.message}`));
|
|
1012
1154
|
}
|
|
1155
|
+
if (getTaskLevel(parsed.data.type) === TaskLevel.Epic) {
|
|
1156
|
+
return err(new AppError("VALIDATION", `Subtask "${parsed.data.name}" cannot be an epic`));
|
|
1157
|
+
}
|
|
1013
1158
|
const taskIdResult = this.projectService.nextTaskId(project);
|
|
1014
1159
|
if (!taskIdResult.ok) return taskIdResult;
|
|
1015
1160
|
const result = this.repo.insert(taskIdResult.value, {
|
|
@@ -1063,11 +1208,16 @@ var TaskServiceImpl = class {
|
|
|
1063
1208
|
)
|
|
1064
1209
|
);
|
|
1065
1210
|
}
|
|
1211
|
+
const taskLevel = getTaskLevel(task.type);
|
|
1066
1212
|
const projectRef = projectIdOrName ?? task.projectId;
|
|
1067
1213
|
const projectResult = this.projectService.resolveProject(projectRef);
|
|
1068
1214
|
if (!projectResult.ok) return projectResult;
|
|
1069
1215
|
const projectId = projectResult.value.id;
|
|
1070
|
-
const rankedResult = this.repo.
|
|
1216
|
+
const rankedResult = this.repo.getRankedTasksByLevel(
|
|
1217
|
+
projectId,
|
|
1218
|
+
taskLevel,
|
|
1219
|
+
TaskStatus.Backlog
|
|
1220
|
+
);
|
|
1071
1221
|
if (!rankedResult.ok) return rankedResult;
|
|
1072
1222
|
const ranked = rankedResult.value.filter((t) => t.id !== taskId);
|
|
1073
1223
|
let newRank;
|
|
@@ -1151,6 +1301,42 @@ var TaskServiceImpl = class {
|
|
|
1151
1301
|
return this.repo.search(query, projectId);
|
|
1152
1302
|
});
|
|
1153
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
|
+
}
|
|
1154
1340
|
};
|
|
1155
1341
|
|
|
1156
1342
|
// src/types/dependency.ts
|
|
@@ -1673,7 +1859,7 @@ function registerProjectSetDefault(parent, container) {
|
|
|
1673
1859
|
|
|
1674
1860
|
// src/cli/commands/task/create.ts
|
|
1675
1861
|
function registerTaskCreate(parent, container) {
|
|
1676
|
-
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(
|
|
1677
1863
|
(opts) => {
|
|
1678
1864
|
const result = container.taskService.createTask(
|
|
1679
1865
|
{
|
|
@@ -1695,12 +1881,13 @@ function registerTaskCreate(parent, container) {
|
|
|
1695
1881
|
|
|
1696
1882
|
// src/cli/commands/task/list.ts
|
|
1697
1883
|
function registerTaskList(parent, container) {
|
|
1698
|
-
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(
|
|
1699
1885
|
(opts) => {
|
|
1700
1886
|
const result = container.taskService.listTasks({
|
|
1701
1887
|
projectId: opts.project,
|
|
1702
1888
|
status: opts.status ?? "backlog",
|
|
1703
1889
|
type: opts.type,
|
|
1890
|
+
level: opts.level ? parseInt(opts.level, 10) : void 0,
|
|
1704
1891
|
parentId: opts.parent,
|
|
1705
1892
|
search: opts.search
|
|
1706
1893
|
});
|
|
@@ -1719,7 +1906,7 @@ function registerTaskShow(parent, container) {
|
|
|
1719
1906
|
|
|
1720
1907
|
// src/cli/commands/task/update.ts
|
|
1721
1908
|
function registerTaskUpdate(parent, container) {
|
|
1722
|
-
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(
|
|
1723
1910
|
(id, opts) => {
|
|
1724
1911
|
const result = container.taskService.updateTask(id, {
|
|
1725
1912
|
name: opts.name,
|
|
@@ -1927,7 +2114,7 @@ function buildCLI(container) {
|
|
|
1927
2114
|
registerDepList(dep, container);
|
|
1928
2115
|
registerDepGraph(dep, container);
|
|
1929
2116
|
program.command("tui").description("Launch interactive terminal UI").option("-p, --project <project>", "Start with specific project").action(async (opts) => {
|
|
1930
|
-
const { launchTUI } = await import("./tui-
|
|
2117
|
+
const { launchTUI } = await import("./tui-FTXYP3HM.js");
|
|
1931
2118
|
await launchTUI(container, opts.project);
|
|
1932
2119
|
});
|
|
1933
2120
|
return program;
|
|
@@ -1943,7 +2130,7 @@ async function main() {
|
|
|
1943
2130
|
const container = createContainer(db);
|
|
1944
2131
|
const args = process.argv.slice(2);
|
|
1945
2132
|
if (args.length === 0) {
|
|
1946
|
-
const { launchTUI } = await import("./tui-
|
|
2133
|
+
const { launchTUI } = await import("./tui-FTXYP3HM.js");
|
|
1947
2134
|
await launchTUI(container);
|
|
1948
2135
|
} else {
|
|
1949
2136
|
const program = buildCLI(container);
|