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/epic.ts
CHANGED
|
@@ -2,24 +2,39 @@ import {
|
|
|
2
2
|
SEARCH_REPLACE_FIELDS,
|
|
3
3
|
findUnknownOption,
|
|
4
4
|
hasFlag,
|
|
5
|
+
isValidCompactTempKey,
|
|
5
6
|
parseArgs,
|
|
7
|
+
parseCompactEntityRef,
|
|
8
|
+
parseCompactFields,
|
|
6
9
|
parseCsvEnumOption,
|
|
7
10
|
parseStrictNonNegativeInt,
|
|
8
11
|
parseStrictPositiveInt,
|
|
9
12
|
readEnumOption,
|
|
10
13
|
readMissingOptionValue,
|
|
11
14
|
readOption,
|
|
15
|
+
readOptions,
|
|
16
|
+
readUnexpectedPositionals,
|
|
12
17
|
resolvePreviewApplyMode,
|
|
13
18
|
suggestOptions,
|
|
14
19
|
} from "./arg-parser";
|
|
20
|
+
import { unexpectedFailureResult } from "./error-utils";
|
|
15
21
|
|
|
16
22
|
import { MutationService } from "../domain/mutation-service";
|
|
17
23
|
import { TrackerDomain } from "../domain/tracker-domain";
|
|
18
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
COMPACT_TEMP_KEY_PREFIX,
|
|
26
|
+
type CompactBatchResultContract,
|
|
27
|
+
type CompactDependencySpec,
|
|
28
|
+
type CompactEntityRef,
|
|
29
|
+
type CompactSubtaskSpec,
|
|
30
|
+
type CompactTaskSpec,
|
|
31
|
+
type EpicRecord,
|
|
32
|
+
type SearchEntityMatch,
|
|
33
|
+
} from "../domain/types";
|
|
19
34
|
import { formatHumanTable } from "../io/human-table";
|
|
20
35
|
import { failResult, okResult } from "../io/output";
|
|
21
36
|
import { type CliContext, type CliResult } from "../runtime/command-types";
|
|
22
|
-
import { openTrekoonDatabase } from "../storage/database";
|
|
37
|
+
import { openTrekoonDatabase, type TrekoonDatabase } from "../storage/database";
|
|
23
38
|
|
|
24
39
|
function formatEpic(epic: EpicRecord): string {
|
|
25
40
|
return `${epic.id} | ${epic.title} | ${epic.status}`;
|
|
@@ -29,8 +44,10 @@ const VIEW_MODES = ["table", "compact", "tree", "detail"] as const;
|
|
|
29
44
|
const LIST_VIEW_MODES = ["table", "compact"] as const;
|
|
30
45
|
const DEFAULT_LIST_LIMIT = 10;
|
|
31
46
|
const DEFAULT_OPEN_STATUSES = ["in_progress", "in-progress", "todo"] as const;
|
|
47
|
+
const CREATE_OPTIONS = ["title", "t", "description", "d", "status", "s", "task", "subtask", "dep"] as const;
|
|
32
48
|
const SEARCH_OPTIONS = ["fields", "preview"] as const;
|
|
33
49
|
const REPLACE_OPTIONS = ["search", "replace", "fields", "preview", "apply"] as const;
|
|
50
|
+
const EXPAND_OPTIONS = ["task", "subtask", "dep"] as const;
|
|
34
51
|
|
|
35
52
|
function parseStatusCsv(rawStatuses: string | undefined): string[] | undefined {
|
|
36
53
|
if (rawStatuses === undefined) {
|
|
@@ -304,36 +321,346 @@ function formatEpicShowTable(tree: {
|
|
|
304
321
|
}
|
|
305
322
|
|
|
306
323
|
function failFromError(error: unknown, command: string): CliResult {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
code: error.code,
|
|
313
|
-
...(error.details ?? {}),
|
|
314
|
-
},
|
|
315
|
-
error: {
|
|
316
|
-
code: error.code,
|
|
317
|
-
message: error.message,
|
|
318
|
-
},
|
|
319
|
-
});
|
|
320
|
-
}
|
|
324
|
+
return unexpectedFailureResult(error, {
|
|
325
|
+
command,
|
|
326
|
+
human: "Unexpected epic command failure",
|
|
327
|
+
});
|
|
328
|
+
}
|
|
321
329
|
|
|
330
|
+
function failBatchSpec(command: string, human: string, data: Record<string, unknown>): CliResult {
|
|
322
331
|
return failResult({
|
|
323
332
|
command,
|
|
324
|
-
human
|
|
325
|
-
data
|
|
333
|
+
human,
|
|
334
|
+
data,
|
|
326
335
|
error: {
|
|
327
|
-
code: "
|
|
328
|
-
message:
|
|
336
|
+
code: "invalid_input",
|
|
337
|
+
message: human,
|
|
329
338
|
},
|
|
330
339
|
});
|
|
331
340
|
}
|
|
332
341
|
|
|
342
|
+
function failUnexpectedPositionals(command: string, unexpected: readonly string[]): CliResult {
|
|
343
|
+
return failBatchSpec(command, `Unexpected positional arguments: ${unexpected.join(", ")}.`, {
|
|
344
|
+
unexpectedPositionals: unexpected,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function failEmptyCompactField(command: string, option: string, index: number, rawSpec: string, field: string): CliResult {
|
|
349
|
+
const label = option === "task" ? "Task" : "Subtask";
|
|
350
|
+
return failBatchSpec(command, `${label} spec ${index + 1} is missing a ${field}.`, {
|
|
351
|
+
option,
|
|
352
|
+
index,
|
|
353
|
+
rawSpec,
|
|
354
|
+
field,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function validateCompactEntityRef(
|
|
359
|
+
command: string,
|
|
360
|
+
option: string,
|
|
361
|
+
index: number,
|
|
362
|
+
rawSpec: string,
|
|
363
|
+
label: string,
|
|
364
|
+
reference: CompactEntityRef,
|
|
365
|
+
): CliResult | undefined {
|
|
366
|
+
if (reference.kind === "temp_key" && !isValidCompactTempKey(reference.tempKey)) {
|
|
367
|
+
return failBatchSpec(command, `${label} in --${option} spec ${index + 1} must use ${COMPACT_TEMP_KEY_PREFIX}<temp-key> with letters, numbers, dot, dash, or underscore.`, {
|
|
368
|
+
option,
|
|
369
|
+
index,
|
|
370
|
+
rawSpec,
|
|
371
|
+
reference,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (reference.kind === "id" && reference.id.trim().length === 0) {
|
|
376
|
+
return failBatchSpec(command, `${label} in --${option} spec ${index + 1} is required.`, {
|
|
377
|
+
option,
|
|
378
|
+
index,
|
|
379
|
+
rawSpec,
|
|
380
|
+
reference,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return undefined;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function parseExpandTaskSpecs(rawSpecs: readonly string[]): { specs: CompactTaskSpec[]; error?: CliResult } {
|
|
388
|
+
const specs: CompactTaskSpec[] = [];
|
|
389
|
+
const seenTempKeys = new Set<string>();
|
|
390
|
+
|
|
391
|
+
for (const [index, rawSpec] of rawSpecs.entries()) {
|
|
392
|
+
const parsed = parseCompactFields(rawSpec);
|
|
393
|
+
if (parsed.invalidEscape !== null) {
|
|
394
|
+
return {
|
|
395
|
+
specs: [],
|
|
396
|
+
error: failBatchSpec("epic.expand", `Invalid escape sequence ${parsed.invalidEscape} in --task spec ${index + 1}.`, {
|
|
397
|
+
option: "task",
|
|
398
|
+
index,
|
|
399
|
+
rawSpec,
|
|
400
|
+
}),
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (parsed.hasDanglingEscape) {
|
|
405
|
+
return {
|
|
406
|
+
specs: [],
|
|
407
|
+
error: failBatchSpec("epic.expand", `Trailing escape in --task spec ${index + 1}.`, {
|
|
408
|
+
option: "task",
|
|
409
|
+
index,
|
|
410
|
+
rawSpec,
|
|
411
|
+
}),
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (parsed.fields.length !== 4) {
|
|
416
|
+
return {
|
|
417
|
+
specs: [],
|
|
418
|
+
error: failBatchSpec("epic.expand", `Task specs must use <temp-key>|<title>|<description>|<status> in --task spec ${index + 1}.`, {
|
|
419
|
+
option: "task",
|
|
420
|
+
index,
|
|
421
|
+
rawSpec,
|
|
422
|
+
fields: parsed.fields,
|
|
423
|
+
}),
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const tempKey = parsed.fields[0] ?? "";
|
|
428
|
+
const title = parsed.fields[1] ?? "";
|
|
429
|
+
const description = parsed.fields[2] ?? "";
|
|
430
|
+
const status = parsed.fields[3] ?? "";
|
|
431
|
+
if (!tempKey || !isValidCompactTempKey(tempKey)) {
|
|
432
|
+
return {
|
|
433
|
+
specs: [],
|
|
434
|
+
error: failBatchSpec("epic.expand", `Task spec ${index + 1} must start with a temp key like seed-1.`, {
|
|
435
|
+
option: "task",
|
|
436
|
+
index,
|
|
437
|
+
rawSpec,
|
|
438
|
+
tempKey,
|
|
439
|
+
}),
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (seenTempKeys.has(tempKey)) {
|
|
444
|
+
return {
|
|
445
|
+
specs: [],
|
|
446
|
+
error: failBatchSpec("epic.expand", `Duplicate temp key '${tempKey}' across --task specs.`, {
|
|
447
|
+
option: "task",
|
|
448
|
+
index,
|
|
449
|
+
rawSpec,
|
|
450
|
+
tempKey,
|
|
451
|
+
}),
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (!title || title.trim().length === 0) {
|
|
456
|
+
return {
|
|
457
|
+
specs: [],
|
|
458
|
+
error: failBatchSpec("epic.expand", `Task spec ${index + 1} is missing a title.`, {
|
|
459
|
+
option: "task",
|
|
460
|
+
index,
|
|
461
|
+
rawSpec,
|
|
462
|
+
}),
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (description.trim().length === 0) {
|
|
467
|
+
return {
|
|
468
|
+
specs: [],
|
|
469
|
+
error: failEmptyCompactField("epic.expand", "task", index, rawSpec, "description"),
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
seenTempKeys.add(tempKey);
|
|
474
|
+
const spec: CompactTaskSpec = status.length > 0
|
|
475
|
+
? { tempKey, title, description, status }
|
|
476
|
+
: { tempKey, title, description };
|
|
477
|
+
specs.push(spec);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return { specs };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function parseExpandSubtaskSpecs(rawSpecs: readonly string[]): { specs: CompactSubtaskSpec[]; error?: CliResult } {
|
|
484
|
+
const specs: CompactSubtaskSpec[] = [];
|
|
485
|
+
const seenTempKeys = new Set<string>();
|
|
486
|
+
|
|
487
|
+
for (const [index, rawSpec] of rawSpecs.entries()) {
|
|
488
|
+
const parsed = parseCompactFields(rawSpec);
|
|
489
|
+
if (parsed.invalidEscape !== null) {
|
|
490
|
+
return {
|
|
491
|
+
specs: [],
|
|
492
|
+
error: failBatchSpec("epic.expand", `Invalid escape sequence ${parsed.invalidEscape} in --subtask spec ${index + 1}.`, {
|
|
493
|
+
option: "subtask",
|
|
494
|
+
index,
|
|
495
|
+
rawSpec,
|
|
496
|
+
}),
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (parsed.hasDanglingEscape) {
|
|
501
|
+
return {
|
|
502
|
+
specs: [],
|
|
503
|
+
error: failBatchSpec("epic.expand", `Trailing escape in --subtask spec ${index + 1}.`, {
|
|
504
|
+
option: "subtask",
|
|
505
|
+
index,
|
|
506
|
+
rawSpec,
|
|
507
|
+
}),
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (parsed.fields.length !== 5) {
|
|
512
|
+
return {
|
|
513
|
+
specs: [],
|
|
514
|
+
error: failBatchSpec("epic.expand", `Subtask specs must use <parent-ref>|<temp-key>|<title>|<description>|<status> in --subtask spec ${index + 1}.`, {
|
|
515
|
+
option: "subtask",
|
|
516
|
+
index,
|
|
517
|
+
rawSpec,
|
|
518
|
+
fields: parsed.fields,
|
|
519
|
+
}),
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const parent = parseCompactEntityRef(parsed.fields[0] ?? "");
|
|
524
|
+
const parentError = validateCompactEntityRef("epic.expand", "subtask", index, rawSpec, "Parent ref", parent);
|
|
525
|
+
if (parentError !== undefined) {
|
|
526
|
+
return { specs: [], error: parentError };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const tempKey = parsed.fields[1] ?? "";
|
|
530
|
+
const title = parsed.fields[2] ?? "";
|
|
531
|
+
const description = parsed.fields[3] ?? "";
|
|
532
|
+
const status = parsed.fields[4] ?? "";
|
|
533
|
+
if (!tempKey || !isValidCompactTempKey(tempKey)) {
|
|
534
|
+
return {
|
|
535
|
+
specs: [],
|
|
536
|
+
error: failBatchSpec("epic.expand", `Subtask spec ${index + 1} must include a valid temp key.`, {
|
|
537
|
+
option: "subtask",
|
|
538
|
+
index,
|
|
539
|
+
rawSpec,
|
|
540
|
+
tempKey,
|
|
541
|
+
}),
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (seenTempKeys.has(tempKey)) {
|
|
546
|
+
return {
|
|
547
|
+
specs: [],
|
|
548
|
+
error: failBatchSpec("epic.expand", `Duplicate temp key '${tempKey}' across --subtask specs.`, {
|
|
549
|
+
option: "subtask",
|
|
550
|
+
index,
|
|
551
|
+
rawSpec,
|
|
552
|
+
tempKey,
|
|
553
|
+
}),
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (!title || title.trim().length === 0) {
|
|
558
|
+
return {
|
|
559
|
+
specs: [],
|
|
560
|
+
error: failBatchSpec("epic.expand", `Subtask spec ${index + 1} is missing a title.`, {
|
|
561
|
+
option: "subtask",
|
|
562
|
+
index,
|
|
563
|
+
rawSpec,
|
|
564
|
+
}),
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (description.trim().length === 0) {
|
|
569
|
+
return {
|
|
570
|
+
specs: [],
|
|
571
|
+
error: failEmptyCompactField("epic.expand", "subtask", index, rawSpec, "description"),
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
seenTempKeys.add(tempKey);
|
|
576
|
+
const spec: CompactSubtaskSpec = status.length > 0
|
|
577
|
+
? { parent, tempKey, title, description, status }
|
|
578
|
+
: { parent, tempKey, title, description };
|
|
579
|
+
specs.push(spec);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
return { specs };
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function parseExpandDependencySpecs(rawSpecs: readonly string[]): { specs: CompactDependencySpec[]; error?: CliResult } {
|
|
586
|
+
const specs: CompactDependencySpec[] = [];
|
|
587
|
+
|
|
588
|
+
for (const [index, rawSpec] of rawSpecs.entries()) {
|
|
589
|
+
const parsed = parseCompactFields(rawSpec);
|
|
590
|
+
if (parsed.invalidEscape !== null) {
|
|
591
|
+
return {
|
|
592
|
+
specs: [],
|
|
593
|
+
error: failBatchSpec("epic.expand", `Invalid escape sequence ${parsed.invalidEscape} in --dep spec ${index + 1}.`, {
|
|
594
|
+
option: "dep",
|
|
595
|
+
index,
|
|
596
|
+
rawSpec,
|
|
597
|
+
}),
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (parsed.hasDanglingEscape) {
|
|
602
|
+
return {
|
|
603
|
+
specs: [],
|
|
604
|
+
error: failBatchSpec("epic.expand", `Trailing escape in --dep spec ${index + 1}.`, {
|
|
605
|
+
option: "dep",
|
|
606
|
+
index,
|
|
607
|
+
rawSpec,
|
|
608
|
+
}),
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
if (parsed.fields.length !== 2) {
|
|
613
|
+
return {
|
|
614
|
+
specs: [],
|
|
615
|
+
error: failBatchSpec("epic.expand", `Dependency specs must use <source-ref>|<depends-on-ref> in --dep spec ${index + 1}.`, {
|
|
616
|
+
option: "dep",
|
|
617
|
+
index,
|
|
618
|
+
rawSpec,
|
|
619
|
+
fields: parsed.fields,
|
|
620
|
+
}),
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const source = parseCompactEntityRef(parsed.fields[0] ?? "");
|
|
625
|
+
const sourceError = validateCompactEntityRef("epic.expand", "dep", index, rawSpec, "Source ref", source);
|
|
626
|
+
if (sourceError !== undefined) {
|
|
627
|
+
return { specs: [], error: sourceError };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const dependsOn = parseCompactEntityRef(parsed.fields[1] ?? "");
|
|
631
|
+
const dependsOnError = validateCompactEntityRef("epic.expand", "dep", index, rawSpec, "Depends-on ref", dependsOn);
|
|
632
|
+
if (dependsOnError !== undefined) {
|
|
633
|
+
return { specs: [], error: dependsOnError };
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
specs.push({ source, dependsOn });
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
return { specs };
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function findDuplicateExpandTempKey(tasks: readonly CompactTaskSpec[], subtasks: readonly CompactSubtaskSpec[]): string | null {
|
|
643
|
+
const seen = new Set<string>();
|
|
644
|
+
for (const task of tasks) {
|
|
645
|
+
seen.add(task.tempKey);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
for (const subtask of subtasks) {
|
|
649
|
+
if (seen.has(subtask.tempKey)) {
|
|
650
|
+
return subtask.tempKey;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
seen.add(subtask.tempKey);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
return null;
|
|
657
|
+
}
|
|
658
|
+
|
|
333
659
|
export async function runEpic(context: CliContext): Promise<CliResult> {
|
|
334
|
-
|
|
660
|
+
let database: TrekoonDatabase | undefined;
|
|
335
661
|
|
|
336
662
|
try {
|
|
663
|
+
database = openTrekoonDatabase(context.cwd);
|
|
337
664
|
const parsed = parseArgs(context.args);
|
|
338
665
|
const subcommand: string | undefined = parsed.positional[0];
|
|
339
666
|
const domain = new TrackerDomain(database.db);
|
|
@@ -341,26 +668,161 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
|
|
|
341
668
|
|
|
342
669
|
switch (subcommand) {
|
|
343
670
|
case "create": {
|
|
344
|
-
|
|
671
|
+
const missingCreateOption =
|
|
672
|
+
readMissingOptionValue(parsed.missingOptionValues, "title", "t") ??
|
|
345
673
|
readMissingOptionValue(parsed.missingOptionValues, "status", "s") ??
|
|
346
|
-
readMissingOptionValue(parsed.missingOptionValues, "description", "d")
|
|
674
|
+
readMissingOptionValue(parsed.missingOptionValues, "description", "d") ??
|
|
675
|
+
readMissingOptionValue(parsed.missingOptionValues, "task", "subtask", "dep");
|
|
347
676
|
if (missingCreateOption !== undefined) {
|
|
348
677
|
return failMissingOptionValue("epic.create", missingCreateOption);
|
|
349
678
|
}
|
|
350
679
|
|
|
680
|
+
const createUnknownOption = findUnknownOption(parsed, CREATE_OPTIONS);
|
|
681
|
+
if (createUnknownOption !== undefined) {
|
|
682
|
+
return unknownOption("epic.create", createUnknownOption, CREATE_OPTIONS);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const unexpectedPositionals = readUnexpectedPositionals(parsed, 1);
|
|
686
|
+
if (unexpectedPositionals.length > 0) {
|
|
687
|
+
return failUnexpectedPositionals("epic.create", unexpectedPositionals);
|
|
688
|
+
}
|
|
689
|
+
|
|
351
690
|
const title: string | undefined = readOption(parsed.options, "title", "t");
|
|
352
691
|
const description: string | undefined = readOption(parsed.options, "description", "d");
|
|
353
692
|
const status: string | undefined = readOption(parsed.options, "status", "s");
|
|
354
|
-
|
|
693
|
+
|
|
694
|
+
const taskSpecs = readOptions(parsed.optionEntries, "task");
|
|
695
|
+
const subtaskSpecs = readOptions(parsed.optionEntries, "subtask");
|
|
696
|
+
const dependencySpecs = readOptions(parsed.optionEntries, "dep");
|
|
697
|
+
|
|
698
|
+
if (taskSpecs.length === 0 && subtaskSpecs.length === 0 && dependencySpecs.length === 0) {
|
|
699
|
+
const epic = mutations.createEpic({
|
|
700
|
+
title: title ?? "",
|
|
701
|
+
description: description ?? "",
|
|
702
|
+
status,
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
return okResult({
|
|
706
|
+
command: "epic.create",
|
|
707
|
+
human: `Created epic ${formatEpic(epic)}`,
|
|
708
|
+
data: { epic },
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const parsedTasks = parseExpandTaskSpecs(taskSpecs);
|
|
713
|
+
if (parsedTasks.error !== undefined) {
|
|
714
|
+
return parsedTasks.error;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const parsedSubtasks = parseExpandSubtaskSpecs(subtaskSpecs);
|
|
718
|
+
if (parsedSubtasks.error !== undefined) {
|
|
719
|
+
return parsedSubtasks.error;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const parsedDeps = parseExpandDependencySpecs(dependencySpecs);
|
|
723
|
+
if (parsedDeps.error !== undefined) {
|
|
724
|
+
return parsedDeps.error;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const duplicateTempKey = findDuplicateExpandTempKey(parsedTasks.specs, parsedSubtasks.specs);
|
|
728
|
+
if (duplicateTempKey !== null) {
|
|
729
|
+
return failBatchSpec("epic.create", `Duplicate temp key '${duplicateTempKey}' across --task and --subtask specs.`, {
|
|
730
|
+
tempKey: duplicateTempKey,
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const created = mutations.createEpicGraph({
|
|
355
735
|
title: title ?? "",
|
|
356
736
|
description: description ?? "",
|
|
357
737
|
status,
|
|
738
|
+
taskSpecs: parsedTasks.specs,
|
|
739
|
+
subtaskSpecs: parsedSubtasks.specs,
|
|
740
|
+
dependencySpecs: parsedDeps.specs,
|
|
358
741
|
});
|
|
359
742
|
|
|
360
743
|
return okResult({
|
|
361
744
|
command: "epic.create",
|
|
362
|
-
human: `Created epic ${formatEpic(epic)}
|
|
363
|
-
data: {
|
|
745
|
+
human: `Created epic ${formatEpic(created.epic)} with ${created.tasks.length} task(s), ${created.subtasks.length} subtask(s), and ${created.dependencies.length} dependenc${created.dependencies.length === 1 ? "y" : "ies"}.`,
|
|
746
|
+
data: {
|
|
747
|
+
epic: created.epic,
|
|
748
|
+
tasks: created.tasks,
|
|
749
|
+
subtasks: created.subtasks,
|
|
750
|
+
dependencies: created.dependencies,
|
|
751
|
+
result: created.result,
|
|
752
|
+
},
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
case "expand": {
|
|
756
|
+
const expandUnknownOption = findUnknownOption(parsed, EXPAND_OPTIONS);
|
|
757
|
+
if (expandUnknownOption !== undefined) {
|
|
758
|
+
return unknownOption("epic.expand", expandUnknownOption, EXPAND_OPTIONS);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const missingExpandOption = readMissingOptionValue(parsed.missingOptionValues, "task", "subtask", "dep");
|
|
762
|
+
if (missingExpandOption !== undefined) {
|
|
763
|
+
return failMissingOptionValue("epic.expand", missingExpandOption);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const unexpectedPositionals = readUnexpectedPositionals(parsed, 2);
|
|
767
|
+
if (unexpectedPositionals.length > 0) {
|
|
768
|
+
return failUnexpectedPositionals("epic.expand", unexpectedPositionals);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const epicId: string = parsed.positional[1] ?? "";
|
|
772
|
+
if (epicId.trim().length === 0) {
|
|
773
|
+
return failBatchSpec("epic.expand", "Provide an epic id for epic expand.", {
|
|
774
|
+
id: epicId,
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const taskSpecs = readOptions(parsed.optionEntries, "task");
|
|
779
|
+
const subtaskSpecs = readOptions(parsed.optionEntries, "subtask");
|
|
780
|
+
const dependencySpecs = readOptions(parsed.optionEntries, "dep");
|
|
781
|
+
if (taskSpecs.length === 0 && subtaskSpecs.length === 0 && dependencySpecs.length === 0) {
|
|
782
|
+
return failBatchSpec("epic.expand", "Provide at least one --task, --subtask, or --dep spec.", {});
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const parsedTasks = parseExpandTaskSpecs(taskSpecs);
|
|
786
|
+
if (parsedTasks.error !== undefined) {
|
|
787
|
+
return parsedTasks.error;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const parsedSubtasks = parseExpandSubtaskSpecs(subtaskSpecs);
|
|
791
|
+
if (parsedSubtasks.error !== undefined) {
|
|
792
|
+
return parsedSubtasks.error;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const parsedDeps = parseExpandDependencySpecs(dependencySpecs);
|
|
796
|
+
if (parsedDeps.error !== undefined) {
|
|
797
|
+
return parsedDeps.error;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const duplicateTempKey = findDuplicateExpandTempKey(parsedTasks.specs, parsedSubtasks.specs);
|
|
801
|
+
if (duplicateTempKey !== null) {
|
|
802
|
+
return failBatchSpec("epic.expand", `Duplicate temp key '${duplicateTempKey}' across --task and --subtask specs.`, {
|
|
803
|
+
tempKey: duplicateTempKey,
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const created = mutations.expandEpic({
|
|
808
|
+
epicId,
|
|
809
|
+
taskSpecs: parsedTasks.specs,
|
|
810
|
+
subtaskSpecs: parsedSubtasks.specs,
|
|
811
|
+
dependencySpecs: parsedDeps.specs,
|
|
812
|
+
});
|
|
813
|
+
const result: CompactBatchResultContract & {
|
|
814
|
+
counts: { tasks: number; subtasks: number; dependencies: number };
|
|
815
|
+
} = created.result;
|
|
816
|
+
return okResult({
|
|
817
|
+
command: "epic.expand",
|
|
818
|
+
human: `Expanded epic ${epicId} with ${created.tasks.length} task(s), ${created.subtasks.length} subtask(s), and ${created.dependencies.length} dependenc${created.dependencies.length === 1 ? "y" : "ies"}.`,
|
|
819
|
+
data: {
|
|
820
|
+
epicId,
|
|
821
|
+
tasks: created.tasks,
|
|
822
|
+
subtasks: created.subtasks,
|
|
823
|
+
dependencies: created.dependencies,
|
|
824
|
+
result,
|
|
825
|
+
},
|
|
364
826
|
});
|
|
365
827
|
}
|
|
366
828
|
case "list": {
|
|
@@ -841,7 +1303,7 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
|
|
|
841
1303
|
default:
|
|
842
1304
|
return failResult({
|
|
843
1305
|
command: "epic",
|
|
844
|
-
human: "Usage: trekoon epic <create|list|show|search|replace|update|delete>",
|
|
1306
|
+
human: "Usage: trekoon epic <create|expand|list|show|search|replace|update|delete>",
|
|
845
1307
|
data: {
|
|
846
1308
|
args: context.args,
|
|
847
1309
|
},
|
|
@@ -854,6 +1316,6 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
|
|
|
854
1316
|
} catch (error: unknown) {
|
|
855
1317
|
return failFromError(error, "epic");
|
|
856
1318
|
} finally {
|
|
857
|
-
database
|
|
1319
|
+
database?.close();
|
|
858
1320
|
}
|
|
859
1321
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { DomainError } from "../domain/types";
|
|
2
|
+
import { failResult } from "../io/output";
|
|
3
|
+
import { type CliResult } from "../runtime/command-types";
|
|
4
|
+
|
|
5
|
+
interface UnexpectedFailureOptions {
|
|
6
|
+
readonly command: string;
|
|
7
|
+
readonly human: string;
|
|
8
|
+
readonly data?: Record<string, unknown>;
|
|
9
|
+
readonly errorCode?: string;
|
|
10
|
+
readonly errorMessage?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function readErrorMessage(error: unknown): string | null {
|
|
14
|
+
if (error instanceof Error) {
|
|
15
|
+
return error.message;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (typeof error === "string") {
|
|
19
|
+
return error;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (typeof error === "object" && error !== null && "message" in error) {
|
|
23
|
+
const candidate = (error as { message?: unknown }).message;
|
|
24
|
+
if (typeof candidate === "string") {
|
|
25
|
+
return candidate;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function sanitizeErrorMessage(message: string): string {
|
|
33
|
+
const normalized = message.replace(/\s+/gu, " ").trim();
|
|
34
|
+
if (normalized.length <= 240) {
|
|
35
|
+
return normalized;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return `${normalized.slice(0, 237)}...`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isSqliteBusyMessage(message: string): boolean {
|
|
42
|
+
const normalized = sanitizeErrorMessage(message).toLowerCase();
|
|
43
|
+
const hasDatabaseContext = normalized.includes("sqlite") || normalized.includes("database");
|
|
44
|
+
const hasBusySignal =
|
|
45
|
+
normalized.includes("sqlite_busy") ||
|
|
46
|
+
normalized.includes("database is locked") ||
|
|
47
|
+
normalized.includes("database schema is locked") ||
|
|
48
|
+
normalized.includes("database table is locked") ||
|
|
49
|
+
normalized.includes("busy");
|
|
50
|
+
|
|
51
|
+
return hasDatabaseContext && hasBusySignal;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function sqliteBusyFailure(command: string, error: unknown): CliResult | null {
|
|
55
|
+
const message = readErrorMessage(error);
|
|
56
|
+
if (message === null || !isSqliteBusyMessage(message)) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const safeMessage = sanitizeErrorMessage(message);
|
|
61
|
+
return failResult({
|
|
62
|
+
command,
|
|
63
|
+
human: `Trekoon database is busy. ${safeMessage}`,
|
|
64
|
+
data: {
|
|
65
|
+
code: "database_busy",
|
|
66
|
+
reason: "database_busy",
|
|
67
|
+
databaseMessage: safeMessage,
|
|
68
|
+
},
|
|
69
|
+
error: {
|
|
70
|
+
code: "database_busy",
|
|
71
|
+
message: `Trekoon database is busy: ${safeMessage}`,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function unexpectedFailureResult(error: unknown, options: UnexpectedFailureOptions): CliResult {
|
|
77
|
+
if (error instanceof DomainError) {
|
|
78
|
+
return failResult({
|
|
79
|
+
command: options.command,
|
|
80
|
+
human: error.message,
|
|
81
|
+
data: {
|
|
82
|
+
code: error.code,
|
|
83
|
+
...(error.details ?? {}),
|
|
84
|
+
},
|
|
85
|
+
error: {
|
|
86
|
+
code: error.code,
|
|
87
|
+
message: error.message,
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const busyFailure = sqliteBusyFailure(options.command, error);
|
|
93
|
+
if (busyFailure !== null) {
|
|
94
|
+
return busyFailure;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return failResult({
|
|
98
|
+
command: options.command,
|
|
99
|
+
human: options.human,
|
|
100
|
+
data: options.data ?? {},
|
|
101
|
+
error: {
|
|
102
|
+
code: options.errorCode ?? "internal_error",
|
|
103
|
+
message: options.errorMessage ?? options.human,
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function safeErrorMessage(error: unknown, fallback: string): string {
|
|
109
|
+
const message = readErrorMessage(error);
|
|
110
|
+
return message === null ? fallback : sanitizeErrorMessage(message);
|
|
111
|
+
}
|