@tomkapa/tayto 0.3.0 → 0.3.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.
@@ -131,6 +131,10 @@ var UIDependencyType = {
131
131
  BlockedBy: "blocked-by"
132
132
  };
133
133
  var RANK_GAP = 1e3;
134
+ function midpoint(a, b) {
135
+ const m = (a + b) / 2;
136
+ return m > a && m < b ? m : null;
137
+ }
134
138
  var TERMINAL_STATUSES = /* @__PURE__ */ new Set([
135
139
  TaskStatus.Done,
136
140
  TaskStatus.Cancelled
@@ -149,7 +153,8 @@ export {
149
153
  DependencyType,
150
154
  UIDependencyType,
151
155
  RANK_GAP,
156
+ midpoint,
152
157
  TERMINAL_STATUSES,
153
158
  isTerminalStatus
154
159
  };
155
- //# sourceMappingURL=chunk-FUNYPBWJ.js.map
160
+ //# sourceMappingURL=chunk-STYT4TGJ.js.map
@@ -1 +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":[]}
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/**\n * Collapse-safe midpoint between two rank values. Returns `null` when\n * IEEE 754 double precision cannot represent a strictly-between value —\n * callers use this as a signal to rebalance the rank grid and retry.\n * Returning a number unconditionally would silently collide with an\n * endpoint and corrupt the task ordering.\n */\nexport function midpoint(a: number, b: number): number | null {\n const m = (a + b) / 2;\n return m > a && m < b ? m : null;\n}\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
@@ -10,8 +10,9 @@ import {
10
10
  WORK_TYPES,
11
11
  getTaskLevel,
12
12
  isTerminalStatus,
13
- logger
14
- } from "./chunk-FUNYPBWJ.js";
13
+ logger,
14
+ midpoint
15
+ } from "./chunk-STYT4TGJ.js";
15
16
 
16
17
  // src/config/index.ts
17
18
  import { mkdirSync } from "fs";
@@ -332,18 +333,9 @@ var SqliteTaskRepository = class {
332
333
  try {
333
334
  const now = (/* @__PURE__ */ new Date()).toISOString();
334
335
  const level = getTaskLevel(input.type);
335
- const maxActiveResult = this.getMaxActiveRankByLevel(input.projectId, level);
336
- if (!maxActiveResult.ok) return maxActiveResult;
337
- const maxActiveRank = maxActiveResult.value;
338
- const minTerminalResult = this.getMinTerminalRankByLevel(input.projectId, level);
339
- if (!minTerminalResult.ok) return minTerminalResult;
340
- const minTerminalRank = minTerminalResult.value;
341
- let rank;
342
- if (minTerminalRank !== null && minTerminalRank > maxActiveRank) {
343
- rank = maxActiveRank > 0 ? (maxActiveRank + minTerminalRank) / 2 : minTerminalRank - RANK_GAP;
344
- } else {
345
- rank = maxActiveRank + RANK_GAP;
346
- }
336
+ const rankResult = this.computeInsertRank(input.projectId, level);
337
+ if (!rankResult.ok) return rankResult;
338
+ const rank = rankResult.value;
347
339
  this.db.prepare(
348
340
  `INSERT INTO tasks (id, project_id, parent_id, name, description, type, status, rank, technical_notes, additional_requirements, created_at, updated_at)
349
341
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
@@ -630,6 +622,104 @@ _${now}_
630
622
  return err(new AppError("DB_ERROR", "Failed to get ranked non-terminal tasks by level", e));
631
623
  }
632
624
  }
625
+ rebalanceByLevel(projectId, level) {
626
+ return logger.startSpan("TaskRepository.rebalanceByLevel", () => {
627
+ try {
628
+ const types = this.getTypesForLevel(level);
629
+ const typePlaceholders = types.map(() => "?").join(", ");
630
+ const rows = this.db.prepare(
631
+ `SELECT * FROM tasks
632
+ WHERE project_id = ? AND ${NOT_DELETED} AND type IN (${typePlaceholders})
633
+ ORDER BY
634
+ CASE WHEN status IN (${TERMINAL_PLACEHOLDERS}) THEN 1 ELSE 0 END ASC,
635
+ rank ASC,
636
+ id ASC`
637
+ ).all(projectId, ...types, ...TERMINAL_STATUS_ARRAY);
638
+ if (rows.length === 0) return ok(void 0);
639
+ const now = (/* @__PURE__ */ new Date()).toISOString();
640
+ const updateStmt = this.db.prepare(
641
+ "UPDATE tasks SET rank = ?, updated_at = ? WHERE id = ?"
642
+ );
643
+ this.db.exec("BEGIN");
644
+ try {
645
+ for (let i = 0; i < rows.length; i++) {
646
+ const row = rows[i];
647
+ if (!row) continue;
648
+ const newRank = (i + 1) * RANK_GAP;
649
+ if (row.rank === newRank) continue;
650
+ updateStmt.run(newRank, now, row.id);
651
+ }
652
+ this.db.exec("COMMIT");
653
+ } catch (inner) {
654
+ this.db.exec("ROLLBACK");
655
+ throw inner;
656
+ }
657
+ return ok(void 0);
658
+ } catch (e) {
659
+ return err(new AppError("DB_ERROR", "Failed to rebalance ranks by level", e));
660
+ }
661
+ });
662
+ }
663
+ /**
664
+ * Fetch `(maxActive, minTerminal)` for a level in a single SQL round-trip.
665
+ * Used by the insert hot path — faster than calling the two separate
666
+ * level accessors in sequence.
667
+ */
668
+ getRankBoundsByLevel(projectId, level) {
669
+ try {
670
+ const types = this.getTypesForLevel(level);
671
+ const typePlaceholders = types.map(() => "?").join(", ");
672
+ const row = this.db.prepare(
673
+ `SELECT
674
+ MAX(CASE WHEN status NOT IN (${TERMINAL_PLACEHOLDERS}) THEN rank END) AS max_active,
675
+ MIN(CASE WHEN status IN (${TERMINAL_PLACEHOLDERS}) THEN rank END) AS min_terminal
676
+ FROM tasks
677
+ WHERE project_id = ? AND ${NOT_DELETED} AND type IN (${typePlaceholders})`
678
+ ).get(...TERMINAL_STATUS_ARRAY, ...TERMINAL_STATUS_ARRAY, projectId, ...types);
679
+ return ok({
680
+ maxActive: row?.max_active ?? 0,
681
+ minTerminal: row?.min_terminal ?? null
682
+ });
683
+ } catch (e) {
684
+ return err(new AppError("DB_ERROR", "Failed to get rank bounds by level", e));
685
+ }
686
+ }
687
+ /**
688
+ * Compute a fresh rank for a new active task at the given level.
689
+ *
690
+ * Wedges the new task between the last active task and the first terminal
691
+ * task. If the midpoint collapses against either endpoint, or if the
692
+ * level is already in a corrupt interleaved state (terminal rank ≤
693
+ * active rank), rebalance the level once and recompute.
694
+ */
695
+ computeInsertRank(projectId, level) {
696
+ const attempt = () => {
697
+ const boundsResult = this.getRankBoundsByLevel(projectId, level);
698
+ if (!boundsResult.ok) return boundsResult;
699
+ const { maxActive, minTerminal } = boundsResult.value;
700
+ if (minTerminal === null) {
701
+ return ok(maxActive + RANK_GAP);
702
+ }
703
+ if (minTerminal <= maxActive) {
704
+ return ok(null);
705
+ }
706
+ if (maxActive <= 0) {
707
+ return ok(minTerminal - RANK_GAP);
708
+ }
709
+ return ok(midpoint(maxActive, minTerminal));
710
+ };
711
+ const first = attempt();
712
+ if (!first.ok) return first;
713
+ if (first.value !== null) return ok(first.value);
714
+ const rebalanceResult = this.rebalanceByLevel(projectId, level);
715
+ if (!rebalanceResult.ok) return rebalanceResult;
716
+ const second = attempt();
717
+ if (!second.ok) return second;
718
+ if (second.value === null) {
719
+ return err(new AppError("DB_ERROR", "Rank computation did not converge after rebalance"));
720
+ }
721
+ return ok(second.value);
722
+ }
633
723
  search(query, projectId) {
634
724
  return logger.startSpan("TaskRepository.search", () => {
635
725
  try {
@@ -1001,7 +1091,11 @@ var RerankTaskSchema = z2.object({
1001
1091
  taskId: z2.string().min(1, "Task id is required"),
1002
1092
  afterId: z2.string().optional(),
1003
1093
  beforeId: z2.string().optional(),
1004
- position: z2.number().int().min(1).optional()
1094
+ position: z2.number().int().min(1).optional(),
1095
+ /** Move to the top of active tasks (highest priority). */
1096
+ top: z2.boolean().optional(),
1097
+ /** Move to the bottom of active tasks, kept above terminal (done/cancelled) tasks. */
1098
+ bottom: z2.boolean().optional()
1005
1099
  });
1006
1100
 
1007
1101
  // src/service/task.service.ts
@@ -1224,13 +1318,13 @@ var TaskServiceImpl = class {
1224
1318
  if (!parsed.success) {
1225
1319
  return err(new AppError("VALIDATION", parsed.error.message));
1226
1320
  }
1227
- const { taskId, afterId, beforeId, position } = parsed.data;
1228
- const specifiedCount = [afterId, beforeId, position].filter((v) => v !== void 0).length;
1321
+ const { taskId, afterId, beforeId, position, top, bottom } = parsed.data;
1322
+ const specifiedCount = [afterId, beforeId, position].filter((v) => v !== void 0).length + (top ? 1 : 0) + (bottom ? 1 : 0);
1229
1323
  if (specifiedCount !== 1) {
1230
1324
  return err(
1231
1325
  new AppError(
1232
1326
  "VALIDATION",
1233
- "Exactly one of --after, --before, or --position must be specified"
1327
+ "Exactly one of --after, --before, --position, --top, or --bottom must be specified"
1234
1328
  )
1235
1329
  );
1236
1330
  }
@@ -1253,71 +1347,102 @@ var TaskServiceImpl = class {
1253
1347
  const projectResult = this.projectService.resolveProject(projectRef);
1254
1348
  if (!projectResult.ok) return projectResult;
1255
1349
  const projectId = projectResult.value.id;
1256
- const rankedResult = this.repo.getRankedNonTerminalTasksByLevel(projectId, taskLevel);
1257
- if (!rankedResult.ok) return rankedResult;
1258
- const ranked = rankedResult.value.filter((t) => t.id !== taskId);
1259
- let newRank;
1260
- if (afterId) {
1261
- const anchor = ranked.find((t) => t.id === afterId);
1262
- if (!anchor) {
1263
- return err(new AppError("NOT_FOUND", `Anchor task not found among active tasks: ${afterId}`));
1350
+ const depService = this.getDependencyService();
1351
+ const blockersResult = depService.listBlockers(taskId);
1352
+ if (!blockersResult.ok) return blockersResult;
1353
+ const constrainingBlockers = blockersResult.value.filter(
1354
+ (b) => !isTerminalStatus(b.status) && b.projectId === projectId
1355
+ );
1356
+ const dependentsResult = depService.listDependents(taskId);
1357
+ if (!dependentsResult.ok) return dependentsResult;
1358
+ const constrainingDependents = dependentsResult.value.filter(
1359
+ (d) => !isTerminalStatus(d.status) && d.projectId === projectId
1360
+ );
1361
+ const attempt = () => {
1362
+ const rankedResult = this.repo.getRankedNonTerminalTasksByLevel(projectId, taskLevel);
1363
+ if (!rankedResult.ok) return rankedResult;
1364
+ const ranked = rankedResult.value.filter((t) => t.id !== taskId);
1365
+ if (top === true) {
1366
+ return ok(this.computeTopRank(ranked, constrainingBlockers));
1264
1367
  }
1265
- const anchorIndex = ranked.indexOf(anchor);
1266
- const next = ranked[anchorIndex + 1];
1267
- newRank = next ? (anchor.rank + next.rank) / 2 : anchor.rank + RANK_GAP;
1268
- } else if (beforeId) {
1269
- const anchor = ranked.find((t) => t.id === beforeId);
1270
- if (!anchor) {
1271
- return err(new AppError("NOT_FOUND", `Anchor task not found among active tasks: ${beforeId}`));
1368
+ if (bottom === true) {
1369
+ const minTerminalResult = this.repo.getMinTerminalRankByLevel(projectId, taskLevel);
1370
+ if (!minTerminalResult.ok) return minTerminalResult;
1371
+ return ok(
1372
+ this.computeBottomRank(ranked, minTerminalResult.value, constrainingDependents)
1373
+ );
1374
+ }
1375
+ if (afterId) {
1376
+ const anchorIndex = ranked.findIndex((t) => t.id === afterId);
1377
+ const anchor = ranked[anchorIndex];
1378
+ if (!anchor) {
1379
+ return err(
1380
+ new AppError("NOT_FOUND", `Anchor task not found among active tasks: ${afterId}`)
1381
+ );
1382
+ }
1383
+ const next = ranked[anchorIndex + 1];
1384
+ return ok(next ? midpoint(anchor.rank, next.rank) : anchor.rank + RANK_GAP);
1385
+ }
1386
+ if (beforeId) {
1387
+ const anchorIndex = ranked.findIndex((t) => t.id === beforeId);
1388
+ const anchor = ranked[anchorIndex];
1389
+ if (!anchor) {
1390
+ return err(
1391
+ new AppError("NOT_FOUND", `Anchor task not found among active tasks: ${beforeId}`)
1392
+ );
1393
+ }
1394
+ const prev = ranked[anchorIndex - 1];
1395
+ return ok(prev ? midpoint(prev.rank, anchor.rank) : anchor.rank - RANK_GAP);
1272
1396
  }
1273
- const anchorIndex = ranked.indexOf(anchor);
1274
- const prev = ranked[anchorIndex - 1];
1275
- newRank = prev ? (prev.rank + anchor.rank) / 2 : anchor.rank - RANK_GAP;
1276
- } else {
1277
1397
  const pos = position;
1278
1398
  if (pos < 1) {
1279
1399
  return err(new AppError("VALIDATION", "Position must be >= 1"));
1280
1400
  }
1281
1401
  if (pos === 1) {
1282
- const first = ranked[0];
1283
- newRank = first ? first.rank - RANK_GAP : RANK_GAP;
1284
- } else if (pos > ranked.length) {
1285
- const last = ranked[ranked.length - 1];
1286
- newRank = last ? last.rank + RANK_GAP : RANK_GAP;
1287
- } else {
1288
- const above = ranked[pos - 2];
1289
- const below = ranked[pos - 1];
1290
- if (!above || !below) {
1291
- return err(new AppError("DB_ERROR", "Unexpected missing neighbor tasks"));
1292
- }
1293
- newRank = (above.rank + below.rank) / 2;
1402
+ return ok(this.computeTopRank(ranked));
1403
+ }
1404
+ if (pos > ranked.length) {
1405
+ const minTerminalResult = this.repo.getMinTerminalRankByLevel(projectId, taskLevel);
1406
+ if (!minTerminalResult.ok) return minTerminalResult;
1407
+ return ok(this.computeBottomRank(ranked, minTerminalResult.value));
1408
+ }
1409
+ const above = ranked[pos - 2];
1410
+ const below = ranked[pos - 1];
1411
+ if (!above || !below) {
1412
+ return err(new AppError("DB_ERROR", "Unexpected missing neighbor tasks"));
1413
+ }
1414
+ return ok(midpoint(above.rank, below.rank));
1415
+ };
1416
+ let computed = attempt();
1417
+ if (!computed.ok) return computed;
1418
+ if (computed.value === null) {
1419
+ const rb = this.repo.rebalanceByLevel(projectId, taskLevel);
1420
+ if (!rb.ok) return rb;
1421
+ computed = attempt();
1422
+ if (!computed.ok) return computed;
1423
+ if (computed.value === null) {
1424
+ return err(new AppError("DB_ERROR", "Rank computation did not converge after rebalance"));
1294
1425
  }
1295
1426
  }
1296
- const depService = this.getDependencyService();
1297
- const blockersResult = depService.listBlockers(taskId);
1298
- if (blockersResult.ok) {
1299
- for (const blocker of blockersResult.value) {
1300
- if (blocker.projectId === projectId && newRank < blocker.rank) {
1301
- return err(
1302
- new AppError(
1303
- "VALIDATION",
1304
- `Cannot rank above blocker "${blocker.id}" (${blocker.name}). Complete or remove the dependency first.`
1305
- )
1306
- );
1307
- }
1427
+ const newRank = computed.value;
1428
+ for (const blocker of constrainingBlockers) {
1429
+ if (newRank < blocker.rank) {
1430
+ return err(
1431
+ new AppError(
1432
+ "VALIDATION",
1433
+ `Cannot rank above blocker "${blocker.id}" (${blocker.name}). Complete or remove the dependency first.`
1434
+ )
1435
+ );
1308
1436
  }
1309
1437
  }
1310
- const dependentsResult = depService.listDependents(taskId);
1311
- if (dependentsResult.ok) {
1312
- for (const dep of dependentsResult.value) {
1313
- if (dep.projectId === projectId && newRank > dep.rank) {
1314
- return err(
1315
- new AppError(
1316
- "VALIDATION",
1317
- `Cannot rank below dependent "${dep.id}" (${dep.name}). Complete or remove the dependency first.`
1318
- )
1319
- );
1320
- }
1438
+ for (const dep of constrainingDependents) {
1439
+ if (newRank > dep.rank) {
1440
+ return err(
1441
+ new AppError(
1442
+ "VALIDATION",
1443
+ `Cannot rank below dependent "${dep.id}" (${dep.name}). Complete or remove the dependency first.`
1444
+ )
1445
+ );
1321
1446
  }
1322
1447
  }
1323
1448
  return this.repo.rerank(taskId, newRank);
@@ -1337,6 +1462,48 @@ var TaskServiceImpl = class {
1337
1462
  return this.repo.search(query, projectId);
1338
1463
  });
1339
1464
  }
1465
+ /**
1466
+ * Rank for placing a task at the top of the active list. Clamps to
1467
+ * "immediately after the highest-ranked blocker" when blockers exist
1468
+ * rather than returning a value that would fail validation.
1469
+ *
1470
+ * Returns `null` to signal FP precision collapse (caller rebalances).
1471
+ */
1472
+ computeTopRank(ranked, constrainingBlockers = []) {
1473
+ if (constrainingBlockers.length > 0) {
1474
+ const highestBlocker = constrainingBlockers.reduce((a, b) => a.rank > b.rank ? a : b);
1475
+ const idx = ranked.findIndex((t) => t.id === highestBlocker.id);
1476
+ if (idx >= 0) {
1477
+ const next = ranked[idx + 1];
1478
+ return next ? midpoint(highestBlocker.rank, next.rank) : highestBlocker.rank + RANK_GAP;
1479
+ }
1480
+ }
1481
+ const first = ranked[0];
1482
+ return first ? first.rank - RANK_GAP : RANK_GAP;
1483
+ }
1484
+ /**
1485
+ * Rank for placing a task at the bottom of the active list.
1486
+ * - Stays above terminal tasks: terminal tasks live at `maxRank + RANK_GAP`
1487
+ * so a naive `last.rank + RANK_GAP` would collide with the most-recently
1488
+ * completed task.
1489
+ * - Clamps above any dependents rather than failing validation.
1490
+ *
1491
+ * `minTerminal` is passed in so this helper stays pure (no DB access).
1492
+ * Returns `null` to signal FP precision collapse.
1493
+ */
1494
+ computeBottomRank(ranked, minTerminal, constrainingDependents = []) {
1495
+ if (constrainingDependents.length > 0) {
1496
+ const lowestDependent = constrainingDependents.reduce((a, b) => a.rank < b.rank ? a : b);
1497
+ const idx = ranked.findIndex((t) => t.id === lowestDependent.id);
1498
+ if (idx >= 0) {
1499
+ const prev = ranked[idx - 1];
1500
+ return prev ? midpoint(prev.rank, lowestDependent.rank) : lowestDependent.rank - RANK_GAP;
1501
+ }
1502
+ }
1503
+ const last = ranked[ranked.length - 1];
1504
+ if (!last) return RANK_GAP;
1505
+ return minTerminal !== null && minTerminal > last.rank ? midpoint(last.rank, minTerminal) : last.rank + RANK_GAP;
1506
+ }
1340
1507
  /**
1341
1508
  * Auto-propagate status to the parent task after a child status change.
1342
1509
  * - If a child moves to in-progress and parent is backlog/todo → parent becomes in-progress.
@@ -1997,14 +2164,16 @@ function registerTaskBreakdown(parent, container) {
1997
2164
 
1998
2165
  // src/cli/commands/task/rank.ts
1999
2166
  function registerTaskRank(parent, container) {
2000
- parent.command("rank <id>").description("Re-rank a task in the backlog (Jira-style positioning)").option("--after <taskId>", "Place immediately after this task").option("--before <taskId>", "Place immediately before this task").option("--position <n>", "Place at 1-based position in backlog").option("-p, --project <project>", "Project id or name").action(
2167
+ parent.command("rank <id>").description("Re-rank a task in the backlog (Jira-style positioning)").option("--after <taskId>", "Place immediately after this task").option("--before <taskId>", "Place immediately before this task").option("--position <n>", "Place at 1-based position in backlog").option("--top", "Move to the top of active tasks").option("--bottom", "Move to the bottom of active tasks (above done tasks)").option("-p, --project <project>", "Project id or name").action(
2001
2168
  (id, opts) => {
2002
2169
  const result = container.taskService.rerankTask(
2003
2170
  {
2004
2171
  taskId: id,
2005
2172
  afterId: opts.after,
2006
2173
  beforeId: opts.before,
2007
- position: opts.position ? parseInt(opts.position, 10) : void 0
2174
+ position: opts.position ? parseInt(opts.position, 10) : void 0,
2175
+ top: opts.top,
2176
+ bottom: opts.bottom
2008
2177
  },
2009
2178
  opts.project
2010
2179
  );
@@ -2150,7 +2319,7 @@ function buildCLI(container) {
2150
2319
  registerDepList(dep, container);
2151
2320
  registerDepGraph(dep, container);
2152
2321
  program.command("tui").description("Launch interactive terminal UI").option("-p, --project <project>", "Start with specific project").action(async (opts) => {
2153
- const { launchTUI } = await import("./tui-5JJH67YY.js");
2322
+ const { launchTUI } = await import("./tui-JEP3F4JS.js");
2154
2323
  await launchTUI(container, opts.project);
2155
2324
  });
2156
2325
  return program;
@@ -2166,7 +2335,7 @@ async function main() {
2166
2335
  const container = createContainer(db, config.dbPath);
2167
2336
  const args = process.argv.slice(2);
2168
2337
  if (args.length === 0) {
2169
- const { launchTUI } = await import("./tui-5JJH67YY.js");
2338
+ const { launchTUI } = await import("./tui-JEP3F4JS.js");
2170
2339
  await launchTUI(container);
2171
2340
  } else {
2172
2341
  const program = buildCLI(container);