trekoon 0.2.1 → 0.2.4
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/.agents/skills/trekoon/SKILL.md +95 -33
- package/README.md +74 -13
- package/package.json +1 -1
- package/src/commands/help.ts +47 -13
- package/src/commands/init.ts +104 -6
- package/src/commands/quickstart.ts +76 -30
- package/src/commands/session.ts +223 -0
- package/src/commands/skills.ts +100 -63
- package/src/commands/sync.ts +62 -21
- package/src/commands/task-readiness.ts +147 -0
- package/src/commands/task.ts +81 -143
- package/src/commands/wipe.ts +15 -5
- package/src/runtime/cli-shell.ts +83 -5
- package/src/storage/database.ts +86 -0
- package/src/storage/migrations.ts +48 -0
- package/src/storage/path.ts +70 -21
- package/src/storage/schema.ts +9 -2
- package/src/storage/worktree-recovery.ts +376 -0
- package/src/sync/branch-db.ts +87 -35
- package/src/sync/git-context.ts +7 -2
- package/src/sync/service.ts +89 -95
- package/src/sync/types.ts +2 -0
package/src/commands/sync.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { findUnknownOption, parseArgs, readMissingOptionValue, readOption, suggestOptions } from "./arg-parser";
|
|
2
2
|
import { safeErrorMessage, sqliteBusyFailure } from "./error-utils";
|
|
3
3
|
|
|
4
|
+
import { DomainError } from "../domain/types";
|
|
4
5
|
import { failResult, okResult } from "../io/output";
|
|
5
6
|
import { type CliContext, type CliResult } from "../runtime/command-types";
|
|
6
|
-
import {
|
|
7
|
+
import { resolveStorageResolutionDiagnostics } from "../storage/database";
|
|
8
|
+
import { assertValidSourceRef } from "../sync/branch-db";
|
|
7
9
|
import { getSyncConflict, listSyncConflicts, syncPull, syncResolve, syncStatus } from "../sync/service";
|
|
8
10
|
import { type SyncResolution } from "../sync/types";
|
|
9
11
|
|
|
@@ -110,6 +112,15 @@ function formatConflictList(
|
|
|
110
112
|
.join("\n");
|
|
111
113
|
}
|
|
112
114
|
|
|
115
|
+
function formatDomainErrorHuman(message: string, details: Record<string, unknown> | undefined): string {
|
|
116
|
+
const operatorAction = typeof details?.operatorAction === "string" ? details.operatorAction : null;
|
|
117
|
+
return operatorAction ? `${message}\n${operatorAction}` : message;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isStorageBootstrapError(code: string): boolean {
|
|
121
|
+
return code === "tracked_ignored_mismatch" || code === "ambiguous_legacy_state" || code === "legacy_import_failed";
|
|
122
|
+
}
|
|
123
|
+
|
|
113
124
|
export async function runSync(context: CliContext): Promise<CliResult> {
|
|
114
125
|
const parsed = parseArgs(context.args);
|
|
115
126
|
const subcommand: string | undefined = parsed.positional[0];
|
|
@@ -133,11 +144,17 @@ export async function runSync(context: CliContext): Promise<CliResult> {
|
|
|
133
144
|
}
|
|
134
145
|
|
|
135
146
|
const sourceBranch: string = readOption(parsed.options, "from") ?? "main";
|
|
147
|
+
assertValidSourceRef(context.cwd, sourceBranch);
|
|
136
148
|
const summary = syncStatus(context.cwd, sourceBranch);
|
|
137
149
|
|
|
150
|
+
const humanLines = [statusMessage(summary.sourceBranch, summary.ahead, summary.behind, summary.pendingConflicts)];
|
|
151
|
+
if (summary.sameBranch) {
|
|
152
|
+
humanLines.push(`Same-branch mode: already on '${summary.sourceBranch}', no sync needed`);
|
|
153
|
+
}
|
|
154
|
+
|
|
138
155
|
return okResult({
|
|
139
156
|
command: "sync.status",
|
|
140
|
-
human:
|
|
157
|
+
human: humanLines.join("\n"),
|
|
141
158
|
data: summary,
|
|
142
159
|
});
|
|
143
160
|
}
|
|
@@ -158,20 +175,26 @@ export async function runSync(context: CliContext): Promise<CliResult> {
|
|
|
158
175
|
return usage("sync pull requires --from <branch>.", "sync.pull");
|
|
159
176
|
}
|
|
160
177
|
|
|
178
|
+
assertValidSourceRef(context.cwd, sourceBranch);
|
|
161
179
|
const summary = syncPull(context.cwd, sourceBranch);
|
|
162
180
|
|
|
181
|
+
const humanLines = [
|
|
182
|
+
`Pulled from '${summary.sourceBranch}'`,
|
|
183
|
+
`Scanned events: ${summary.scannedEvents}`,
|
|
184
|
+
`Applied events: ${summary.appliedEvents}`,
|
|
185
|
+
`Created conflicts: ${summary.createdConflicts}`,
|
|
186
|
+
`Malformed payloads: ${summary.diagnostics.malformedPayloadEvents}`,
|
|
187
|
+
`Quarantined events: ${summary.diagnostics.quarantinedEvents}`,
|
|
188
|
+
`Conflict events: ${summary.diagnostics.conflictEvents}`,
|
|
189
|
+
...summary.diagnostics.errorHints,
|
|
190
|
+
];
|
|
191
|
+
if (summary.sameBranch) {
|
|
192
|
+
humanLines.push(`Same-branch mode: already on '${summary.sourceBranch}', no sync needed`);
|
|
193
|
+
}
|
|
194
|
+
|
|
163
195
|
return okResult({
|
|
164
196
|
command: "sync.pull",
|
|
165
|
-
human:
|
|
166
|
-
`Pulled from '${summary.sourceBranch}'`,
|
|
167
|
-
`Scanned events: ${summary.scannedEvents}`,
|
|
168
|
-
`Applied events: ${summary.appliedEvents}`,
|
|
169
|
-
`Created conflicts: ${summary.createdConflicts}`,
|
|
170
|
-
`Malformed payloads: ${summary.diagnostics.malformedPayloadEvents}`,
|
|
171
|
-
`Quarantined events: ${summary.diagnostics.quarantinedEvents}`,
|
|
172
|
-
`Conflict events: ${summary.diagnostics.conflictEvents}`,
|
|
173
|
-
...summary.diagnostics.errorHints,
|
|
174
|
-
].join("\n"),
|
|
197
|
+
human: humanLines.join("\n"),
|
|
175
198
|
data: summary,
|
|
176
199
|
});
|
|
177
200
|
}
|
|
@@ -275,25 +298,43 @@ export async function runSync(context: CliContext): Promise<CliResult> {
|
|
|
275
298
|
|
|
276
299
|
return usage(`Unknown sync subcommand '${subcommand}'.`);
|
|
277
300
|
} catch (error) {
|
|
278
|
-
|
|
301
|
+
const busyFailure = sqliteBusyFailure(resolvedCommand, error);
|
|
302
|
+
if (busyFailure !== null) {
|
|
303
|
+
return busyFailure;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (error instanceof DomainError) {
|
|
307
|
+
if (isStorageBootstrapError(error.code)) {
|
|
308
|
+
const storageDiagnostics = resolveStorageResolutionDiagnostics(context.cwd);
|
|
309
|
+
|
|
310
|
+
return failResult({
|
|
311
|
+
command: resolvedCommand,
|
|
312
|
+
human: formatDomainErrorHuman(error.message, error.details),
|
|
313
|
+
data: {
|
|
314
|
+
reason: "storage_bootstrap_blocked",
|
|
315
|
+
...storageDiagnostics,
|
|
316
|
+
},
|
|
317
|
+
error: {
|
|
318
|
+
code: error.code,
|
|
319
|
+
message: error.message,
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
279
324
|
return failResult({
|
|
280
325
|
command: resolvedCommand,
|
|
281
|
-
human: error.message,
|
|
326
|
+
human: formatDomainErrorHuman(error.message, error.details),
|
|
282
327
|
data: {
|
|
283
|
-
|
|
328
|
+
...(error.details ?? {}),
|
|
329
|
+
reason: error.code,
|
|
284
330
|
},
|
|
285
331
|
error: {
|
|
286
|
-
code:
|
|
332
|
+
code: error.code,
|
|
287
333
|
message: error.message,
|
|
288
334
|
},
|
|
289
335
|
});
|
|
290
336
|
}
|
|
291
337
|
|
|
292
|
-
const busyFailure = sqliteBusyFailure(resolvedCommand, error);
|
|
293
|
-
if (busyFailure !== null) {
|
|
294
|
-
return busyFailure;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
338
|
const message = safeErrorMessage(error, "Unknown sync error.");
|
|
298
339
|
|
|
299
340
|
return failResult({
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { TrackerDomain } from "../domain/tracker-domain";
|
|
2
|
+
import { type TaskRecord } from "../domain/types";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_OPEN_TASK_STATUSES = ["in_progress", "in-progress", "todo"] as const;
|
|
5
|
+
export const READY_REASON_READY = "all_dependencies_done";
|
|
6
|
+
export const READY_REASON_BLOCKED = "blocked_by_dependencies";
|
|
7
|
+
|
|
8
|
+
export interface DependencyBlocker {
|
|
9
|
+
readonly id: string;
|
|
10
|
+
readonly kind: "task" | "subtask";
|
|
11
|
+
readonly status: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface TaskReadyCandidate {
|
|
15
|
+
readonly task: TaskRecord;
|
|
16
|
+
readonly readiness: {
|
|
17
|
+
readonly isReady: boolean;
|
|
18
|
+
readonly reason: typeof READY_REASON_READY | typeof READY_REASON_BLOCKED;
|
|
19
|
+
};
|
|
20
|
+
readonly blockerSummary: {
|
|
21
|
+
readonly totalDependencies: number;
|
|
22
|
+
readonly blockedByCount: number;
|
|
23
|
+
readonly blockedBy: ReadonlyArray<DependencyBlocker>;
|
|
24
|
+
};
|
|
25
|
+
readonly ranking: {
|
|
26
|
+
readonly statusPriority: number;
|
|
27
|
+
readonly blockerCount: number;
|
|
28
|
+
readonly createdAt: number;
|
|
29
|
+
readonly id: string;
|
|
30
|
+
readonly rank: number;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type ReadyReason = typeof READY_REASON_READY | typeof READY_REASON_BLOCKED;
|
|
35
|
+
|
|
36
|
+
export interface TaskReadinessSummary {
|
|
37
|
+
readonly totalOpenTasks: number;
|
|
38
|
+
readonly readyCount: number;
|
|
39
|
+
readonly returnedCount: number;
|
|
40
|
+
readonly appliedLimit: number | null;
|
|
41
|
+
readonly blockedCount: number;
|
|
42
|
+
readonly unresolvedDependencyCount: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface TaskReadinessResult {
|
|
46
|
+
readonly candidates: readonly TaskReadyCandidate[];
|
|
47
|
+
readonly blocked: readonly TaskReadyCandidate[];
|
|
48
|
+
readonly summary: TaskReadinessSummary;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function taskStatusPriority(status: string): number {
|
|
52
|
+
if (status === "in_progress" || status === "in-progress") {
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (status === "todo") {
|
|
57
|
+
return 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return 2;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function buildTaskReadiness(domain: TrackerDomain, epicId: string | undefined): TaskReadinessResult {
|
|
64
|
+
const openStatuses = new Set<string>(DEFAULT_OPEN_TASK_STATUSES);
|
|
65
|
+
const openTasks = domain.listTasks(epicId).filter((task) => openStatuses.has(task.status));
|
|
66
|
+
const assessed = openTasks
|
|
67
|
+
.map((task) => {
|
|
68
|
+
const blockers: DependencyBlocker[] = [];
|
|
69
|
+
const dependencies = domain.listDependencies(task.id);
|
|
70
|
+
for (const dependency of dependencies) {
|
|
71
|
+
const dependencyStatus =
|
|
72
|
+
dependency.dependsOnKind === "task"
|
|
73
|
+
? domain.getTaskOrThrow(dependency.dependsOnId).status
|
|
74
|
+
: domain.getSubtaskOrThrow(dependency.dependsOnId).status;
|
|
75
|
+
|
|
76
|
+
if (dependencyStatus !== "done") {
|
|
77
|
+
blockers.push({
|
|
78
|
+
id: dependency.dependsOnId,
|
|
79
|
+
kind: dependency.dependsOnKind,
|
|
80
|
+
status: dependencyStatus,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const blockerCount = blockers.length;
|
|
86
|
+
const readinessReason: ReadyReason = blockerCount === 0 ? READY_REASON_READY : READY_REASON_BLOCKED;
|
|
87
|
+
return {
|
|
88
|
+
task,
|
|
89
|
+
readiness: {
|
|
90
|
+
isReady: blockerCount === 0,
|
|
91
|
+
reason: readinessReason,
|
|
92
|
+
},
|
|
93
|
+
blockerSummary: {
|
|
94
|
+
totalDependencies: dependencies.length,
|
|
95
|
+
blockedByCount: blockerCount,
|
|
96
|
+
blockedBy: blockers,
|
|
97
|
+
},
|
|
98
|
+
ranking: {
|
|
99
|
+
statusPriority: taskStatusPriority(task.status),
|
|
100
|
+
blockerCount,
|
|
101
|
+
createdAt: task.createdAt,
|
|
102
|
+
id: task.id,
|
|
103
|
+
rank: 0,
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
})
|
|
107
|
+
.sort((left, right) => {
|
|
108
|
+
const byStatus = left.ranking.statusPriority - right.ranking.statusPriority;
|
|
109
|
+
if (byStatus !== 0) {
|
|
110
|
+
return byStatus;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const byBlockers = left.ranking.blockerCount - right.ranking.blockerCount;
|
|
114
|
+
if (byBlockers !== 0) {
|
|
115
|
+
return byBlockers;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const byCreatedAt = left.ranking.createdAt - right.ranking.createdAt;
|
|
119
|
+
if (byCreatedAt !== 0) {
|
|
120
|
+
return byCreatedAt;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return left.ranking.id.localeCompare(right.ranking.id);
|
|
124
|
+
})
|
|
125
|
+
.map((item, index) => ({
|
|
126
|
+
...item,
|
|
127
|
+
ranking: {
|
|
128
|
+
...item.ranking,
|
|
129
|
+
rank: index + 1,
|
|
130
|
+
},
|
|
131
|
+
}));
|
|
132
|
+
|
|
133
|
+
const candidates = assessed.filter((item) => item.readiness.isReady);
|
|
134
|
+
const blocked = assessed.filter((item) => !item.readiness.isReady);
|
|
135
|
+
return {
|
|
136
|
+
candidates,
|
|
137
|
+
blocked,
|
|
138
|
+
summary: {
|
|
139
|
+
totalOpenTasks: assessed.length,
|
|
140
|
+
readyCount: candidates.length,
|
|
141
|
+
returnedCount: candidates.length,
|
|
142
|
+
appliedLimit: null,
|
|
143
|
+
blockedCount: blocked.length,
|
|
144
|
+
unresolvedDependencyCount: blocked.reduce((total, item) => total + item.blockerSummary.blockedByCount, 0),
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
package/src/commands/task.ts
CHANGED
|
@@ -17,6 +17,18 @@ import {
|
|
|
17
17
|
suggestOptions,
|
|
18
18
|
} from "./arg-parser";
|
|
19
19
|
import { unexpectedFailureResult } from "./error-utils";
|
|
20
|
+
import {
|
|
21
|
+
buildTaskReadiness,
|
|
22
|
+
DEFAULT_OPEN_TASK_STATUSES,
|
|
23
|
+
type DependencyBlocker,
|
|
24
|
+
READY_REASON_BLOCKED,
|
|
25
|
+
READY_REASON_READY,
|
|
26
|
+
type ReadyReason,
|
|
27
|
+
taskStatusPriority,
|
|
28
|
+
type TaskReadinessResult,
|
|
29
|
+
type TaskReadinessSummary,
|
|
30
|
+
type TaskReadyCandidate,
|
|
31
|
+
} from "./task-readiness";
|
|
20
32
|
|
|
21
33
|
import { MutationService } from "../domain/mutation-service";
|
|
22
34
|
import { TrackerDomain } from "../domain/tracker-domain";
|
|
@@ -33,56 +45,10 @@ function formatTask(task: TaskRecord): string {
|
|
|
33
45
|
const VIEW_MODES = ["table", "compact", "tree", "detail"] as const;
|
|
34
46
|
const LIST_VIEW_MODES = ["table", "compact"] as const;
|
|
35
47
|
const DEFAULT_TASK_LIST_LIMIT = 10;
|
|
36
|
-
const DEFAULT_OPEN_TASK_STATUSES = ["in_progress", "in-progress", "todo"] as const;
|
|
37
|
-
const READY_REASON_READY = "all_dependencies_done";
|
|
38
|
-
const READY_REASON_BLOCKED = "blocked_by_dependencies";
|
|
39
48
|
const SEARCH_OPTIONS = ["fields", "preview"] as const;
|
|
40
49
|
const REPLACE_OPTIONS = ["search", "replace", "fields", "preview", "apply"] as const;
|
|
41
50
|
const CREATE_MANY_OPTIONS = ["epic", "e", "task"] as const;
|
|
42
51
|
|
|
43
|
-
interface DependencyBlocker {
|
|
44
|
-
readonly id: string;
|
|
45
|
-
readonly kind: "task" | "subtask";
|
|
46
|
-
readonly status: string;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
interface TaskReadyCandidate {
|
|
50
|
-
readonly task: TaskRecord;
|
|
51
|
-
readonly readiness: {
|
|
52
|
-
readonly isReady: boolean;
|
|
53
|
-
readonly reason: typeof READY_REASON_READY | typeof READY_REASON_BLOCKED;
|
|
54
|
-
};
|
|
55
|
-
readonly blockerSummary: {
|
|
56
|
-
readonly totalDependencies: number;
|
|
57
|
-
readonly blockedByCount: number;
|
|
58
|
-
readonly blockedBy: ReadonlyArray<DependencyBlocker>;
|
|
59
|
-
};
|
|
60
|
-
readonly ranking: {
|
|
61
|
-
readonly statusPriority: number;
|
|
62
|
-
readonly blockerCount: number;
|
|
63
|
-
readonly createdAt: number;
|
|
64
|
-
readonly id: string;
|
|
65
|
-
readonly rank: number;
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
type ReadyReason = typeof READY_REASON_READY | typeof READY_REASON_BLOCKED;
|
|
70
|
-
|
|
71
|
-
interface TaskReadinessSummary {
|
|
72
|
-
readonly totalOpenTasks: number;
|
|
73
|
-
readonly readyCount: number;
|
|
74
|
-
readonly returnedCount: number;
|
|
75
|
-
readonly appliedLimit: number | null;
|
|
76
|
-
readonly blockedCount: number;
|
|
77
|
-
readonly unresolvedDependencyCount: number;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
interface TaskReadinessResult {
|
|
81
|
-
readonly candidates: readonly TaskReadyCandidate[];
|
|
82
|
-
readonly blocked: readonly TaskReadyCandidate[];
|
|
83
|
-
readonly summary: TaskReadinessSummary;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
52
|
function parseIdsOption(rawIds: string | undefined): string[] {
|
|
87
53
|
if (rawIds === undefined) {
|
|
88
54
|
return [];
|
|
@@ -154,102 +120,6 @@ function parseStatusCsv(rawStatuses: string | undefined): string[] | undefined {
|
|
|
154
120
|
.filter((value) => value.length > 0);
|
|
155
121
|
}
|
|
156
122
|
|
|
157
|
-
function taskStatusPriority(status: string): number {
|
|
158
|
-
if (status === "in_progress" || status === "in-progress") {
|
|
159
|
-
return 0;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
if (status === "todo") {
|
|
163
|
-
return 1;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
return 2;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function buildTaskReadiness(domain: TrackerDomain, epicId: string | undefined): TaskReadinessResult {
|
|
170
|
-
const openStatuses = new Set<string>(DEFAULT_OPEN_TASK_STATUSES);
|
|
171
|
-
const openTasks = domain.listTasks(epicId).filter((task) => openStatuses.has(task.status));
|
|
172
|
-
const assessed = openTasks
|
|
173
|
-
.map((task) => {
|
|
174
|
-
const blockers: DependencyBlocker[] = [];
|
|
175
|
-
const dependencies = domain.listDependencies(task.id);
|
|
176
|
-
for (const dependency of dependencies) {
|
|
177
|
-
const dependencyStatus =
|
|
178
|
-
dependency.dependsOnKind === "task"
|
|
179
|
-
? domain.getTaskOrThrow(dependency.dependsOnId).status
|
|
180
|
-
: domain.getSubtaskOrThrow(dependency.dependsOnId).status;
|
|
181
|
-
|
|
182
|
-
if (dependencyStatus !== "done") {
|
|
183
|
-
blockers.push({
|
|
184
|
-
id: dependency.dependsOnId,
|
|
185
|
-
kind: dependency.dependsOnKind,
|
|
186
|
-
status: dependencyStatus,
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
const blockerCount = blockers.length;
|
|
192
|
-
const readinessReason: ReadyReason = blockerCount === 0 ? READY_REASON_READY : READY_REASON_BLOCKED;
|
|
193
|
-
return {
|
|
194
|
-
task,
|
|
195
|
-
readiness: {
|
|
196
|
-
isReady: blockerCount === 0,
|
|
197
|
-
reason: readinessReason,
|
|
198
|
-
},
|
|
199
|
-
blockerSummary: {
|
|
200
|
-
totalDependencies: dependencies.length,
|
|
201
|
-
blockedByCount: blockerCount,
|
|
202
|
-
blockedBy: blockers,
|
|
203
|
-
},
|
|
204
|
-
ranking: {
|
|
205
|
-
statusPriority: taskStatusPriority(task.status),
|
|
206
|
-
blockerCount,
|
|
207
|
-
createdAt: task.createdAt,
|
|
208
|
-
id: task.id,
|
|
209
|
-
},
|
|
210
|
-
};
|
|
211
|
-
})
|
|
212
|
-
.sort((left, right) => {
|
|
213
|
-
const byStatus = left.ranking.statusPriority - right.ranking.statusPriority;
|
|
214
|
-
if (byStatus !== 0) {
|
|
215
|
-
return byStatus;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
const byBlockers = left.ranking.blockerCount - right.ranking.blockerCount;
|
|
219
|
-
if (byBlockers !== 0) {
|
|
220
|
-
return byBlockers;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
const byCreatedAt = left.ranking.createdAt - right.ranking.createdAt;
|
|
224
|
-
if (byCreatedAt !== 0) {
|
|
225
|
-
return byCreatedAt;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
return left.ranking.id.localeCompare(right.ranking.id);
|
|
229
|
-
})
|
|
230
|
-
.map((item, index) => ({
|
|
231
|
-
...item,
|
|
232
|
-
ranking: {
|
|
233
|
-
...item.ranking,
|
|
234
|
-
rank: index + 1,
|
|
235
|
-
},
|
|
236
|
-
}));
|
|
237
|
-
|
|
238
|
-
const candidates = assessed.filter((item) => item.readiness.isReady);
|
|
239
|
-
const blocked = assessed.filter((item) => !item.readiness.isReady);
|
|
240
|
-
return {
|
|
241
|
-
candidates,
|
|
242
|
-
blocked,
|
|
243
|
-
summary: {
|
|
244
|
-
totalOpenTasks: assessed.length,
|
|
245
|
-
readyCount: candidates.length,
|
|
246
|
-
returnedCount: candidates.length,
|
|
247
|
-
appliedLimit: null,
|
|
248
|
-
blockedCount: blocked.length,
|
|
249
|
-
unresolvedDependencyCount: blocked.reduce((total, item) => total + item.blockerSummary.blockedByCount, 0),
|
|
250
|
-
},
|
|
251
|
-
};
|
|
252
|
-
}
|
|
253
123
|
|
|
254
124
|
function formatTaskReadyCandidateLine(candidate: TaskReadyCandidate): string {
|
|
255
125
|
return `${candidate.ranking.rank}. ${formatTask(candidate.task)} | reason=${candidate.readiness.reason} | blockers=${candidate.blockerSummary.blockedByCount}/${candidate.blockerSummary.totalDependencies}`;
|
|
@@ -1242,6 +1112,74 @@ export async function runTask(context: CliContext): Promise<CliResult> {
|
|
|
1242
1112
|
data: { task },
|
|
1243
1113
|
});
|
|
1244
1114
|
}
|
|
1115
|
+
case "done": {
|
|
1116
|
+
const taskId: string = parsed.positional[1] ?? "";
|
|
1117
|
+
if (taskId.length === 0) {
|
|
1118
|
+
return failResult({
|
|
1119
|
+
command: "task.done",
|
|
1120
|
+
human: "Provide a task id. Usage: trekoon task done <id>",
|
|
1121
|
+
data: { code: "invalid_input" },
|
|
1122
|
+
error: {
|
|
1123
|
+
code: "invalid_input",
|
|
1124
|
+
message: "Missing task id",
|
|
1125
|
+
},
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
const existingTask = domain.getTask(taskId);
|
|
1130
|
+
if (!existingTask) {
|
|
1131
|
+
return failResult({
|
|
1132
|
+
command: "task.done",
|
|
1133
|
+
human: `Task not found: ${taskId}`,
|
|
1134
|
+
data: { code: "not_found", id: taskId },
|
|
1135
|
+
error: {
|
|
1136
|
+
code: "not_found",
|
|
1137
|
+
message: `Task not found: ${taskId}`,
|
|
1138
|
+
},
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
if (existingTask.status === "done") {
|
|
1143
|
+
return failResult({
|
|
1144
|
+
command: "task.done",
|
|
1145
|
+
human: "Task is already done",
|
|
1146
|
+
data: { code: "already_done", id: taskId },
|
|
1147
|
+
error: {
|
|
1148
|
+
code: "already_done",
|
|
1149
|
+
message: "Task is already done",
|
|
1150
|
+
},
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
const completed = mutations.updateTask(taskId, { status: "done" });
|
|
1155
|
+
const readiness = buildTaskReadiness(domain, completed.epicId);
|
|
1156
|
+
const nextCandidate = readiness.candidates[0] ?? null;
|
|
1157
|
+
|
|
1158
|
+
const nextTree = nextCandidate !== null ? domain.buildTaskTreeDetailed(nextCandidate.task.id) : null;
|
|
1159
|
+
const nextDeps = nextCandidate?.blockerSummary.blockedBy ?? [];
|
|
1160
|
+
|
|
1161
|
+
const readinessStats = {
|
|
1162
|
+
readyCount: readiness.summary.readyCount,
|
|
1163
|
+
blockedCount: readiness.summary.blockedCount,
|
|
1164
|
+
};
|
|
1165
|
+
|
|
1166
|
+
let human = `Task ${completed.title} marked done.`;
|
|
1167
|
+
if (nextTree !== null && nextCandidate !== null) {
|
|
1168
|
+
human += `\nNext: ${formatTask(nextCandidate.task)}`;
|
|
1169
|
+
}
|
|
1170
|
+
human += `\nReadiness: ready=${readinessStats.readyCount}, blocked=${readinessStats.blockedCount}.`;
|
|
1171
|
+
|
|
1172
|
+
return okResult({
|
|
1173
|
+
command: "task.done",
|
|
1174
|
+
human,
|
|
1175
|
+
data: {
|
|
1176
|
+
completed,
|
|
1177
|
+
next: nextTree,
|
|
1178
|
+
nextDeps,
|
|
1179
|
+
readiness: readinessStats,
|
|
1180
|
+
},
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1245
1183
|
case "delete": {
|
|
1246
1184
|
const taskId: string = parsed.positional[1] ?? "";
|
|
1247
1185
|
mutations.deleteTask(taskId);
|
|
@@ -1255,7 +1193,7 @@ export async function runTask(context: CliContext): Promise<CliResult> {
|
|
|
1255
1193
|
default:
|
|
1256
1194
|
return failResult({
|
|
1257
1195
|
command: "task",
|
|
1258
|
-
human: "Usage: trekoon task <create|create-many|list|show|ready|next|search|replace|update|delete>",
|
|
1196
|
+
human: "Usage: trekoon task <create|create-many|list|show|ready|next|done|search|replace|update|delete>",
|
|
1259
1197
|
data: {
|
|
1260
1198
|
args: context.args,
|
|
1261
1199
|
},
|
package/src/commands/wipe.ts
CHANGED
|
@@ -6,22 +6,29 @@ import { resolveStoragePaths } from "../storage/path";
|
|
|
6
6
|
|
|
7
7
|
export async function runWipe(context: CliContext): Promise<CliResult> {
|
|
8
8
|
const confirmed: boolean = context.args.includes("--yes");
|
|
9
|
+
const paths = resolveStoragePaths(context.cwd);
|
|
10
|
+
const repoScoped: boolean = paths.storageMode === "git_common_dir";
|
|
11
|
+
const sharedAcrossWorktrees: boolean = repoScoped && paths.sharedStorageRoot !== paths.worktreeRoot;
|
|
12
|
+
const scopeLabel: string = repoScoped ? "repo-wide Trekoon state" : "local Trekoon state";
|
|
9
13
|
|
|
10
14
|
if (!confirmed) {
|
|
11
15
|
return failResult({
|
|
12
16
|
command: "wipe",
|
|
13
|
-
human:
|
|
17
|
+
human: `Refusing to wipe ${scopeLabel} without --yes. This deletes ${paths.storageDir}${repoScoped ? " for the entire repository, including any linked worktrees that share this storage" : " for this working directory"}.`,
|
|
14
18
|
data: {
|
|
15
19
|
confirmed,
|
|
20
|
+
storageDir: paths.storageDir,
|
|
21
|
+
worktreeRoot: paths.worktreeRoot,
|
|
22
|
+
sharedStorageRoot: paths.sharedStorageRoot,
|
|
23
|
+
repoScoped,
|
|
16
24
|
},
|
|
17
25
|
error: {
|
|
18
26
|
code: "confirmation_required",
|
|
19
|
-
message:
|
|
27
|
+
message: `Wipe requires --yes to remove ${scopeLabel}`,
|
|
20
28
|
},
|
|
21
29
|
});
|
|
22
30
|
}
|
|
23
31
|
|
|
24
|
-
const paths = resolveStoragePaths(context.cwd);
|
|
25
32
|
const existed: boolean = existsSync(paths.storageDir);
|
|
26
33
|
|
|
27
34
|
rmSync(paths.storageDir, { recursive: true, force: true });
|
|
@@ -29,10 +36,13 @@ export async function runWipe(context: CliContext): Promise<CliResult> {
|
|
|
29
36
|
return okResult({
|
|
30
37
|
command: "wipe",
|
|
31
38
|
human: existed
|
|
32
|
-
? `Removed
|
|
33
|
-
: `No
|
|
39
|
+
? `Removed ${scopeLabel} at ${paths.storageDir}${repoScoped ? ` for repository ${paths.sharedStorageRoot}` : ""}${sharedAcrossWorktrees ? ", which is shared with linked worktrees" : ""}.`
|
|
40
|
+
: `No ${scopeLabel} found at ${paths.storageDir}${repoScoped ? ` for repository ${paths.sharedStorageRoot}` : ""}${sharedAcrossWorktrees ? ", which is shared with linked worktrees" : ""}.`,
|
|
34
41
|
data: {
|
|
35
42
|
storageDir: paths.storageDir,
|
|
43
|
+
worktreeRoot: paths.worktreeRoot,
|
|
44
|
+
sharedStorageRoot: paths.sharedStorageRoot,
|
|
45
|
+
repoScoped,
|
|
36
46
|
wiped: existed,
|
|
37
47
|
},
|
|
38
48
|
});
|