trekoon 0.3.5 → 0.3.7

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.
@@ -1,11 +1,7 @@
1
1
  import { TrackerDomain } from "../domain/tracker-domain";
2
2
  import { type DependencyRecord, type EpicRecord, type SubtaskRecord, type TaskRecord } from "../domain/types";
3
3
 
4
- interface SearchFields {
5
- readonly title: string;
6
- readonly description: string;
7
- readonly text: string;
8
- }
4
+ type BoardStatus = "todo" | "blocked" | "in_progress" | "done";
9
5
 
10
6
  interface StatusCounts {
11
7
  readonly total: number;
@@ -16,65 +12,63 @@ interface StatusCounts {
16
12
  readonly other: number;
17
13
  }
18
14
 
19
- export interface BoardSnapshotEpic {
15
+ interface FlatCounts extends Record<BoardStatus, number> {}
16
+
17
+ export interface BoardSnapshotDependency {
20
18
  readonly id: string;
21
- readonly title: string;
22
- readonly description: string;
23
- readonly status: string;
19
+ readonly sourceId: string;
20
+ readonly sourceKind: "task" | "subtask";
21
+ readonly dependsOnId: string;
22
+ readonly dependsOnKind: "task" | "subtask";
24
23
  readonly createdAt: number;
25
24
  readonly updatedAt: number;
26
- readonly taskIds: readonly string[];
27
- readonly counts: {
28
- readonly tasks: StatusCounts;
29
- readonly subtasks: StatusCounts;
30
- };
31
- readonly search: SearchFields;
32
25
  }
33
26
 
34
- export interface BoardSnapshotTask {
27
+ interface BoardSnapshotSubtask {
35
28
  readonly id: string;
36
- readonly epicId: string;
29
+ readonly kind: "subtask";
30
+ readonly taskId: string;
37
31
  readonly title: string;
38
32
  readonly description: string;
39
33
  readonly status: string;
34
+ readonly owner: string | null;
40
35
  readonly createdAt: number;
41
36
  readonly updatedAt: number;
42
- readonly subtaskIds: readonly string[];
37
+ readonly blockedBy: readonly string[];
38
+ readonly blocks: readonly string[];
43
39
  readonly dependencyIds: readonly string[];
44
40
  readonly dependentIds: readonly string[];
45
- readonly counts: {
46
- readonly subtasks: StatusCounts;
47
- readonly dependencies: number;
48
- readonly dependents: number;
49
- };
50
- readonly search: SearchFields;
41
+ readonly searchText: string;
51
42
  }
52
43
 
53
- export interface BoardSnapshotSubtask {
44
+ interface BoardSnapshotTask {
54
45
  readonly id: string;
55
- readonly taskId: string;
46
+ readonly kind: "task";
47
+ readonly epicId: string;
56
48
  readonly title: string;
57
49
  readonly description: string;
58
50
  readonly status: string;
51
+ readonly owner: string | null;
59
52
  readonly createdAt: number;
60
53
  readonly updatedAt: number;
54
+ readonly blockedBy: readonly string[];
55
+ readonly blocks: readonly string[];
61
56
  readonly dependencyIds: readonly string[];
62
57
  readonly dependentIds: readonly string[];
63
- readonly counts: {
64
- readonly dependencies: number;
65
- readonly dependents: number;
66
- };
67
- readonly search: SearchFields;
58
+ readonly subtasks: readonly BoardSnapshotSubtask[];
59
+ readonly searchText: string;
68
60
  }
69
61
 
70
- export interface BoardSnapshotDependency {
62
+ interface BoardSnapshotEpic {
71
63
  readonly id: string;
72
- readonly sourceId: string;
73
- readonly sourceKind: "task" | "subtask";
74
- readonly dependsOnId: string;
75
- readonly dependsOnKind: "task" | "subtask";
64
+ readonly title: string;
65
+ readonly description: string;
66
+ readonly status: string;
76
67
  readonly createdAt: number;
77
68
  readonly updatedAt: number;
69
+ readonly taskIds: readonly string[];
70
+ readonly counts: FlatCounts;
71
+ readonly searchText: string;
78
72
  }
79
73
 
80
74
  export interface BoardSnapshot {
@@ -92,56 +86,31 @@ export interface BoardSnapshot {
92
86
  }
93
87
 
94
88
  function normalizeStatusBucket(status: string): keyof Omit<StatusCounts, "total"> {
95
- if (status === "todo") {
96
- return "todo";
97
- }
98
-
99
- if (status === "blocked") {
100
- return "blocked";
101
- }
102
-
103
- if (status === "in_progress" || status === "in-progress") {
104
- return "inProgress";
105
- }
106
-
107
- if (status === "done") {
108
- return "done";
109
- }
110
-
89
+ if (status === "todo") return "todo";
90
+ if (status === "blocked") return "blocked";
91
+ if (status === "in_progress" || status === "in-progress") return "inProgress";
92
+ if (status === "done") return "done";
111
93
  return "other";
112
94
  }
113
95
 
114
96
  function countStatuses(records: readonly { readonly status: string }[]): StatusCounts {
115
- const counts: {
116
- total: number;
117
- todo: number;
118
- blocked: number;
119
- inProgress: number;
120
- done: number;
121
- other: number;
122
- } = {
123
- total: records.length,
124
- todo: 0,
125
- blocked: 0,
126
- inProgress: 0,
127
- done: 0,
128
- other: 0,
129
- };
130
-
97
+ const counts = { total: records.length, todo: 0, blocked: 0, inProgress: 0, done: 0, other: 0 };
131
98
  for (const record of records) {
132
- const bucket = normalizeStatusBucket(record.status);
133
- counts[bucket] += 1;
99
+ counts[normalizeStatusBucket(record.status)] += 1;
134
100
  }
135
-
136
101
  return counts;
137
102
  }
138
103
 
139
- function buildSearchFields(title: string, description: string): SearchFields {
140
- return {
141
- title,
142
- description,
143
- text: `${title}\n${description}`.trim(),
144
- };
104
+ function deriveFlatCounts(records: readonly { readonly status: string }[]): FlatCounts {
105
+ return records.reduce<FlatCounts>(
106
+ (counts, record) => {
107
+ if (record.status === "todo" || record.status === "blocked" || record.status === "in_progress" || record.status === "done") {
108
+ counts[record.status] += 1;
109
+ }
110
+ return counts;
111
+ },
112
+ { todo: 0, blocked: 0, in_progress: 0, done: 0 },
113
+ );
145
114
  }
146
115
 
147
116
  function mapDependency(record: DependencyRecord): BoardSnapshotDependency {
@@ -157,115 +126,129 @@ function mapDependency(record: DependencyRecord): BoardSnapshotDependency {
157
126
  }
158
127
 
159
128
  export function buildBoardSnapshot(domain: TrackerDomain): BoardSnapshot {
160
- const generatedAt: number = Date.now();
161
- const epics: readonly EpicRecord[] = domain.listEpics();
162
- const tasks: readonly TaskRecord[] = domain.listTasks();
163
- const subtasks: readonly SubtaskRecord[] = domain.listSubtasks();
129
+ const generatedAt = Date.now();
130
+ const epics = domain.listEpics();
131
+ const tasks = domain.listTasks();
132
+ const subtasks = domain.listSubtasks();
133
+ const sourceIds = [...tasks.map((task) => task.id), ...subtasks.map((subtask) => subtask.id)];
134
+ const dependenciesBySourceId = domain.listDependenciesBySourceIds(sourceIds);
135
+ const subtasksByTaskId = new Map<string, SubtaskRecord[]>();
136
+ const tasksByEpicId = new Map<string, TaskRecord[]>();
137
+ const dependencyIdsBySource = new Map<string, string[]>();
138
+ const blockedByIdsBySource = new Map<string, string[]>();
139
+ const dependentIdsByTarget = new Map<string, string[]>();
140
+ const blocksByTarget = new Map<string, string[]>();
164
141
  const dependencies: BoardSnapshotDependency[] = [];
165
142
 
166
- const tasksByEpic = new Map<string, TaskRecord[]>();
167
143
  for (const task of tasks) {
168
- const existing = tasksByEpic.get(task.epicId) ?? [];
144
+ const existing = tasksByEpicId.get(task.epicId) ?? [];
169
145
  existing.push(task);
170
- tasksByEpic.set(task.epicId, existing);
146
+ tasksByEpicId.set(task.epicId, existing);
171
147
  }
172
148
 
173
- const subtasksByTask = new Map<string, SubtaskRecord[]>();
174
149
  for (const subtask of subtasks) {
175
- const existing = subtasksByTask.get(subtask.taskId) ?? [];
150
+ const existing = subtasksByTaskId.get(subtask.taskId) ?? [];
176
151
  existing.push(subtask);
177
- subtasksByTask.set(subtask.taskId, existing);
152
+ subtasksByTaskId.set(subtask.taskId, existing);
178
153
  }
179
154
 
180
- const dependencyIdsBySource = new Map<string, string[]>();
181
- const dependentIdsByTarget = new Map<string, string[]>();
182
- for (const task of tasks) {
183
- for (const dependency of domain.listDependencies(task.id)) {
155
+ for (const sourceId of sourceIds) {
156
+ for (const dependency of dependenciesBySourceId.get(sourceId) ?? []) {
184
157
  dependencies.push(mapDependency(dependency));
185
- const sourceIds = dependencyIdsBySource.get(dependency.sourceId) ?? [];
186
- sourceIds.push(dependency.id);
187
- dependencyIdsBySource.set(dependency.sourceId, sourceIds);
188
- const dependentIds = dependentIdsByTarget.get(dependency.dependsOnId) ?? [];
189
- dependentIds.push(dependency.id);
190
- dependentIdsByTarget.set(dependency.dependsOnId, dependentIds);
158
+
159
+ const sourceDependencyIds = dependencyIdsBySource.get(dependency.sourceId) ?? [];
160
+ sourceDependencyIds.push(dependency.id);
161
+ dependencyIdsBySource.set(dependency.sourceId, sourceDependencyIds);
162
+
163
+ const sourceBlockedBy = blockedByIdsBySource.get(dependency.sourceId) ?? [];
164
+ sourceBlockedBy.push(dependency.dependsOnId);
165
+ blockedByIdsBySource.set(dependency.sourceId, sourceBlockedBy);
166
+
167
+ const targetDependentIds = dependentIdsByTarget.get(dependency.dependsOnId) ?? [];
168
+ targetDependentIds.push(dependency.id);
169
+ dependentIdsByTarget.set(dependency.dependsOnId, targetDependentIds);
170
+
171
+ const targetBlocks = blocksByTarget.get(dependency.dependsOnId) ?? [];
172
+ targetBlocks.push(dependency.sourceId);
173
+ blocksByTarget.set(dependency.dependsOnId, targetBlocks);
191
174
  }
192
175
  }
193
176
 
194
- for (const subtask of subtasks) {
195
- for (const dependency of domain.listDependencies(subtask.id)) {
196
- dependencies.push(mapDependency(dependency));
197
- const sourceIds = dependencyIdsBySource.get(dependency.sourceId) ?? [];
198
- sourceIds.push(dependency.id);
199
- dependencyIdsBySource.set(dependency.sourceId, sourceIds);
200
- const dependentIds = dependentIdsByTarget.get(dependency.dependsOnId) ?? [];
201
- dependentIds.push(dependency.id);
202
- dependentIdsByTarget.set(dependency.dependsOnId, dependentIds);
203
- }
177
+ const snapshotSubtasks: BoardSnapshotSubtask[] = subtasks.map((subtask) => ({
178
+ id: subtask.id,
179
+ kind: "subtask",
180
+ taskId: subtask.taskId,
181
+ title: subtask.title,
182
+ description: subtask.description,
183
+ status: subtask.status,
184
+ owner: subtask.owner ?? null,
185
+ createdAt: subtask.createdAt,
186
+ updatedAt: subtask.updatedAt,
187
+ blockedBy: blockedByIdsBySource.get(subtask.id) ?? [],
188
+ blocks: blocksByTarget.get(subtask.id) ?? [],
189
+ dependencyIds: dependencyIdsBySource.get(subtask.id) ?? [],
190
+ dependentIds: dependentIdsByTarget.get(subtask.id) ?? [],
191
+ searchText: [subtask.title, subtask.description, subtask.status].join(" ").toLowerCase(),
192
+ }));
193
+ const snapshotSubtasksByTaskId = new Map<string, BoardSnapshotSubtask[]>();
194
+ for (const subtask of snapshotSubtasks) {
195
+ const existing = snapshotSubtasksByTaskId.get(subtask.taskId) ?? [];
196
+ existing.push(subtask);
197
+ snapshotSubtasksByTaskId.set(subtask.taskId, existing);
198
+ }
199
+
200
+ const snapshotTasks: BoardSnapshotTask[] = tasks.map((task) => {
201
+ const taskSubtasks = snapshotSubtasksByTaskId.get(task.id) ?? [];
202
+ return {
203
+ id: task.id,
204
+ kind: "task",
205
+ epicId: task.epicId,
206
+ title: task.title,
207
+ description: task.description,
208
+ status: task.status,
209
+ owner: task.owner ?? null,
210
+ createdAt: task.createdAt,
211
+ updatedAt: task.updatedAt,
212
+ blockedBy: blockedByIdsBySource.get(task.id) ?? [],
213
+ blocks: blocksByTarget.get(task.id) ?? [],
214
+ dependencyIds: dependencyIdsBySource.get(task.id) ?? [],
215
+ dependentIds: dependentIdsByTarget.get(task.id) ?? [],
216
+ subtasks: taskSubtasks,
217
+ searchText: [
218
+ task.title,
219
+ task.description,
220
+ task.status,
221
+ ...taskSubtasks.map((subtask) => `${subtask.title} ${subtask.description} ${subtask.status}`),
222
+ ].join(" ").toLowerCase(),
223
+ };
224
+ });
225
+ const taskSearchTextByEpicId = new Map<string, string[]>();
226
+ for (const task of snapshotTasks) {
227
+ const existing = taskSearchTextByEpicId.get(task.epicId) ?? [];
228
+ existing.push(task.searchText);
229
+ taskSearchTextByEpicId.set(task.epicId, existing);
204
230
  }
205
231
 
232
+ const snapshotEpics: BoardSnapshotEpic[] = epics.map((epic) => {
233
+ const epicTasks = tasksByEpicId.get(epic.id) ?? [];
234
+ return {
235
+ id: epic.id,
236
+ title: epic.title,
237
+ description: epic.description,
238
+ status: epic.status,
239
+ createdAt: epic.createdAt,
240
+ updatedAt: epic.updatedAt,
241
+ taskIds: epicTasks.map((task) => task.id),
242
+ counts: deriveFlatCounts(epicTasks),
243
+ searchText: [epic.title, epic.description, ...(taskSearchTextByEpicId.get(epic.id) ?? [])].join(" ").toLowerCase(),
244
+ };
245
+ });
246
+
206
247
  return {
207
248
  generatedAt,
208
- epics: epics.map((epic) => {
209
- const epicTasks = tasksByEpic.get(epic.id) ?? [];
210
- const epicSubtasks = epicTasks.flatMap((task) => subtasksByTask.get(task.id) ?? []);
211
- return {
212
- id: epic.id,
213
- title: epic.title,
214
- description: epic.description,
215
- status: epic.status,
216
- createdAt: epic.createdAt,
217
- updatedAt: epic.updatedAt,
218
- taskIds: epicTasks.map((task) => task.id),
219
- counts: {
220
- tasks: countStatuses(epicTasks),
221
- subtasks: countStatuses(epicSubtasks),
222
- },
223
- search: buildSearchFields(epic.title, epic.description),
224
- };
225
- }),
226
- tasks: tasks.map((task) => {
227
- const taskSubtasks = subtasksByTask.get(task.id) ?? [];
228
- const dependencyIds = dependencyIdsBySource.get(task.id) ?? [];
229
- const dependentIds = dependentIdsByTarget.get(task.id) ?? [];
230
- return {
231
- id: task.id,
232
- epicId: task.epicId,
233
- title: task.title,
234
- description: task.description,
235
- status: task.status,
236
- createdAt: task.createdAt,
237
- updatedAt: task.updatedAt,
238
- subtaskIds: taskSubtasks.map((subtask) => subtask.id),
239
- dependencyIds,
240
- dependentIds,
241
- counts: {
242
- subtasks: countStatuses(taskSubtasks),
243
- dependencies: dependencyIds.length,
244
- dependents: dependentIds.length,
245
- },
246
- search: buildSearchFields(task.title, task.description),
247
- };
248
- }),
249
- subtasks: subtasks.map((subtask) => {
250
- const dependencyIds = dependencyIdsBySource.get(subtask.id) ?? [];
251
- const dependentIds = dependentIdsByTarget.get(subtask.id) ?? [];
252
- return {
253
- id: subtask.id,
254
- taskId: subtask.taskId,
255
- title: subtask.title,
256
- description: subtask.description,
257
- status: subtask.status,
258
- createdAt: subtask.createdAt,
259
- updatedAt: subtask.updatedAt,
260
- dependencyIds,
261
- dependentIds,
262
- counts: {
263
- dependencies: dependencyIds.length,
264
- dependents: dependentIds.length,
265
- },
266
- search: buildSearchFields(subtask.title, subtask.description),
267
- };
268
- }),
249
+ epics: snapshotEpics,
250
+ tasks: snapshotTasks,
251
+ subtasks: snapshotSubtasks,
269
252
  dependencies,
270
253
  counts: {
271
254
  epics: countStatuses(epics),
@@ -73,17 +73,23 @@ export async function runEvents(context: CliContext): Promise<CliResult> {
73
73
  archive,
74
74
  });
75
75
 
76
- return okResult({
77
- command: "events.prune",
78
- human: [
79
- dryRun ? "Dry run complete." : "Prune complete.",
80
- `Retention days: ${summary.retentionDays}`,
81
- `Candidates: ${summary.candidateCount}`,
82
- `Archived: ${summary.archivedCount}`,
83
- `Deleted: ${summary.deletedCount}`,
84
- ].join("\n"),
85
- data: summary,
86
- });
76
+ return okResult({
77
+ command: "events.prune",
78
+ human: [
79
+ dryRun ? "Dry run complete." : "Prune complete.",
80
+ `Retention days: ${summary.retentionDays}`,
81
+ `Candidates: ${summary.candidateCount}`,
82
+ `Archived: ${summary.archivedCount}`,
83
+ `Deleted: ${summary.deletedCount}`,
84
+ summary.staleCursorCount > 0
85
+ ? `Sync guidance: ${summary.staleCursorCount} cursor(s) reference pruned history. Run 'trekoon sync pull --from <branch>' and rebuild if stale cursor hints persist.`
86
+ : "Sync guidance: pruning stayed within retained cursor history.",
87
+ archive
88
+ ? "Retention automation: archived copies were kept before deletion."
89
+ : "Retention automation: rerun with --archive to keep retained copies before deletion.",
90
+ ].join("\n"),
91
+ data: summary,
92
+ });
87
93
  } catch (error: unknown) {
88
94
  const busyFailure = sqliteBusyFailure("events.prune", error);
89
95
  if (busyFailure !== null) {
@@ -1,3 +1,6 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+
1
4
  import { unexpectedFailureResult } from "./error-utils";
2
5
 
3
6
  import { ensureBoardInstalled } from "../board/install";
@@ -6,6 +9,11 @@ import { DomainError } from "../domain/types";
6
9
  import { failResult, okResult } from "../io/output";
7
10
  import { type CliContext, type CliResult } from "../runtime/command-types";
8
11
  import { openTrekoonDatabase, type TrekoonDatabase } from "../storage/database";
12
+ import { type StorageMode } from "../storage/path";
13
+
14
+ type GitignoreAction = "created" | "already_exists" | "skipped";
15
+
16
+ const GITIGNORE_CONTENT = "*\n";
9
17
 
10
18
  function buildRecoverySummary(database: TrekoonDatabase): string[] {
11
19
  const diagnostics = database.diagnostics;
@@ -76,6 +84,24 @@ function recoveryFailureResult(error: DomainError): CliResult | null {
76
84
  });
77
85
  }
78
86
 
87
+ function ensureGitignore(storageDir: string, storageMode: StorageMode): GitignoreAction {
88
+ if (storageMode === "cwd") {
89
+ return "skipped";
90
+ }
91
+
92
+ const gitignorePath: string = resolve(storageDir, ".gitignore");
93
+
94
+ if (existsSync(gitignorePath)) {
95
+ const existing: string = readFileSync(gitignorePath, "utf8");
96
+ if (existing === GITIGNORE_CONTENT) {
97
+ return "already_exists";
98
+ }
99
+ }
100
+
101
+ writeFileSync(gitignorePath, GITIGNORE_CONTENT, "utf8");
102
+ return "created";
103
+ }
104
+
79
105
  export async function runInit(context: CliContext): Promise<CliResult> {
80
106
  let database: TrekoonDatabase | undefined;
81
107
 
@@ -87,6 +113,11 @@ export async function runInit(context: CliContext): Promise<CliResult> {
87
113
  workingDirectory: context.cwd,
88
114
  ...(bundledAssetRoot === undefined ? {} : { bundledAssetRoot }),
89
115
  });
116
+ const gitignoreAction: GitignoreAction = ensureGitignore(
117
+ database.paths.storageDir,
118
+ diagnostics.storageMode,
119
+ );
120
+
90
121
  const humanLines: string[] = [
91
122
  "Trekoon initialized.",
92
123
  `Storage mode: ${diagnostics.storageMode}`,
@@ -96,6 +127,7 @@ export async function runInit(context: CliContext): Promise<CliResult> {
96
127
  `Database file: ${database.paths.databaseFile}`,
97
128
  `Board assets: ${board.action}`,
98
129
  `Board runtime root: ${board.paths.runtimeRoot}`,
130
+ `Gitignore: ${gitignoreAction}`,
99
131
  ...buildRecoverySummary(database),
100
132
  ];
101
133
 
@@ -115,6 +147,10 @@ export async function runInit(context: CliContext): Promise<CliResult> {
115
147
  paths: board.paths,
116
148
  manifest: board.manifest,
117
149
  },
150
+ gitignore: {
151
+ action: gitignoreAction,
152
+ path: resolve(database.paths.storageDir, ".gitignore"),
153
+ },
118
154
  legacyStateDetected: diagnostics.legacyStateDetected,
119
155
  recoveryRequired: diagnostics.recoveryRequired,
120
156
  recoveryStatus: diagnostics.recoveryStatus,
@@ -935,12 +935,12 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
935
935
  }
936
936
  case "delete": {
937
937
  const subtaskId: string = parsed.positional[1] ?? "";
938
- mutations.deleteSubtask(subtaskId);
938
+ const result = mutations.deleteSubtask(subtaskId);
939
939
 
940
940
  return okResult({
941
941
  command: "subtask.delete",
942
942
  human: `Deleted subtask ${subtaskId}`,
943
- data: { id: subtaskId },
943
+ data: { id: subtaskId, deletedDependencyIds: result.deletedDependencyIds },
944
944
  });
945
945
  }
946
946
  default: