trekoon 0.2.0 → 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 +232 -297
- package/README.md +288 -16
- package/package.json +1 -1
- package/src/commands/arg-parser.ts +116 -0
- package/src/commands/dep.ts +197 -25
- package/src/commands/epic.ts +490 -28
- package/src/commands/error-utils.ts +111 -0
- package/src/commands/events.ts +23 -3
- package/src/commands/help.ts +83 -17
- package/src/commands/init.ts +115 -9
- package/src/commands/migrate.ts +11 -4
- package/src/commands/quickstart.ts +76 -30
- package/src/commands/session.ts +223 -0
- package/src/commands/skills.ts +100 -63
- package/src/commands/subtask.ts +224 -26
- package/src/commands/sync.ts +64 -17
- package/src/commands/task-readiness.ts +147 -0
- package/src/commands/task.ts +277 -168
- package/src/commands/wipe.ts +15 -5
- package/src/domain/mutation-service.ts +152 -0
- package/src/domain/tracker-domain.ts +503 -0
- package/src/domain/types.ts +80 -0
- 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 +131 -95
- package/src/sync/types.ts +2 -0
package/src/commands/subtask.ts
CHANGED
|
@@ -2,24 +2,29 @@ import {
|
|
|
2
2
|
SEARCH_REPLACE_FIELDS,
|
|
3
3
|
findUnknownOption,
|
|
4
4
|
hasFlag,
|
|
5
|
+
isValidCompactTempKey,
|
|
5
6
|
parseArgs,
|
|
7
|
+
parseCompactFields,
|
|
6
8
|
parseCsvEnumOption,
|
|
7
9
|
parseStrictNonNegativeInt,
|
|
8
10
|
parseStrictPositiveInt,
|
|
9
11
|
readEnumOption,
|
|
10
12
|
readMissingOptionValue,
|
|
11
13
|
readOption,
|
|
14
|
+
readOptions,
|
|
15
|
+
readUnexpectedPositionals,
|
|
12
16
|
resolvePreviewApplyMode,
|
|
13
17
|
suggestOptions,
|
|
14
18
|
} from "./arg-parser";
|
|
19
|
+
import { unexpectedFailureResult } from "./error-utils";
|
|
15
20
|
|
|
16
21
|
import { MutationService } from "../domain/mutation-service";
|
|
17
22
|
import { TrackerDomain } from "../domain/tracker-domain";
|
|
18
|
-
import {
|
|
23
|
+
import { type CompactBatchResultContract, type CompactSubtaskSpec, type SearchEntityMatch, type SubtaskRecord } from "../domain/types";
|
|
19
24
|
import { formatHumanTable } from "../io/human-table";
|
|
20
25
|
import { failResult, okResult } from "../io/output";
|
|
21
26
|
import { type CliContext, type CliResult } from "../runtime/command-types";
|
|
22
|
-
import { openTrekoonDatabase } from "../storage/database";
|
|
27
|
+
import { openTrekoonDatabase, type TrekoonDatabase } from "../storage/database";
|
|
23
28
|
|
|
24
29
|
function formatSubtask(subtask: SubtaskRecord): string {
|
|
25
30
|
return `${subtask.id} | task=${subtask.taskId} | ${subtask.title} | ${subtask.status}`;
|
|
@@ -30,6 +35,7 @@ const DEFAULT_SUBTASK_LIST_LIMIT = 10;
|
|
|
30
35
|
const DEFAULT_OPEN_SUBTASK_STATUSES = ["in_progress", "in-progress", "todo"] as const;
|
|
31
36
|
const SEARCH_OPTIONS = ["fields", "preview"] as const;
|
|
32
37
|
const REPLACE_OPTIONS = ["search", "replace", "fields", "preview", "apply"] as const;
|
|
38
|
+
const CREATE_MANY_OPTIONS = ["task", "t", "subtask"] as const;
|
|
33
39
|
|
|
34
40
|
function parseIdsOption(rawIds: string | undefined): string[] {
|
|
35
41
|
if (rawIds === undefined) {
|
|
@@ -171,29 +177,9 @@ function formatSubtaskListTable(subtasks: readonly SubtaskRecord[]): string {
|
|
|
171
177
|
}
|
|
172
178
|
|
|
173
179
|
function failFromError(error: unknown): CliResult {
|
|
174
|
-
|
|
175
|
-
return failResult({
|
|
176
|
-
command: "subtask",
|
|
177
|
-
human: error.message,
|
|
178
|
-
data: {
|
|
179
|
-
code: error.code,
|
|
180
|
-
...(error.details ?? {}),
|
|
181
|
-
},
|
|
182
|
-
error: {
|
|
183
|
-
code: error.code,
|
|
184
|
-
message: error.message,
|
|
185
|
-
},
|
|
186
|
-
});
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
return failResult({
|
|
180
|
+
return unexpectedFailureResult(error, {
|
|
190
181
|
command: "subtask",
|
|
191
182
|
human: "Unexpected subtask command failure",
|
|
192
|
-
data: {},
|
|
193
|
-
error: {
|
|
194
|
-
code: "internal_error",
|
|
195
|
-
message: "Unexpected subtask command failure",
|
|
196
|
-
},
|
|
197
183
|
});
|
|
198
184
|
}
|
|
199
185
|
|
|
@@ -212,10 +198,160 @@ function failMissingOptionValue(command: string, option: string): CliResult {
|
|
|
212
198
|
});
|
|
213
199
|
}
|
|
214
200
|
|
|
201
|
+
function failBatchSpec(command: string, human: string, data: Record<string, unknown>): CliResult {
|
|
202
|
+
return failResult({
|
|
203
|
+
command,
|
|
204
|
+
human,
|
|
205
|
+
data,
|
|
206
|
+
error: {
|
|
207
|
+
code: "invalid_input",
|
|
208
|
+
message: human,
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function failUnexpectedPositionals(command: string, unexpected: readonly string[]): CliResult {
|
|
214
|
+
return failBatchSpec(command, `Unexpected positional arguments: ${unexpected.join(", ")}.`, {
|
|
215
|
+
unexpectedPositionals: unexpected,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function failConflictingTaskIds(optionTaskId: string, positionalTaskId: string): CliResult {
|
|
220
|
+
return failBatchSpec("subtask.create-many", "Conflicting task ids for subtask create-many: positional task id must match --task.", {
|
|
221
|
+
option: "task",
|
|
222
|
+
optionTaskId,
|
|
223
|
+
positionalTaskId,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function failEmptyCompactField(command: string, option: string, index: number, rawSpec: string, field: string): CliResult {
|
|
228
|
+
return failBatchSpec(command, `${option === "subtask" ? "Subtask" : "Spec"} spec ${index + 1} is missing a ${field}.`, {
|
|
229
|
+
option,
|
|
230
|
+
index,
|
|
231
|
+
rawSpec,
|
|
232
|
+
field,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function parseSubtaskCreateManySpecs(parentTaskId: string, rawSpecs: readonly string[]): { specs: CompactSubtaskSpec[]; error?: CliResult } {
|
|
237
|
+
const specs: CompactSubtaskSpec[] = [];
|
|
238
|
+
const seenTempKeys = new Set<string>();
|
|
239
|
+
|
|
240
|
+
for (const [index, rawSpec] of rawSpecs.entries()) {
|
|
241
|
+
const parsed = parseCompactFields(rawSpec);
|
|
242
|
+
if (parsed.invalidEscape !== null) {
|
|
243
|
+
return {
|
|
244
|
+
specs: [],
|
|
245
|
+
error: failBatchSpec("subtask.create-many", `Invalid escape sequence ${parsed.invalidEscape} in --subtask spec ${index + 1}.`, {
|
|
246
|
+
option: "subtask",
|
|
247
|
+
index,
|
|
248
|
+
rawSpec,
|
|
249
|
+
invalidEscape: parsed.invalidEscape,
|
|
250
|
+
}),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (parsed.hasDanglingEscape) {
|
|
255
|
+
return {
|
|
256
|
+
specs: [],
|
|
257
|
+
error: failBatchSpec("subtask.create-many", `Trailing escape in --subtask spec ${index + 1}.`, {
|
|
258
|
+
option: "subtask",
|
|
259
|
+
index,
|
|
260
|
+
rawSpec,
|
|
261
|
+
}),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (parsed.fields.length !== 4) {
|
|
266
|
+
return {
|
|
267
|
+
specs: [],
|
|
268
|
+
error: failBatchSpec("subtask.create-many", `Subtask specs must use <temp-key>|<title>|<description>|<status> in --subtask spec ${index + 1}.`, {
|
|
269
|
+
option: "subtask",
|
|
270
|
+
index,
|
|
271
|
+
rawSpec,
|
|
272
|
+
fields: parsed.fields,
|
|
273
|
+
}),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const tempKey = parsed.fields[0] ?? "";
|
|
278
|
+
const title = parsed.fields[1] ?? "";
|
|
279
|
+
const description = parsed.fields[2] ?? "";
|
|
280
|
+
const status = parsed.fields[3] ?? "";
|
|
281
|
+
if (!tempKey || !isValidCompactTempKey(tempKey)) {
|
|
282
|
+
return {
|
|
283
|
+
specs: [],
|
|
284
|
+
error: failBatchSpec("subtask.create-many", `Subtask spec ${index + 1} must start with a temp key like seed-1.`, {
|
|
285
|
+
option: "subtask",
|
|
286
|
+
index,
|
|
287
|
+
rawSpec,
|
|
288
|
+
tempKey,
|
|
289
|
+
}),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (seenTempKeys.has(tempKey)) {
|
|
294
|
+
return {
|
|
295
|
+
specs: [],
|
|
296
|
+
error: failBatchSpec("subtask.create-many", `Duplicate temp key '${tempKey}' in --subtask specs.`, {
|
|
297
|
+
option: "subtask",
|
|
298
|
+
index,
|
|
299
|
+
rawSpec,
|
|
300
|
+
tempKey,
|
|
301
|
+
}),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (!title || title.trim().length === 0) {
|
|
306
|
+
return {
|
|
307
|
+
specs: [],
|
|
308
|
+
error: failBatchSpec("subtask.create-many", `Subtask spec ${index + 1} is missing a title.`, {
|
|
309
|
+
option: "subtask",
|
|
310
|
+
index,
|
|
311
|
+
rawSpec,
|
|
312
|
+
}),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (description.trim().length === 0) {
|
|
317
|
+
return {
|
|
318
|
+
specs: [],
|
|
319
|
+
error: failEmptyCompactField("subtask.create-many", "subtask", index, rawSpec, "description"),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
seenTempKeys.add(tempKey);
|
|
324
|
+
const spec: CompactSubtaskSpec = status.length > 0
|
|
325
|
+
? {
|
|
326
|
+
parent: {
|
|
327
|
+
kind: "id",
|
|
328
|
+
id: parentTaskId,
|
|
329
|
+
},
|
|
330
|
+
tempKey,
|
|
331
|
+
title,
|
|
332
|
+
description,
|
|
333
|
+
status,
|
|
334
|
+
}
|
|
335
|
+
: {
|
|
336
|
+
parent: {
|
|
337
|
+
kind: "id",
|
|
338
|
+
id: parentTaskId,
|
|
339
|
+
},
|
|
340
|
+
tempKey,
|
|
341
|
+
title,
|
|
342
|
+
description,
|
|
343
|
+
};
|
|
344
|
+
specs.push(spec);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return { specs };
|
|
348
|
+
}
|
|
349
|
+
|
|
215
350
|
export async function runSubtask(context: CliContext): Promise<CliResult> {
|
|
216
|
-
|
|
351
|
+
let database: TrekoonDatabase | undefined;
|
|
217
352
|
|
|
218
353
|
try {
|
|
354
|
+
database = openTrekoonDatabase(context.cwd);
|
|
219
355
|
const parsed = parseArgs(context.args);
|
|
220
356
|
const subcommand: string | undefined = parsed.positional[0];
|
|
221
357
|
const domain = new TrackerDomain(database.db);
|
|
@@ -248,6 +384,68 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
|
|
|
248
384
|
data: { subtask },
|
|
249
385
|
});
|
|
250
386
|
}
|
|
387
|
+
case "create-many": {
|
|
388
|
+
const createManyUnknownOption = findUnknownOption(parsed, CREATE_MANY_OPTIONS);
|
|
389
|
+
if (createManyUnknownOption !== undefined) {
|
|
390
|
+
return unknownOption("subtask.create-many", createManyUnknownOption, CREATE_MANY_OPTIONS);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const missingCreateManyOption = readMissingOptionValue(parsed.missingOptionValues, "task", "t", "subtask");
|
|
394
|
+
if (missingCreateManyOption !== undefined) {
|
|
395
|
+
return failMissingOptionValue("subtask.create-many", missingCreateManyOption);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const optionTaskId = readOption(parsed.options, "task", "t");
|
|
399
|
+
const positionalTaskId = parsed.positional[1];
|
|
400
|
+
const unexpectedPositionals = readUnexpectedPositionals(parsed, positionalTaskId === undefined ? 1 : 2);
|
|
401
|
+
if (unexpectedPositionals.length > 0) {
|
|
402
|
+
return failUnexpectedPositionals("subtask.create-many", unexpectedPositionals);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (
|
|
406
|
+
optionTaskId !== undefined
|
|
407
|
+
&& positionalTaskId !== undefined
|
|
408
|
+
&& optionTaskId.trim().length > 0
|
|
409
|
+
&& positionalTaskId.trim().length > 0
|
|
410
|
+
&& optionTaskId !== positionalTaskId
|
|
411
|
+
) {
|
|
412
|
+
return failConflictingTaskIds(optionTaskId, positionalTaskId);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const taskId = optionTaskId ?? positionalTaskId;
|
|
416
|
+
if (taskId === undefined || taskId.trim().length === 0) {
|
|
417
|
+
return failBatchSpec("subtask.create-many", "Provide --task (or positional task id) for subtask create-many.", {
|
|
418
|
+
option: "task",
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const rawSpecs = readOptions(parsed.optionEntries, "subtask");
|
|
423
|
+
if (rawSpecs.length === 0) {
|
|
424
|
+
return failBatchSpec("subtask.create-many", "Provide at least one --subtask spec.", {
|
|
425
|
+
option: "subtask",
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const specResult = parseSubtaskCreateManySpecs(taskId, rawSpecs);
|
|
430
|
+
if (specResult.error !== undefined) {
|
|
431
|
+
return specResult.error;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const created = mutations.createSubtaskBatch({
|
|
435
|
+
taskId,
|
|
436
|
+
specs: specResult.specs,
|
|
437
|
+
});
|
|
438
|
+
const result: CompactBatchResultContract = created.result;
|
|
439
|
+
return okResult({
|
|
440
|
+
command: "subtask.create-many",
|
|
441
|
+
human: `Created ${created.subtasks.length} subtask(s): ${created.subtasks.map(formatSubtask).join("\n")}`,
|
|
442
|
+
data: {
|
|
443
|
+
taskId,
|
|
444
|
+
subtasks: created.subtasks,
|
|
445
|
+
result,
|
|
446
|
+
},
|
|
447
|
+
});
|
|
448
|
+
}
|
|
251
449
|
case "list": {
|
|
252
450
|
const missingListOption =
|
|
253
451
|
readMissingOptionValue(parsed.missingOptionValues, "view") ??
|
|
@@ -669,7 +867,7 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
|
|
|
669
867
|
default:
|
|
670
868
|
return failResult({
|
|
671
869
|
command: "subtask",
|
|
672
|
-
human: "Usage: trekoon subtask <create|list|search|replace|update|delete>",
|
|
870
|
+
human: "Usage: trekoon subtask <create|create-many|list|search|replace|update|delete>",
|
|
673
871
|
data: {
|
|
674
872
|
args: context.args,
|
|
675
873
|
},
|
|
@@ -682,6 +880,6 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
|
|
|
682
880
|
} catch (error: unknown) {
|
|
683
881
|
return failFromError(error);
|
|
684
882
|
} finally {
|
|
685
|
-
database
|
|
883
|
+
database?.close();
|
|
686
884
|
}
|
|
687
885
|
}
|
package/src/commands/sync.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { findUnknownOption, parseArgs, readMissingOptionValue, readOption, suggestOptions } from "./arg-parser";
|
|
2
|
+
import { safeErrorMessage, sqliteBusyFailure } from "./error-utils";
|
|
2
3
|
|
|
4
|
+
import { DomainError } from "../domain/types";
|
|
3
5
|
import { failResult, okResult } from "../io/output";
|
|
4
6
|
import { type CliContext, type CliResult } from "../runtime/command-types";
|
|
5
|
-
import {
|
|
7
|
+
import { resolveStorageResolutionDiagnostics } from "../storage/database";
|
|
8
|
+
import { assertValidSourceRef } from "../sync/branch-db";
|
|
6
9
|
import { getSyncConflict, listSyncConflicts, syncPull, syncResolve, syncStatus } from "../sync/service";
|
|
7
10
|
import { type SyncResolution } from "../sync/types";
|
|
8
11
|
|
|
@@ -109,6 +112,15 @@ function formatConflictList(
|
|
|
109
112
|
.join("\n");
|
|
110
113
|
}
|
|
111
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
|
+
|
|
112
124
|
export async function runSync(context: CliContext): Promise<CliResult> {
|
|
113
125
|
const parsed = parseArgs(context.args);
|
|
114
126
|
const subcommand: string | undefined = parsed.positional[0];
|
|
@@ -132,11 +144,17 @@ export async function runSync(context: CliContext): Promise<CliResult> {
|
|
|
132
144
|
}
|
|
133
145
|
|
|
134
146
|
const sourceBranch: string = readOption(parsed.options, "from") ?? "main";
|
|
147
|
+
assertValidSourceRef(context.cwd, sourceBranch);
|
|
135
148
|
const summary = syncStatus(context.cwd, sourceBranch);
|
|
136
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
|
+
|
|
137
155
|
return okResult({
|
|
138
156
|
command: "sync.status",
|
|
139
|
-
human:
|
|
157
|
+
human: humanLines.join("\n"),
|
|
140
158
|
data: summary,
|
|
141
159
|
});
|
|
142
160
|
}
|
|
@@ -157,20 +175,26 @@ export async function runSync(context: CliContext): Promise<CliResult> {
|
|
|
157
175
|
return usage("sync pull requires --from <branch>.", "sync.pull");
|
|
158
176
|
}
|
|
159
177
|
|
|
178
|
+
assertValidSourceRef(context.cwd, sourceBranch);
|
|
160
179
|
const summary = syncPull(context.cwd, sourceBranch);
|
|
161
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
|
+
|
|
162
195
|
return okResult({
|
|
163
196
|
command: "sync.pull",
|
|
164
|
-
human:
|
|
165
|
-
`Pulled from '${summary.sourceBranch}'`,
|
|
166
|
-
`Scanned events: ${summary.scannedEvents}`,
|
|
167
|
-
`Applied events: ${summary.appliedEvents}`,
|
|
168
|
-
`Created conflicts: ${summary.createdConflicts}`,
|
|
169
|
-
`Malformed payloads: ${summary.diagnostics.malformedPayloadEvents}`,
|
|
170
|
-
`Quarantined events: ${summary.diagnostics.quarantinedEvents}`,
|
|
171
|
-
`Conflict events: ${summary.diagnostics.conflictEvents}`,
|
|
172
|
-
...summary.diagnostics.errorHints,
|
|
173
|
-
].join("\n"),
|
|
197
|
+
human: humanLines.join("\n"),
|
|
174
198
|
data: summary,
|
|
175
199
|
});
|
|
176
200
|
}
|
|
@@ -274,21 +298,44 @@ export async function runSync(context: CliContext): Promise<CliResult> {
|
|
|
274
298
|
|
|
275
299
|
return usage(`Unknown sync subcommand '${subcommand}'.`);
|
|
276
300
|
} catch (error) {
|
|
277
|
-
|
|
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
|
+
|
|
278
324
|
return failResult({
|
|
279
325
|
command: resolvedCommand,
|
|
280
|
-
human: error.message,
|
|
326
|
+
human: formatDomainErrorHuman(error.message, error.details),
|
|
281
327
|
data: {
|
|
282
|
-
|
|
328
|
+
...(error.details ?? {}),
|
|
329
|
+
reason: error.code,
|
|
283
330
|
},
|
|
284
331
|
error: {
|
|
285
|
-
code:
|
|
332
|
+
code: error.code,
|
|
286
333
|
message: error.message,
|
|
287
334
|
},
|
|
288
335
|
});
|
|
289
336
|
}
|
|
290
337
|
|
|
291
|
-
const message = error
|
|
338
|
+
const message = safeErrorMessage(error, "Unknown sync error.");
|
|
292
339
|
|
|
293
340
|
return failResult({
|
|
294
341
|
command: resolvedCommand,
|
|
@@ -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
|
+
}
|