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.
@@ -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 { DomainError, type SearchEntityMatch, type SubtaskRecord } from "../domain/types";
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
- if (error instanceof DomainError) {
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
- const database = openTrekoonDatabase(context.cwd);
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.close();
883
+ database?.close();
686
884
  }
687
885
  }
@@ -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 { MissingBranchDatabaseError } from "../sync/branch-db";
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: statusMessage(summary.sourceBranch, summary.ahead, summary.behind, summary.pendingConflicts),
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
- if (error instanceof MissingBranchDatabaseError) {
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
- reason: "missing_branch_db",
328
+ ...(error.details ?? {}),
329
+ reason: error.code,
283
330
  },
284
331
  error: {
285
- code: "missing_branch_db",
332
+ code: error.code,
286
333
  message: error.message,
287
334
  },
288
335
  });
289
336
  }
290
337
 
291
- const message = error instanceof Error ? error.message : "Unknown sync 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
+ }