@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/{chunk-FUNYPBWJ.js → chunk-74Q55TOV.js} +46 -2
- package/dist/chunk-74Q55TOV.js.map +1 -0
- package/dist/index.js +376 -120
- package/dist/index.js.map +1 -1
- package/dist/migrations/005_project_git_remote.sql +5 -0
- package/dist/{tui-5JJH67YY.js → tui-4GNIGMCK.js} +342 -132
- package/dist/tui-4GNIGMCK.js.map +1 -0
- package/package.json +1 -1
- package/dist/chunk-FUNYPBWJ.js.map +0 -1
- package/dist/tui-5JJH67YY.js.map +0 -1
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
|
-
|
|
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
|
|
336
|
-
if (!
|
|
337
|
-
const
|
|
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
|
-
|
|
1078
|
-
if (
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
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 --
|
|
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
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
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
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
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
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
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
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
const
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
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
|
|
1301
|
-
const
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
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
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
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
|
-
|
|
1338
|
-
if (
|
|
1339
|
-
|
|
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-
|
|
1836
|
-
).option("-d, --description <description>", "Project description").option("--default", "Set as default project").
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
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-
|
|
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-
|
|
2431
|
+
const { launchTUI } = await import("./tui-4GNIGMCK.js");
|
|
2176
2432
|
await launchTUI(container);
|
|
2177
2433
|
} else {
|
|
2178
2434
|
const program = buildCLI(container);
|