@tomkapa/tayto 0.3.1 → 0.4.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/index.js CHANGED
@@ -8,10 +8,14 @@ import {
8
8
  TaskType,
9
9
  UIDependencyType,
10
10
  WORK_TYPES,
11
+ detectGitRemote,
12
+ err,
11
13
  getTaskLevel,
12
14
  isTerminalStatus,
13
- logger
14
- } from "./chunk-FUNYPBWJ.js";
15
+ logger,
16
+ midpoint,
17
+ ok
18
+ } from "./chunk-74Q55TOV.js";
15
19
 
16
20
  // src/config/index.ts
17
21
  import { mkdirSync } from "fs";
@@ -105,14 +109,6 @@ async function shutdownTelemetry() {
105
109
  }
106
110
  }
107
111
 
108
- // src/types/common.ts
109
- function ok(value) {
110
- return { ok: true, value };
111
- }
112
- function err(error) {
113
- return { ok: false, error };
114
- }
115
-
116
112
  // src/errors/app-error.ts
117
113
  var AppError = class extends Error {
118
114
  constructor(code, message, cause) {
@@ -155,6 +151,7 @@ function rowToProject(row) {
155
151
  name: row.name,
156
152
  description: row.description,
157
153
  isDefault: row.is_default === 1,
154
+ gitRemote: row.git_remote,
158
155
  createdAt: row.created_at,
159
156
  updatedAt: row.updated_at
160
157
  };
@@ -173,14 +170,15 @@ var SqliteProjectRepository = class {
173
170
  this.db.prepare("UPDATE projects SET is_default = 0 WHERE is_default = 1").run();
174
171
  }
175
172
  this.db.prepare(
176
- `INSERT INTO projects (id, key, name, description, is_default, created_at, updated_at)
177
- VALUES (?, ?, ?, ?, ?, ?, ?)`
173
+ `INSERT INTO projects (id, key, name, description, is_default, git_remote, created_at, updated_at)
174
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
178
175
  ).run(
179
176
  id,
180
177
  input.key,
181
178
  input.name,
182
179
  input.description ?? "",
183
180
  input.isDefault ? 1 : 0,
181
+ input.gitRemote ?? null,
184
182
  now,
185
183
  now
186
184
  );
@@ -191,6 +189,15 @@ var SqliteProjectRepository = class {
191
189
  return ok(rowToProject(row));
192
190
  } catch (e) {
193
191
  if (e instanceof Error && e.message.includes("UNIQUE constraint")) {
192
+ if (e.message.includes("git_remote")) {
193
+ return err(
194
+ new AppError(
195
+ "DUPLICATE",
196
+ `Git remote already linked to another project: ${input.gitRemote}`,
197
+ e
198
+ )
199
+ );
200
+ }
194
201
  return err(new AppError("DUPLICATE", `Project name already exists: ${input.name}`, e));
195
202
  }
196
203
  return err(new AppError("DB_ERROR", "Failed to insert project", e));
@@ -221,6 +228,14 @@ var SqliteProjectRepository = class {
221
228
  return err(new AppError("DB_ERROR", "Failed to find project by name", e));
222
229
  }
223
230
  }
231
+ findByGitRemote(remote) {
232
+ try {
233
+ const row = this.db.prepare(`SELECT * FROM projects WHERE git_remote = ? AND ${NOT_DELETED}`).get(remote);
234
+ return ok(row ? rowToProject(row) : null);
235
+ } catch (e) {
236
+ return err(new AppError("DB_ERROR", "Failed to find project by git remote", e));
237
+ }
238
+ }
224
239
  findDefault() {
225
240
  try {
226
241
  const row = this.db.prepare(`SELECT * FROM projects WHERE is_default = 1 AND ${NOT_DELETED}`).get();
@@ -250,12 +265,13 @@ var SqliteProjectRepository = class {
250
265
  }
251
266
  this.db.prepare(
252
267
  `UPDATE projects SET
253
- name = ?, description = ?, is_default = ?, updated_at = ?
268
+ name = ?, description = ?, is_default = ?, git_remote = ?, updated_at = ?
254
269
  WHERE id = ?`
255
270
  ).run(
256
271
  input.name ?? existing.name,
257
272
  input.description ?? existing.description,
258
273
  input.isDefault !== void 0 ? input.isDefault ? 1 : 0 : existing.is_default,
274
+ input.gitRemote !== void 0 ? input.gitRemote : existing.git_remote,
259
275
  now,
260
276
  id
261
277
  );
@@ -266,6 +282,11 @@ var SqliteProjectRepository = class {
266
282
  return ok(rowToProject(row));
267
283
  } catch (e) {
268
284
  if (e instanceof Error && e.message.includes("UNIQUE constraint")) {
285
+ if (e.message.includes("git_remote")) {
286
+ return err(
287
+ new AppError("DUPLICATE", `Git remote already linked to another project`, e)
288
+ );
289
+ }
269
290
  return err(new AppError("DUPLICATE", `Project name already exists`, e));
270
291
  }
271
292
  return err(new AppError("DB_ERROR", "Failed to update project", e));
@@ -332,18 +353,9 @@ var SqliteTaskRepository = class {
332
353
  try {
333
354
  const now = (/* @__PURE__ */ new Date()).toISOString();
334
355
  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
- }
356
+ const rankResult = this.computeInsertRank(input.projectId, level);
357
+ if (!rankResult.ok) return rankResult;
358
+ const rank = rankResult.value;
347
359
  this.db.prepare(
348
360
  `INSERT INTO tasks (id, project_id, parent_id, name, description, type, status, rank, technical_notes, additional_requirements, created_at, updated_at)
349
361
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
@@ -630,6 +642,104 @@ _${now}_
630
642
  return err(new AppError("DB_ERROR", "Failed to get ranked non-terminal tasks by level", e));
631
643
  }
632
644
  }
645
+ rebalanceByLevel(projectId, level) {
646
+ return logger.startSpan("TaskRepository.rebalanceByLevel", () => {
647
+ try {
648
+ const types = this.getTypesForLevel(level);
649
+ const typePlaceholders = types.map(() => "?").join(", ");
650
+ const rows = this.db.prepare(
651
+ `SELECT * FROM tasks
652
+ WHERE project_id = ? AND ${NOT_DELETED} AND type IN (${typePlaceholders})
653
+ ORDER BY
654
+ CASE WHEN status IN (${TERMINAL_PLACEHOLDERS}) THEN 1 ELSE 0 END ASC,
655
+ rank ASC,
656
+ id ASC`
657
+ ).all(projectId, ...types, ...TERMINAL_STATUS_ARRAY);
658
+ if (rows.length === 0) return ok(void 0);
659
+ const now = (/* @__PURE__ */ new Date()).toISOString();
660
+ const updateStmt = this.db.prepare(
661
+ "UPDATE tasks SET rank = ?, updated_at = ? WHERE id = ?"
662
+ );
663
+ this.db.exec("BEGIN");
664
+ try {
665
+ for (let i = 0; i < rows.length; i++) {
666
+ const row = rows[i];
667
+ if (!row) continue;
668
+ const newRank = (i + 1) * RANK_GAP;
669
+ if (row.rank === newRank) continue;
670
+ updateStmt.run(newRank, now, row.id);
671
+ }
672
+ this.db.exec("COMMIT");
673
+ } catch (inner) {
674
+ this.db.exec("ROLLBACK");
675
+ throw inner;
676
+ }
677
+ return ok(void 0);
678
+ } catch (e) {
679
+ return err(new AppError("DB_ERROR", "Failed to rebalance ranks by level", e));
680
+ }
681
+ });
682
+ }
683
+ /**
684
+ * Fetch `(maxActive, minTerminal)` for a level in a single SQL round-trip.
685
+ * Used by the insert hot path — faster than calling the two separate
686
+ * level accessors in sequence.
687
+ */
688
+ getRankBoundsByLevel(projectId, level) {
689
+ try {
690
+ const types = this.getTypesForLevel(level);
691
+ const typePlaceholders = types.map(() => "?").join(", ");
692
+ const row = this.db.prepare(
693
+ `SELECT
694
+ MAX(CASE WHEN status NOT IN (${TERMINAL_PLACEHOLDERS}) THEN rank END) AS max_active,
695
+ MIN(CASE WHEN status IN (${TERMINAL_PLACEHOLDERS}) THEN rank END) AS min_terminal
696
+ FROM tasks
697
+ WHERE project_id = ? AND ${NOT_DELETED} AND type IN (${typePlaceholders})`
698
+ ).get(...TERMINAL_STATUS_ARRAY, ...TERMINAL_STATUS_ARRAY, projectId, ...types);
699
+ return ok({
700
+ maxActive: row?.max_active ?? 0,
701
+ minTerminal: row?.min_terminal ?? null
702
+ });
703
+ } catch (e) {
704
+ return err(new AppError("DB_ERROR", "Failed to get rank bounds by level", e));
705
+ }
706
+ }
707
+ /**
708
+ * Compute a fresh rank for a new active task at the given level.
709
+ *
710
+ * Wedges the new task between the last active task and the first terminal
711
+ * task. If the midpoint collapses against either endpoint, or if the
712
+ * level is already in a corrupt interleaved state (terminal rank ≤
713
+ * active rank), rebalance the level once and recompute.
714
+ */
715
+ computeInsertRank(projectId, level) {
716
+ const attempt = () => {
717
+ const boundsResult = this.getRankBoundsByLevel(projectId, level);
718
+ if (!boundsResult.ok) return boundsResult;
719
+ const { maxActive, minTerminal } = boundsResult.value;
720
+ if (minTerminal === null) {
721
+ return ok(maxActive + RANK_GAP);
722
+ }
723
+ if (minTerminal <= maxActive) {
724
+ return ok(null);
725
+ }
726
+ if (maxActive <= 0) {
727
+ return ok(minTerminal - RANK_GAP);
728
+ }
729
+ return ok(midpoint(maxActive, minTerminal));
730
+ };
731
+ const first = attempt();
732
+ if (!first.ok) return first;
733
+ if (first.value !== null) return ok(first.value);
734
+ const rebalanceResult = this.rebalanceByLevel(projectId, level);
735
+ if (!rebalanceResult.ok) return rebalanceResult;
736
+ const second = attempt();
737
+ if (!second.ok) return second;
738
+ if (second.value === null) {
739
+ return err(new AppError("DB_ERROR", "Rank computation did not converge after rebalance"));
740
+ }
741
+ return ok(second.value);
742
+ }
633
743
  search(query, projectId) {
634
744
  return logger.startSpan("TaskRepository.search", () => {
635
745
  try {
@@ -852,20 +962,24 @@ var CreateProjectSchema = z.object({
852
962
  name: z.string().min(1, "Project name is required").max(255),
853
963
  key: z.string().min(2, "Project key must be at least 2 characters").max(7, "Project key must be at most 7 characters").regex(/^[A-Za-z0-9]+$/, "Project key must contain only letters and digits").transform((v) => v.toUpperCase()).optional(),
854
964
  description: z.string().max(5e3).optional(),
855
- isDefault: z.boolean().optional()
965
+ isDefault: z.boolean().optional(),
966
+ gitRemote: z.string().min(1, "Git remote URL must not be empty").nullable().optional()
856
967
  });
857
968
  var UpdateProjectSchema = z.object({
858
969
  name: z.string().min(1).max(255).optional(),
859
970
  description: z.string().max(5e3).optional(),
860
- isDefault: z.boolean().optional()
971
+ isDefault: z.boolean().optional(),
972
+ gitRemote: z.string().min(1, "Git remote URL must not be empty").nullable().optional()
861
973
  });
862
974
 
863
975
  // src/service/project.service.ts
864
976
  var ProjectServiceImpl = class {
865
- constructor(repo) {
977
+ constructor(repo, detectRemote = detectGitRemote) {
866
978
  this.repo = repo;
979
+ this.detectRemote = detectRemote;
867
980
  }
868
981
  repo;
982
+ detectRemote;
869
983
  createProject(input) {
870
984
  return logger.startSpan("ProjectService.createProject", () => {
871
985
  const parsed = CreateProjectSchema.safeParse(input);
@@ -947,6 +1061,58 @@ var ProjectServiceImpl = class {
947
1061
  );
948
1062
  });
949
1063
  }
1064
+ resolveProjectWithGit(idOrName, cwd) {
1065
+ return logger.startSpan("ProjectService.resolveProjectWithGit", () => {
1066
+ if (idOrName) {
1067
+ return this.resolveProject(idOrName);
1068
+ }
1069
+ const remoteResult = this.detectRemote(cwd);
1070
+ if (remoteResult.ok && remoteResult.value) {
1071
+ const byRemote = this.repo.findByGitRemote(remoteResult.value);
1072
+ if (!byRemote.ok) return byRemote;
1073
+ if (byRemote.value) {
1074
+ logger.info(
1075
+ `resolveProjectWithGit: matched git remote to project key=${byRemote.value.key}`
1076
+ );
1077
+ return ok(byRemote.value);
1078
+ }
1079
+ }
1080
+ return this.resolveProject();
1081
+ });
1082
+ }
1083
+ linkGitRemote(idOrName, remote) {
1084
+ return logger.startSpan("ProjectService.linkGitRemote", () => {
1085
+ const resolved = this.resolveProject(idOrName);
1086
+ if (!resolved.ok) return resolved;
1087
+ let url = remote;
1088
+ if (!url) {
1089
+ const detected = this.detectRemote();
1090
+ if (!detected.ok) return detected;
1091
+ if (!detected.value) {
1092
+ return err(
1093
+ new AppError(
1094
+ "NOT_FOUND",
1095
+ "No git remote detected in current directory. Use --remote <url> to specify one explicitly."
1096
+ )
1097
+ );
1098
+ }
1099
+ url = detected.value;
1100
+ }
1101
+ return this.repo.update(resolved.value.id, { gitRemote: url });
1102
+ });
1103
+ }
1104
+ unlinkGitRemote(idOrName) {
1105
+ return logger.startSpan("ProjectService.unlinkGitRemote", () => {
1106
+ const resolved = this.resolveProject(idOrName);
1107
+ if (!resolved.ok) return resolved;
1108
+ if (!resolved.value.gitRemote) {
1109
+ return err(
1110
+ new AppError("NOT_FOUND", `Project "${resolved.value.name}" has no linked git remote.`)
1111
+ );
1112
+ }
1113
+ return this.repo.update(resolved.value.id, { gitRemote: null });
1114
+ });
1115
+ }
950
1116
  nextTaskId(project) {
951
1117
  return logger.startSpan("ProjectService.nextTaskId", () => {
952
1118
  const counterResult = this.repo.incrementTaskCounter(project.id);
@@ -1001,7 +1167,11 @@ var RerankTaskSchema = z2.object({
1001
1167
  taskId: z2.string().min(1, "Task id is required"),
1002
1168
  afterId: z2.string().optional(),
1003
1169
  beforeId: z2.string().optional(),
1004
- position: z2.number().int().min(1).optional()
1170
+ position: z2.number().int().min(1).optional(),
1171
+ /** Move to the top of active tasks (highest priority). */
1172
+ top: z2.boolean().optional(),
1173
+ /** Move to the bottom of active tasks, kept above terminal (done/cancelled) tasks. */
1174
+ bottom: z2.boolean().optional()
1005
1175
  });
1006
1176
 
1007
1177
  // src/service/task.service.ts
@@ -1074,12 +1244,12 @@ var TaskServiceImpl = class {
1074
1244
  if (!parsed.success) {
1075
1245
  return err(new AppError("VALIDATION", parsed.error.message));
1076
1246
  }
1077
- let resolvedFilter = parsed.data;
1078
- if (parsed.data.projectId) {
1079
- const projectResult = this.projectService.resolveProject(parsed.data.projectId);
1080
- if (!projectResult.ok) return projectResult;
1081
- resolvedFilter = { ...resolvedFilter, projectId: projectResult.value.id };
1082
- }
1247
+ const projectResult = this.projectService.resolveProject(parsed.data.projectId);
1248
+ if (!projectResult.ok) return projectResult;
1249
+ const resolvedFilter = {
1250
+ ...parsed.data,
1251
+ projectId: projectResult.value.id
1252
+ };
1083
1253
  return this.repo.findMany(resolvedFilter);
1084
1254
  });
1085
1255
  }
@@ -1224,13 +1394,13 @@ var TaskServiceImpl = class {
1224
1394
  if (!parsed.success) {
1225
1395
  return err(new AppError("VALIDATION", parsed.error.message));
1226
1396
  }
1227
- const { taskId, afterId, beforeId, position } = parsed.data;
1228
- const specifiedCount = [afterId, beforeId, position].filter((v) => v !== void 0).length;
1397
+ const { taskId, afterId, beforeId, position, top, bottom } = parsed.data;
1398
+ const specifiedCount = [afterId, beforeId, position].filter((v) => v !== void 0).length + (top ? 1 : 0) + (bottom ? 1 : 0);
1229
1399
  if (specifiedCount !== 1) {
1230
1400
  return err(
1231
1401
  new AppError(
1232
1402
  "VALIDATION",
1233
- "Exactly one of --after, --before, or --position must be specified"
1403
+ "Exactly one of --after, --before, --position, --top, or --bottom must be specified"
1234
1404
  )
1235
1405
  );
1236
1406
  }
@@ -1253,77 +1423,102 @@ var TaskServiceImpl = class {
1253
1423
  const projectResult = this.projectService.resolveProject(projectRef);
1254
1424
  if (!projectResult.ok) return projectResult;
1255
1425
  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(
1264
- new AppError("NOT_FOUND", `Anchor task not found among active tasks: ${afterId}`)
1265
- );
1426
+ const depService = this.getDependencyService();
1427
+ const blockersResult = depService.listBlockers(taskId);
1428
+ if (!blockersResult.ok) return blockersResult;
1429
+ const constrainingBlockers = blockersResult.value.filter(
1430
+ (b) => !isTerminalStatus(b.status) && b.projectId === projectId
1431
+ );
1432
+ const dependentsResult = depService.listDependents(taskId);
1433
+ if (!dependentsResult.ok) return dependentsResult;
1434
+ const constrainingDependents = dependentsResult.value.filter(
1435
+ (d) => !isTerminalStatus(d.status) && d.projectId === projectId
1436
+ );
1437
+ const attempt = () => {
1438
+ const rankedResult = this.repo.getRankedNonTerminalTasksByLevel(projectId, taskLevel);
1439
+ if (!rankedResult.ok) return rankedResult;
1440
+ const ranked = rankedResult.value.filter((t) => t.id !== taskId);
1441
+ if (top === true) {
1442
+ return ok(this.computeTopRank(ranked, constrainingBlockers));
1266
1443
  }
1267
- const anchorIndex = ranked.indexOf(anchor);
1268
- const next = ranked[anchorIndex + 1];
1269
- newRank = next ? (anchor.rank + next.rank) / 2 : anchor.rank + RANK_GAP;
1270
- } else if (beforeId) {
1271
- const anchor = ranked.find((t) => t.id === beforeId);
1272
- if (!anchor) {
1273
- return err(
1274
- new AppError("NOT_FOUND", `Anchor task not found among active tasks: ${beforeId}`)
1444
+ if (bottom === true) {
1445
+ const minTerminalResult = this.repo.getMinTerminalRankByLevel(projectId, taskLevel);
1446
+ if (!minTerminalResult.ok) return minTerminalResult;
1447
+ return ok(
1448
+ this.computeBottomRank(ranked, minTerminalResult.value, constrainingDependents)
1275
1449
  );
1276
1450
  }
1277
- const anchorIndex = ranked.indexOf(anchor);
1278
- const prev = ranked[anchorIndex - 1];
1279
- newRank = prev ? (prev.rank + anchor.rank) / 2 : anchor.rank - RANK_GAP;
1280
- } else {
1451
+ if (afterId) {
1452
+ const anchorIndex = ranked.findIndex((t) => t.id === afterId);
1453
+ const anchor = ranked[anchorIndex];
1454
+ if (!anchor) {
1455
+ return err(
1456
+ new AppError("NOT_FOUND", `Anchor task not found among active tasks: ${afterId}`)
1457
+ );
1458
+ }
1459
+ const next = ranked[anchorIndex + 1];
1460
+ return ok(next ? midpoint(anchor.rank, next.rank) : anchor.rank + RANK_GAP);
1461
+ }
1462
+ if (beforeId) {
1463
+ const anchorIndex = ranked.findIndex((t) => t.id === beforeId);
1464
+ const anchor = ranked[anchorIndex];
1465
+ if (!anchor) {
1466
+ return err(
1467
+ new AppError("NOT_FOUND", `Anchor task not found among active tasks: ${beforeId}`)
1468
+ );
1469
+ }
1470
+ const prev = ranked[anchorIndex - 1];
1471
+ return ok(prev ? midpoint(prev.rank, anchor.rank) : anchor.rank - RANK_GAP);
1472
+ }
1281
1473
  const pos = position;
1282
1474
  if (pos < 1) {
1283
1475
  return err(new AppError("VALIDATION", "Position must be >= 1"));
1284
1476
  }
1285
1477
  if (pos === 1) {
1286
- const first = ranked[0];
1287
- newRank = first ? first.rank - RANK_GAP : RANK_GAP;
1288
- } else if (pos > ranked.length) {
1289
- const last = ranked[ranked.length - 1];
1290
- newRank = last ? last.rank + RANK_GAP : RANK_GAP;
1291
- } else {
1292
- const above = ranked[pos - 2];
1293
- const below = ranked[pos - 1];
1294
- if (!above || !below) {
1295
- return err(new AppError("DB_ERROR", "Unexpected missing neighbor tasks"));
1296
- }
1297
- newRank = (above.rank + below.rank) / 2;
1478
+ return ok(this.computeTopRank(ranked));
1479
+ }
1480
+ if (pos > ranked.length) {
1481
+ const minTerminalResult = this.repo.getMinTerminalRankByLevel(projectId, taskLevel);
1482
+ if (!minTerminalResult.ok) return minTerminalResult;
1483
+ return ok(this.computeBottomRank(ranked, minTerminalResult.value));
1484
+ }
1485
+ const above = ranked[pos - 2];
1486
+ const below = ranked[pos - 1];
1487
+ if (!above || !below) {
1488
+ return err(new AppError("DB_ERROR", "Unexpected missing neighbor tasks"));
1489
+ }
1490
+ return ok(midpoint(above.rank, below.rank));
1491
+ };
1492
+ let computed = attempt();
1493
+ if (!computed.ok) return computed;
1494
+ if (computed.value === null) {
1495
+ const rb = this.repo.rebalanceByLevel(projectId, taskLevel);
1496
+ if (!rb.ok) return rb;
1497
+ computed = attempt();
1498
+ if (!computed.ok) return computed;
1499
+ if (computed.value === null) {
1500
+ return err(new AppError("DB_ERROR", "Rank computation did not converge after rebalance"));
1298
1501
  }
1299
1502
  }
1300
- const depService = this.getDependencyService();
1301
- const blockersResult = depService.listBlockers(taskId);
1302
- if (blockersResult.ok) {
1303
- for (const blocker of blockersResult.value) {
1304
- if (isTerminalStatus(blocker.status)) continue;
1305
- if (blocker.projectId === projectId && newRank < blocker.rank) {
1306
- return err(
1307
- new AppError(
1308
- "VALIDATION",
1309
- `Cannot rank above blocker "${blocker.id}" (${blocker.name}). Complete or remove the dependency first.`
1310
- )
1311
- );
1312
- }
1503
+ const newRank = computed.value;
1504
+ for (const blocker of constrainingBlockers) {
1505
+ if (newRank < blocker.rank) {
1506
+ return err(
1507
+ new AppError(
1508
+ "VALIDATION",
1509
+ `Cannot rank above blocker "${blocker.id}" (${blocker.name}). Complete or remove the dependency first.`
1510
+ )
1511
+ );
1313
1512
  }
1314
1513
  }
1315
- const dependentsResult = depService.listDependents(taskId);
1316
- if (dependentsResult.ok) {
1317
- for (const dep of dependentsResult.value) {
1318
- if (isTerminalStatus(dep.status)) continue;
1319
- if (dep.projectId === projectId && newRank > dep.rank) {
1320
- return err(
1321
- new AppError(
1322
- "VALIDATION",
1323
- `Cannot rank below dependent "${dep.id}" (${dep.name}). Complete or remove the dependency first.`
1324
- )
1325
- );
1326
- }
1514
+ for (const dep of constrainingDependents) {
1515
+ if (newRank > dep.rank) {
1516
+ return err(
1517
+ new AppError(
1518
+ "VALIDATION",
1519
+ `Cannot rank below dependent "${dep.id}" (${dep.name}). Complete or remove the dependency first.`
1520
+ )
1521
+ );
1327
1522
  }
1328
1523
  }
1329
1524
  return this.repo.rerank(taskId, newRank);
@@ -1334,15 +1529,53 @@ var TaskServiceImpl = class {
1334
1529
  if (!query.trim()) {
1335
1530
  return err(new AppError("VALIDATION", "Search query cannot be empty"));
1336
1531
  }
1337
- let projectId;
1338
- if (projectIdOrName) {
1339
- const projectResult = this.projectService.resolveProject(projectIdOrName);
1340
- if (!projectResult.ok) return projectResult;
1341
- projectId = projectResult.value.id;
1342
- }
1343
- return this.repo.search(query, projectId);
1532
+ const projectResult = this.projectService.resolveProject(projectIdOrName);
1533
+ if (!projectResult.ok) return projectResult;
1534
+ return this.repo.search(query, projectResult.value.id);
1344
1535
  });
1345
1536
  }
1537
+ /**
1538
+ * Rank for placing a task at the top of the active list. Clamps to
1539
+ * "immediately after the highest-ranked blocker" when blockers exist
1540
+ * rather than returning a value that would fail validation.
1541
+ *
1542
+ * Returns `null` to signal FP precision collapse (caller rebalances).
1543
+ */
1544
+ computeTopRank(ranked, constrainingBlockers = []) {
1545
+ if (constrainingBlockers.length > 0) {
1546
+ const highestBlocker = constrainingBlockers.reduce((a, b) => a.rank > b.rank ? a : b);
1547
+ const idx = ranked.findIndex((t) => t.id === highestBlocker.id);
1548
+ if (idx >= 0) {
1549
+ const next = ranked[idx + 1];
1550
+ return next ? midpoint(highestBlocker.rank, next.rank) : highestBlocker.rank + RANK_GAP;
1551
+ }
1552
+ }
1553
+ const first = ranked[0];
1554
+ return first ? first.rank - RANK_GAP : RANK_GAP;
1555
+ }
1556
+ /**
1557
+ * Rank for placing a task at the bottom of the active list.
1558
+ * - Stays above terminal tasks: terminal tasks live at `maxRank + RANK_GAP`
1559
+ * so a naive `last.rank + RANK_GAP` would collide with the most-recently
1560
+ * completed task.
1561
+ * - Clamps above any dependents rather than failing validation.
1562
+ *
1563
+ * `minTerminal` is passed in so this helper stays pure (no DB access).
1564
+ * Returns `null` to signal FP precision collapse.
1565
+ */
1566
+ computeBottomRank(ranked, minTerminal, constrainingDependents = []) {
1567
+ if (constrainingDependents.length > 0) {
1568
+ const lowestDependent = constrainingDependents.reduce((a, b) => a.rank < b.rank ? a : b);
1569
+ const idx = ranked.findIndex((t) => t.id === lowestDependent.id);
1570
+ if (idx >= 0) {
1571
+ const prev = ranked[idx - 1];
1572
+ return prev ? midpoint(prev.rank, lowestDependent.rank) : lowestDependent.rank - RANK_GAP;
1573
+ }
1574
+ }
1575
+ const last = ranked[ranked.length - 1];
1576
+ if (!last) return RANK_GAP;
1577
+ return minTerminal !== null && minTerminal > last.rank ? midpoint(last.rank, minTerminal) : last.rank + RANK_GAP;
1578
+ }
1346
1579
  /**
1347
1580
  * Auto-propagate status to the parent task after a child status change.
1348
1581
  * - If a child moves to in-progress and parent is backlog/todo → parent becomes in-progress.
@@ -1792,11 +2025,11 @@ var PortabilityServiceImpl = class {
1792
2025
  };
1793
2026
 
1794
2027
  // src/cli/container.ts
1795
- function createContainer(db, dbPath) {
2028
+ function createContainer(db, dbPath, detectGitRemote2) {
1796
2029
  const projectRepo = new SqliteProjectRepository(db);
1797
2030
  const taskRepo = new SqliteTaskRepository(db);
1798
2031
  const depRepo = new SqliteDependencyRepository(db);
1799
- const projectService = new ProjectServiceImpl(projectRepo);
2032
+ const projectService = new ProjectServiceImpl(projectRepo, detectGitRemote2);
1800
2033
  const dependencyService = new DependencyServiceImpl(depRepo, taskRepo);
1801
2034
  const taskService = new TaskServiceImpl(taskRepo, projectService, () => dependencyService);
1802
2035
  const portabilityService = new PortabilityServiceImpl(
@@ -1832,16 +2065,19 @@ function handleResult(result) {
1832
2065
  function registerProjectCreate(parent, container) {
1833
2066
  parent.command("create").description("Create a new project").requiredOption("-n, --name <name>", "Project name").option(
1834
2067
  "-k, --key <key>",
1835
- "Project key (2-10 alphanumeric chars, defaults to first 3 chars of name)"
1836
- ).option("-d, --description <description>", "Project description").option("--default", "Set as default project").action((opts) => {
1837
- const result = container.projectService.createProject({
1838
- name: opts.name,
1839
- key: opts.key,
1840
- description: opts.description,
1841
- isDefault: opts.default
1842
- });
1843
- handleResult(result);
1844
- });
2068
+ "Project key (2-7 uppercase alphanumeric chars, defaults to first 3 chars of name)"
2069
+ ).option("-d, --description <description>", "Project description").option("--default", "Set as default project").option("--git-remote <url>", "Git remote URL to associate with the project").action(
2070
+ (opts) => {
2071
+ const result = container.projectService.createProject({
2072
+ name: opts.name,
2073
+ key: opts.key,
2074
+ description: opts.description,
2075
+ isDefault: opts.default,
2076
+ gitRemote: opts.gitRemote
2077
+ });
2078
+ handleResult(result);
2079
+ }
2080
+ );
1845
2081
  }
1846
2082
 
1847
2083
  // src/cli/commands/project/list.ts
@@ -1899,6 +2135,22 @@ function registerProjectSetDefault(parent, container) {
1899
2135
  });
1900
2136
  }
1901
2137
 
2138
+ // src/cli/commands/project/link.ts
2139
+ function registerProjectLink(parent, container) {
2140
+ parent.command("link <idOrKeyOrName>").description("Link a project to a git remote (auto-detects from cwd if --remote omitted)").option("-r, --remote <url>", "Git remote URL").action((idOrKeyOrName, opts) => {
2141
+ const result = container.projectService.linkGitRemote(idOrKeyOrName, opts.remote);
2142
+ handleResult(result);
2143
+ });
2144
+ }
2145
+
2146
+ // src/cli/commands/project/unlink.ts
2147
+ function registerProjectUnlink(parent, container) {
2148
+ parent.command("unlink <idOrKeyOrName>").description("Remove git remote link from a project").action((idOrKeyOrName) => {
2149
+ const result = container.projectService.unlinkGitRemote(idOrKeyOrName);
2150
+ handleResult(result);
2151
+ });
2152
+ }
2153
+
1902
2154
  // src/cli/commands/task/create.ts
1903
2155
  function registerTaskCreate(parent, container) {
1904
2156
  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(
@@ -2003,14 +2255,16 @@ function registerTaskBreakdown(parent, container) {
2003
2255
 
2004
2256
  // src/cli/commands/task/rank.ts
2005
2257
  function registerTaskRank(parent, container) {
2006
- 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(
2258
+ 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(
2007
2259
  (id, opts) => {
2008
2260
  const result = container.taskService.rerankTask(
2009
2261
  {
2010
2262
  taskId: id,
2011
2263
  afterId: opts.after,
2012
2264
  beforeId: opts.before,
2013
- position: opts.position ? parseInt(opts.position, 10) : void 0
2265
+ position: opts.position ? parseInt(opts.position, 10) : void 0,
2266
+ top: opts.top,
2267
+ bottom: opts.bottom
2014
2268
  },
2015
2269
  opts.project
2016
2270
  );
@@ -2139,6 +2393,8 @@ function buildCLI(container) {
2139
2393
  registerProjectUpdate(project, container);
2140
2394
  registerProjectDelete(project, container);
2141
2395
  registerProjectSetDefault(project, container);
2396
+ registerProjectLink(project, container);
2397
+ registerProjectUnlink(project, container);
2142
2398
  const task = program.command("task").description("Manage tasks");
2143
2399
  registerTaskCreate(task, container);
2144
2400
  registerTaskList(task, container);
@@ -2156,7 +2412,7 @@ function buildCLI(container) {
2156
2412
  registerDepList(dep, container);
2157
2413
  registerDepGraph(dep, container);
2158
2414
  program.command("tui").description("Launch interactive terminal UI").option("-p, --project <project>", "Start with specific project").action(async (opts) => {
2159
- const { launchTUI } = await import("./tui-5JJH67YY.js");
2415
+ const { launchTUI } = await import("./tui-4GNIGMCK.js");
2160
2416
  await launchTUI(container, opts.project);
2161
2417
  });
2162
2418
  return program;
@@ -2172,7 +2428,7 @@ async function main() {
2172
2428
  const container = createContainer(db, config.dbPath);
2173
2429
  const args = process.argv.slice(2);
2174
2430
  if (args.length === 0) {
2175
- const { launchTUI } = await import("./tui-5JJH67YY.js");
2431
+ const { launchTUI } = await import("./tui-4GNIGMCK.js");
2176
2432
  await launchTUI(container);
2177
2433
  } else {
2178
2434
  const program = buildCLI(container);