backlog.md 0.1.0

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.
Files changed (162) hide show
  1. package/.backlog/archive/drafts/readme.md +3 -0
  2. package/.backlog/archive/drafts/task-41 - temporary-test-task.md +13 -0
  3. package/.backlog/archive/readme.md +6 -0
  4. package/.backlog/archive/tasks/readme.md +3 -0
  5. package/.backlog/archive/tasks/task-41 - cli-migrate-terminal-ui-to-bblessed.md +14 -0
  6. package/.backlog/config.yml +7 -0
  7. package/.backlog/decisions/readme.md +7 -0
  8. package/.backlog/docs/readme.md +20 -0
  9. package/.backlog/drafts/readme.md +3 -0
  10. package/.backlog/drafts/task-26 - docs-add-board-export-step-to-agent-dod.md +21 -0
  11. package/.backlog/drafts/task-28 - add-code-of-conduct.md +20 -0
  12. package/.backlog/drafts/task-30 - create-changelog.md +19 -0
  13. package/.backlog/milestones/m-0 - project-setup.md +8 -0
  14. package/.backlog/milestones/m-1 - cli.md +8 -0
  15. package/.backlog/milestones/m-2 - cli-kanban.md +8 -0
  16. package/.backlog/milestones/m-3 - gui.md +8 -0
  17. package/.backlog/milestones/m-4 - gui-kanban.md +8 -0
  18. package/.backlog/milestones/m-5 - gui-advanced.md +12 -0
  19. package/.backlog/milestones/readme.md +3 -0
  20. package/.backlog/readme.md +5 -0
  21. package/.backlog/tasks/readme.md +37 -0
  22. package/.backlog/tasks/task-1 - cli-setup-core-project.md +23 -0
  23. package/.backlog/tasks/task-10 - gui-init-packaging.md +23 -0
  24. package/.backlog/tasks/task-11 - gui-kanban-board.md +26 -0
  25. package/.backlog/tasks/task-12 - gui-advanced.md +25 -0
  26. package/.backlog/tasks/task-13 - cli-add-agent-instruction-prompt.md +53 -0
  27. package/.backlog/tasks/task-13.1 - cli-agent-instruction-file-selection.md +40 -0
  28. package/.backlog/tasks/task-14 - gui-introduction-screens.md +21 -0
  29. package/.backlog/tasks/task-15 - improve-tasks-readme-with-generic-example-and-cli-reference.md +20 -0
  30. package/.backlog/tasks/task-16 - improve-docs-readme-with-generic-example-and-cli-reference.md +20 -0
  31. package/.backlog/tasks/task-17 - improve-drafts-readme-with-generic-example-and-cli-reference.md +20 -0
  32. package/.backlog/tasks/task-18 - improve-decisions-readme-with-generic-example-and-cli-reference.md +20 -0
  33. package/.backlog/tasks/task-19 - cli-fix-default-task-status-and-remove-draft-from-statuses.md +55 -0
  34. package/.backlog/tasks/task-2 - cli-core-logic-library.md +28 -0
  35. package/.backlog/tasks/task-20 - add-agent-guideline-to-mark-tasks-in-progress-on-start.md +32 -0
  36. package/.backlog/tasks/task-21 - kanban-board-vertical-layout.md +31 -0
  37. package/.backlog/tasks/task-22 - cli-prevent-double-dash-in-task-filenames.md +24 -0
  38. package/.backlog/tasks/task-23 - cli-kanban-board-order-tasks-by-id-asc.md +30 -0
  39. package/.backlog/tasks/task-24 - handle-subtasks-in-the-kanban-view.md +38 -0
  40. package/.backlog/tasks/task-24.1 - cli-kanban-board-milestone-view.md +19 -0
  41. package/.backlog/tasks/task-25 - cli-export-kanban-board-to-readme.md +28 -0
  42. package/.backlog/tasks/task-27 - add-contributing-guidelines.md +27 -0
  43. package/.backlog/tasks/task-29 - add-github-templates.md +28 -0
  44. package/.backlog/tasks/task-3 - cli-implement-backlog-init.md +63 -0
  45. package/.backlog/tasks/task-31 - update-readme-for-open-source.md +26 -0
  46. package/.backlog/tasks/task-32 - cli-hide-empty-'no-status'-column.md +31 -0
  47. package/.backlog/tasks/task-33 - cli-export-milestones-board-as-roadmap.md +20 -0
  48. package/.backlog/tasks/task-34 - split-readme.md-for-users-and-contributors.md +26 -0
  49. package/.backlog/tasks/task-35 - finalize-package.json-metadata-for-publishing.md +24 -0
  50. package/.backlog/tasks/task-36 - cli-prompt-for-project-name-in-init.md +24 -0
  51. package/.backlog/tasks/task-37 - cli-board-view-open-tasks-in-ide.md +19 -0
  52. package/.backlog/tasks/task-38 - cli-improved-agent-selection-for-init.md +25 -0
  53. package/.backlog/tasks/task-39 - cli-fix-empty-agent-instruction-files-on-init.md +31 -0
  54. package/.backlog/tasks/task-4 - cli-task-management-commands.md +28 -0
  55. package/.backlog/tasks/task-4.1 - cli-task-create.md +27 -0
  56. package/.backlog/tasks/task-4.10 - use-cli-to-mark-tasks-done.md +51 -0
  57. package/.backlog/tasks/task-4.11 - docs-add-definition-of-done-to-agent-guidelines.md +23 -0
  58. package/.backlog/tasks/task-4.12 - cli-handle-task-id-conflicts-across-branches.md +53 -0
  59. package/.backlog/tasks/task-4.13 - cli-fix-config-command-local-global-logic.md +58 -0
  60. package/.backlog/tasks/task-4.2 - cli-task-list-view.md +25 -0
  61. package/.backlog/tasks/task-4.3 - cli-task-edit.md +24 -0
  62. package/.backlog/tasks/task-4.4 - cli-task-archive-transition.md +27 -0
  63. package/.backlog/tasks/task-4.5 - cli-init-prompts-for-reporter-name-and-global-local-config.md +28 -0
  64. package/.backlog/tasks/task-4.6 - cli-add-empty-assignee-array-field-for-new-tasks.md +35 -0
  65. package/.backlog/tasks/task-4.7 - cli-parse-unquoted-created_date.md +40 -0
  66. package/.backlog/tasks/task-4.8 - cli-enforce-description-header.md +48 -0
  67. package/.backlog/tasks/task-4.9 - cli-normalize-task-id-inputs.md +66 -0
  68. package/.backlog/tasks/task-40 - cli-board-command-defaults-to-view.md +38 -0
  69. package/.backlog/tasks/task-41 - cli-migrate-terminal-ui-to-bblessed.md +93 -0
  70. package/.backlog/tasks/task-41.1 - cli-bblessed-init-wizard.md +42 -0
  71. package/.backlog/tasks/task-41.2 - cli-bblessed-task-view.md +44 -0
  72. package/.backlog/tasks/task-41.3 - cli-bblessed-doc-view.md +45 -0
  73. package/.backlog/tasks/task-41.4 - cli-bblessed-board-view.md +49 -0
  74. package/.backlog/tasks/task-41.5 - cli-audit-remaining-ui-for-bblessed.md +55 -0
  75. package/.backlog/tasks/task-42 - visual-hierarchy.md +54 -0
  76. package/.backlog/tasks/task-43 - remove-duplicate-acceptance-criteria-and-style-metadata.md +56 -0
  77. package/.backlog/tasks/task-44 - checklist-alignment.md +24 -0
  78. package/.backlog/tasks/task-45 - safe-line-wrapping.md +23 -0
  79. package/.backlog/tasks/task-46 - split-pane-layout.md +24 -0
  80. package/.backlog/tasks/task-47 - sticky-header-in-detail-view.md +43 -0
  81. package/.backlog/tasks/task-48 - footer-hint-line.md +21 -0
  82. package/.backlog/tasks/task-49 - status-styling.md +53 -0
  83. package/.backlog/tasks/task-5 - cli-docs-decisions.md +57 -0
  84. package/.backlog/tasks/task-50 - borders-&-padding.md +22 -0
  85. package/.backlog/tasks/task-51 - code-path-styling.md +23 -0
  86. package/.backlog/tasks/task-52 - cli-filter-tasks-list-by-status-or-assignee.md +29 -0
  87. package/.backlog/tasks/task-6 - cli-packaging.md +65 -0
  88. package/.backlog/tasks/task-6.1 - cli-local-installation-support-for-bunx-npx.md +49 -0
  89. package/.backlog/tasks/task-6.2 - cli-github-actions-for-build-&-publish.md +64 -0
  90. package/.backlog/tasks/task-7 - cli-kanban-view.md +60 -0
  91. package/.backlog/tasks/task-7.1 - cli-kanban-board-detect-remote-task-status.md +62 -0
  92. package/.backlog/tasks/task-8 - gui-project-setup.md +21 -0
  93. package/.backlog/tasks/task-9 - gui-task-crud.md +24 -0
  94. package/.cursorrules +223 -0
  95. package/.gitattributes +2 -0
  96. package/.github/ISSUE_TEMPLATE/bug_report.md +25 -0
  97. package/.github/ISSUE_TEMPLATE/feature_request.md +15 -0
  98. package/.github/PULL_REQUEST_TEMPLATE.md +8 -0
  99. package/.github/workflows/ci.yml +36 -0
  100. package/.husky/pre-commit +1 -0
  101. package/AGENTS.md +65 -0
  102. package/CLAUDE.md +87 -0
  103. package/CONTRIBUTING.md +19 -0
  104. package/DEVELOPMENT.md +37 -0
  105. package/LICENSE +21 -0
  106. package/biome.json +31 -0
  107. package/bun.lock +152 -0
  108. package/cli/.cursorrules-xh86jabm.md +82 -0
  109. package/cli/AGENTS-xh86jabm.md +82 -0
  110. package/cli/CLAUDE-xh86jabm.md +82 -0
  111. package/cli/backlog +0 -0
  112. package/cli/cli.js +19622 -0
  113. package/cli/index.js +2 -0
  114. package/docs/npm-publishing.md +69 -0
  115. package/package.json +47 -0
  116. package/readme.md +97 -0
  117. package/scripts/build.js +73 -0
  118. package/src/agent-instructions.ts +54 -0
  119. package/src/board.ts +263 -0
  120. package/src/cli.ts +806 -0
  121. package/src/constants/index.ts +48 -0
  122. package/src/core/backlog.ts +183 -0
  123. package/src/core/remote-tasks.ts +168 -0
  124. package/src/file-system/operations.ts +515 -0
  125. package/src/git/operations.ts +189 -0
  126. package/src/guidelines/.cursorrules.md +82 -0
  127. package/src/guidelines/AGENTS.md +82 -0
  128. package/src/guidelines/CLAUDE.md +82 -0
  129. package/src/guidelines/index.ts +7 -0
  130. package/src/index.ts +30 -0
  131. package/src/markdown/parser.ts +145 -0
  132. package/src/markdown/serializer.ts +71 -0
  133. package/src/test/agent-instructions.test.ts +62 -0
  134. package/src/test/board.test.ts +291 -0
  135. package/src/test/build.test.ts +28 -0
  136. package/src/test/checklist.test.ts +273 -0
  137. package/src/test/cli.test.ts +1300 -0
  138. package/src/test/code-path.test.ts +204 -0
  139. package/src/test/core.test.ts +330 -0
  140. package/src/test/filesystem.test.ts +435 -0
  141. package/src/test/git.test.ts +26 -0
  142. package/src/test/heading.test.ts +102 -0
  143. package/src/test/line-wrapping.test.ts +252 -0
  144. package/src/test/local-install.test.ts +34 -0
  145. package/src/test/markdown.test.ts +526 -0
  146. package/src/test/parallel-loading.test.ts +160 -0
  147. package/src/test/parent-id-normalization.test.ts +48 -0
  148. package/src/test/remote-id-conflict.test.ts +60 -0
  149. package/src/test/status-icon.test.ts +93 -0
  150. package/src/types/blessed.d.ts +14 -0
  151. package/src/types/index.ts +55 -0
  152. package/src/types/raw.d.ts +4 -0
  153. package/src/ui/board.ts +322 -0
  154. package/src/ui/checklist.ts +103 -0
  155. package/src/ui/code-path.ts +113 -0
  156. package/src/ui/heading.ts +121 -0
  157. package/src/ui/loading.ts +216 -0
  158. package/src/ui/status-icon.ts +53 -0
  159. package/src/ui/task-list.ts +168 -0
  160. package/src/ui/task-viewer.ts +640 -0
  161. package/src/ui/tui.ts +301 -0
  162. package/tsconfig.json +26 -0
@@ -0,0 +1,515 @@
1
+ import { mkdir, unlink } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { DEFAULT_DIRECTORIES, DEFAULT_FILES, DEFAULT_STATUSES } from "../constants/index.ts";
5
+ import { parseDecisionLog, parseDocument, parseTask } from "../markdown/parser.ts";
6
+ import { serializeDecisionLog, serializeDocument, serializeTask } from "../markdown/serializer.ts";
7
+ import type { BacklogConfig, DecisionLog, Document, Task } from "../types/index.ts";
8
+
9
+ export class FileSystem {
10
+ private backlogDir: string;
11
+ private projectRoot: string;
12
+
13
+ constructor(projectRoot: string) {
14
+ this.projectRoot = projectRoot;
15
+ this.backlogDir = join(projectRoot, DEFAULT_DIRECTORIES.BACKLOG);
16
+ }
17
+
18
+ // Public accessors for directory paths
19
+ get tasksDir(): string {
20
+ return join(this.backlogDir, DEFAULT_DIRECTORIES.TASKS);
21
+ }
22
+
23
+ get draftsDir(): string {
24
+ return join(this.backlogDir, DEFAULT_DIRECTORIES.DRAFTS);
25
+ }
26
+
27
+ get archiveTasksDir(): string {
28
+ return join(this.backlogDir, DEFAULT_DIRECTORIES.ARCHIVE_TASKS);
29
+ }
30
+
31
+ get archiveDraftsDir(): string {
32
+ return join(this.backlogDir, DEFAULT_DIRECTORIES.ARCHIVE_DRAFTS);
33
+ }
34
+
35
+ get decisionsDir(): string {
36
+ return join(this.backlogDir, DEFAULT_DIRECTORIES.DECISIONS);
37
+ }
38
+
39
+ get docsDir(): string {
40
+ return join(this.backlogDir, DEFAULT_DIRECTORIES.DOCS);
41
+ }
42
+
43
+ async ensureBacklogStructure(): Promise<void> {
44
+ const directories = [
45
+ this.backlogDir,
46
+ join(this.backlogDir, DEFAULT_DIRECTORIES.TASKS),
47
+ join(this.backlogDir, DEFAULT_DIRECTORIES.DRAFTS),
48
+ join(this.backlogDir, DEFAULT_DIRECTORIES.ARCHIVE_TASKS),
49
+ join(this.backlogDir, DEFAULT_DIRECTORIES.ARCHIVE_DRAFTS),
50
+ join(this.backlogDir, DEFAULT_DIRECTORIES.DOCS),
51
+ join(this.backlogDir, DEFAULT_DIRECTORIES.DECISIONS),
52
+ ];
53
+
54
+ for (const dir of directories) {
55
+ await mkdir(dir, { recursive: true });
56
+ }
57
+ }
58
+
59
+ // Task operations
60
+ async saveTask(task: Task): Promise<void> {
61
+ const taskId = task.id.startsWith("task-") ? task.id : `task-${task.id}`;
62
+ const filename = `${taskId} - ${this.sanitizeFilename(task.title)}.md`;
63
+ const filepath = join(this.tasksDir, filename);
64
+ const content = serializeTask(task);
65
+
66
+ // Delete any existing task files with the same ID but different filenames
67
+ try {
68
+ const files = await Array.fromAsync(new Bun.Glob("*.md").scan({ cwd: this.tasksDir }));
69
+ const normalizedTaskId = taskId;
70
+ const existingFiles = files.filter((file) => file.startsWith(`${normalizedTaskId} -`) && file !== filename);
71
+
72
+ for (const existingFile of existingFiles) {
73
+ const existingPath = join(this.tasksDir, existingFile);
74
+ await unlink(existingPath);
75
+ }
76
+ } catch {
77
+ // Ignore errors if no existing files found
78
+ }
79
+
80
+ await this.ensureDirectoryExists(dirname(filepath));
81
+ await Bun.write(filepath, content);
82
+ }
83
+
84
+ async loadTask(taskId: string): Promise<Task | null> {
85
+ try {
86
+ const files = await Array.fromAsync(new Bun.Glob("*.md").scan({ cwd: this.tasksDir }));
87
+ const normalizedTaskId = taskId.startsWith("task-") ? taskId : `task-${taskId}`;
88
+ const taskFile = files.find((file) => file.startsWith(`${normalizedTaskId} -`));
89
+
90
+ if (!taskFile) return null;
91
+
92
+ const filepath = join(this.tasksDir, taskFile);
93
+ const content = await Bun.file(filepath).text();
94
+ return parseTask(content);
95
+ } catch (error) {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ async listTasks(): Promise<Task[]> {
101
+ try {
102
+ const taskFiles = await Array.fromAsync(new Bun.Glob("task-*.md").scan({ cwd: this.tasksDir }));
103
+
104
+ const tasks: Task[] = [];
105
+ for (const file of taskFiles) {
106
+ const filepath = join(this.tasksDir, file);
107
+ const content = await Bun.file(filepath).text();
108
+ tasks.push(parseTask(content));
109
+ }
110
+
111
+ return tasks.sort((a, b) => a.id.localeCompare(b.id));
112
+ } catch (error) {
113
+ return [];
114
+ }
115
+ }
116
+
117
+ async listTasksWithMetadata(): Promise<Array<Task & { lastModified?: Date }>> {
118
+ try {
119
+ const taskFiles = await Array.fromAsync(new Bun.Glob("task-*.md").scan({ cwd: this.tasksDir }));
120
+
121
+ const tasks: Array<Task & { lastModified?: Date }> = [];
122
+ for (const file of taskFiles) {
123
+ const filepath = join(this.tasksDir, file);
124
+ const bunFile = Bun.file(filepath);
125
+ const content = await bunFile.text();
126
+ const task = parseTask(content);
127
+
128
+ // Get file stats for modification time
129
+ const stats = await bunFile.stat();
130
+ tasks.push({
131
+ ...task,
132
+ lastModified: new Date(stats.mtime),
133
+ });
134
+ }
135
+
136
+ return tasks.sort((a, b) => a.id.localeCompare(b.id));
137
+ } catch (error) {
138
+ return [];
139
+ }
140
+ }
141
+
142
+ async archiveTask(taskId: string): Promise<boolean> {
143
+ try {
144
+ const sourceFiles = await Array.fromAsync(new Bun.Glob("*.md").scan({ cwd: this.tasksDir }));
145
+ const normalizedTaskId = taskId.startsWith("task-") ? taskId : `task-${taskId}`;
146
+ const taskFile = sourceFiles.find((file) => file.startsWith(`${normalizedTaskId} -`));
147
+
148
+ if (!taskFile) return false;
149
+
150
+ const sourcePath = join(this.tasksDir, taskFile);
151
+ const targetPath = join(this.archiveTasksDir, taskFile);
152
+
153
+ // Read source file
154
+ const content = await Bun.file(sourcePath).text();
155
+
156
+ // Write to target and ensure directory exists
157
+ await this.ensureDirectoryExists(dirname(targetPath));
158
+ await Bun.write(targetPath, content);
159
+
160
+ // Remove source file
161
+ await unlink(sourcePath);
162
+
163
+ return true;
164
+ } catch (error) {
165
+ return false;
166
+ }
167
+ }
168
+
169
+ async archiveDraft(taskId: string): Promise<boolean> {
170
+ try {
171
+ const sourceFiles = await Array.fromAsync(new Bun.Glob("*.md").scan({ cwd: this.draftsDir }));
172
+ const normalizedTaskId = taskId.startsWith("task-") ? taskId : `task-${taskId}`;
173
+ const taskFile = sourceFiles.find((file) => file.startsWith(`${normalizedTaskId} -`));
174
+
175
+ if (!taskFile) return false;
176
+
177
+ const sourcePath = join(this.draftsDir, taskFile);
178
+ const targetPath = join(this.archiveDraftsDir, taskFile);
179
+
180
+ const content = await Bun.file(sourcePath).text();
181
+ await this.ensureDirectoryExists(dirname(targetPath));
182
+ await Bun.write(targetPath, content);
183
+
184
+ await unlink(sourcePath);
185
+
186
+ return true;
187
+ } catch {
188
+ return false;
189
+ }
190
+ }
191
+
192
+ async promoteDraft(taskId: string): Promise<boolean> {
193
+ try {
194
+ const sourceFiles = await Array.fromAsync(new Bun.Glob("*.md").scan({ cwd: this.draftsDir }));
195
+ const normalizedTaskId = taskId.startsWith("task-") ? taskId : `task-${taskId}`;
196
+ const taskFile = sourceFiles.find((file) => file.startsWith(`${normalizedTaskId} -`));
197
+
198
+ if (!taskFile) return false;
199
+
200
+ const sourcePath = join(this.draftsDir, taskFile);
201
+ const targetPath = join(this.tasksDir, taskFile);
202
+
203
+ const content = await Bun.file(sourcePath).text();
204
+ await this.ensureDirectoryExists(dirname(targetPath));
205
+ await Bun.write(targetPath, content);
206
+
207
+ await unlink(sourcePath);
208
+
209
+ return true;
210
+ } catch {
211
+ return false;
212
+ }
213
+ }
214
+
215
+ async demoteTask(taskId: string): Promise<boolean> {
216
+ try {
217
+ const sourceFiles = await Array.fromAsync(new Bun.Glob("*.md").scan({ cwd: this.tasksDir }));
218
+ const normalizedTaskId = taskId.startsWith("task-") ? taskId : `task-${taskId}`;
219
+ const taskFile = sourceFiles.find((file) => file.startsWith(`${normalizedTaskId} -`));
220
+
221
+ if (!taskFile) return false;
222
+
223
+ const sourcePath = join(this.tasksDir, taskFile);
224
+ const targetPath = join(this.draftsDir, taskFile);
225
+
226
+ const content = await Bun.file(sourcePath).text();
227
+ await this.ensureDirectoryExists(dirname(targetPath));
228
+ await Bun.write(targetPath, content);
229
+
230
+ await unlink(sourcePath);
231
+
232
+ return true;
233
+ } catch {
234
+ return false;
235
+ }
236
+ }
237
+
238
+ // Draft operations
239
+ async saveDraft(task: Task): Promise<void> {
240
+ const taskId = task.id.startsWith("task-") ? task.id : `task-${task.id}`;
241
+ const filename = `${taskId} - ${this.sanitizeFilename(task.title)}.md`;
242
+ const filepath = join(this.draftsDir, filename);
243
+ const content = serializeTask(task);
244
+
245
+ try {
246
+ const files = await Array.fromAsync(new Bun.Glob("*.md").scan({ cwd: this.draftsDir }));
247
+ const existingFiles = files.filter((file) => file.startsWith(`${taskId} -`) && file !== filename);
248
+
249
+ for (const existingFile of existingFiles) {
250
+ const existingPath = join(this.draftsDir, existingFile);
251
+ await unlink(existingPath);
252
+ }
253
+ } catch {
254
+ // Ignore errors if no existing files found
255
+ }
256
+
257
+ await this.ensureDirectoryExists(dirname(filepath));
258
+ await Bun.write(filepath, content);
259
+ }
260
+
261
+ async loadDraft(taskId: string): Promise<Task | null> {
262
+ try {
263
+ const files = await Array.fromAsync(new Bun.Glob("*.md").scan({ cwd: this.draftsDir }));
264
+ const normalizedTaskId = taskId.startsWith("task-") ? taskId : `task-${taskId}`;
265
+ const taskFile = files.find((file) => file.startsWith(`${normalizedTaskId} -`));
266
+
267
+ if (!taskFile) return null;
268
+
269
+ const filepath = join(this.draftsDir, taskFile);
270
+ const content = await Bun.file(filepath).text();
271
+ return parseTask(content);
272
+ } catch {
273
+ return null;
274
+ }
275
+ }
276
+
277
+ async listDrafts(): Promise<Task[]> {
278
+ try {
279
+ const taskFiles = await Array.fromAsync(new Bun.Glob("task-*.md").scan({ cwd: this.draftsDir }));
280
+
281
+ const tasks: Task[] = [];
282
+ for (const file of taskFiles) {
283
+ const filepath = join(this.draftsDir, file);
284
+ const content = await Bun.file(filepath).text();
285
+ tasks.push(parseTask(content));
286
+ }
287
+
288
+ return tasks.sort((a, b) => a.id.localeCompare(b.id));
289
+ } catch {
290
+ return [];
291
+ }
292
+ }
293
+
294
+ // Decision log operations
295
+ async saveDecisionLog(decision: DecisionLog): Promise<void> {
296
+ const filename = `decision-${decision.id} - ${this.sanitizeFilename(decision.title)}.md`;
297
+ const filepath = join(this.decisionsDir, filename);
298
+ const content = serializeDecisionLog(decision);
299
+
300
+ await this.ensureDirectoryExists(dirname(filepath));
301
+ await Bun.write(filepath, content);
302
+ }
303
+
304
+ async loadDecisionLog(decisionId: string): Promise<DecisionLog | null> {
305
+ try {
306
+ const files = await Array.fromAsync(new Bun.Glob("*.md").scan({ cwd: this.decisionsDir }));
307
+ const decisionFile = files.find((file) => file.startsWith(`decision-${decisionId} -`));
308
+
309
+ if (!decisionFile) return null;
310
+
311
+ const filepath = join(this.decisionsDir, decisionFile);
312
+ const content = await Bun.file(filepath).text();
313
+ return parseDecisionLog(content);
314
+ } catch (error) {
315
+ return null;
316
+ }
317
+ }
318
+
319
+ // Document operations
320
+ async saveDocument(document: Document, subPath = ""): Promise<void> {
321
+ const dir = join(this.docsDir, subPath);
322
+ const filename = `${this.sanitizeFilename(document.title)}.md`;
323
+ const filepath = join(dir, filename);
324
+ const content = serializeDocument(document);
325
+
326
+ await this.ensureDirectoryExists(dirname(filepath));
327
+ await Bun.write(filepath, content);
328
+ }
329
+
330
+ async listDecisionLogs(): Promise<DecisionLog[]> {
331
+ try {
332
+ const decisionFiles = await Array.fromAsync(new Bun.Glob("decision-*.md").scan({ cwd: this.decisionsDir }));
333
+ const decisions: DecisionLog[] = [];
334
+ for (const file of decisionFiles) {
335
+ const filepath = join(this.decisionsDir, file);
336
+ const content = await Bun.file(filepath).text();
337
+ decisions.push(parseDecisionLog(content));
338
+ }
339
+ return decisions.sort((a, b) => a.id.localeCompare(b.id));
340
+ } catch {
341
+ return [];
342
+ }
343
+ }
344
+
345
+ async listDocuments(): Promise<Document[]> {
346
+ try {
347
+ const docFiles = await Array.fromAsync(new Bun.Glob("*.md").scan({ cwd: this.docsDir }));
348
+ const docs: Document[] = [];
349
+ for (const file of docFiles) {
350
+ const filepath = join(this.docsDir, file);
351
+ const content = await Bun.file(filepath).text();
352
+ docs.push(parseDocument(content));
353
+ }
354
+ return docs.sort((a, b) => a.title.localeCompare(b.title));
355
+ } catch {
356
+ return [];
357
+ }
358
+ }
359
+
360
+ // Config operations
361
+ async loadConfig(): Promise<BacklogConfig | null> {
362
+ try {
363
+ const configPath = join(this.backlogDir, DEFAULT_FILES.CONFIG);
364
+ const content = await Bun.file(configPath).text();
365
+ return this.parseConfig(content);
366
+ } catch (error) {
367
+ return null;
368
+ }
369
+ }
370
+
371
+ async saveConfig(config: BacklogConfig): Promise<void> {
372
+ const configPath = join(this.backlogDir, DEFAULT_FILES.CONFIG);
373
+ const content = this.serializeConfig(config);
374
+ await Bun.write(configPath, content);
375
+ }
376
+
377
+ async getUserSetting(key: string, global = false): Promise<string | undefined> {
378
+ const settings = await this.loadUserSettings(global);
379
+ return settings ? settings[key] : undefined;
380
+ }
381
+
382
+ async setUserSetting(key: string, value: string, global = false): Promise<void> {
383
+ const settings = (await this.loadUserSettings(global)) || {};
384
+ settings[key] = value;
385
+ await this.saveUserSettings(settings, global);
386
+ }
387
+
388
+ private async loadUserSettings(global = false): Promise<Record<string, string> | null> {
389
+ const filePath = global
390
+ ? join(homedir(), ".backlog", DEFAULT_FILES.USER)
391
+ : join(this.projectRoot, DEFAULT_FILES.USER);
392
+ try {
393
+ const content = await Bun.file(filePath).text();
394
+ const result: Record<string, string> = {};
395
+ for (const line of content.split(/\r?\n/)) {
396
+ const trimmed = line.trim();
397
+ if (!trimmed || trimmed.startsWith("#")) continue;
398
+ const idx = trimmed.indexOf(":");
399
+ if (idx === -1) continue;
400
+ const k = trimmed.substring(0, idx).trim();
401
+ const v = trimmed
402
+ .substring(idx + 1)
403
+ .trim()
404
+ .replace(/^['"]|['"]$/g, "");
405
+ result[k] = v;
406
+ }
407
+ return result;
408
+ } catch {
409
+ return null;
410
+ }
411
+ }
412
+
413
+ private async saveUserSettings(settings: Record<string, string>, global = false): Promise<void> {
414
+ const filePath = global
415
+ ? join(homedir(), ".backlog", DEFAULT_FILES.USER)
416
+ : join(this.projectRoot, DEFAULT_FILES.USER);
417
+ await this.ensureDirectoryExists(dirname(filePath));
418
+ const lines = Object.entries(settings).map(([k, v]) => `${k}: ${v}`);
419
+ await Bun.write(filePath, `${lines.join("\n")}\n`);
420
+ }
421
+
422
+ // Utility methods
423
+ private sanitizeFilename(filename: string): string {
424
+ return filename
425
+ .replace(/[<>:"/\\|?*]/g, "-")
426
+ .replace(/\s+/g, "-")
427
+ .replace(/-+/g, "-")
428
+ .replace(/^-|-$/g, "")
429
+ .toLowerCase();
430
+ }
431
+
432
+ private async ensureDirectoryExists(dirPath: string): Promise<void> {
433
+ try {
434
+ await mkdir(dirPath, { recursive: true });
435
+ } catch (error) {
436
+ // Directory creation failed, ignore
437
+ }
438
+ }
439
+
440
+ private parseConfig(content: string): BacklogConfig {
441
+ const config: Partial<BacklogConfig> = {};
442
+ const lines = content.split("\n");
443
+
444
+ for (const line of lines) {
445
+ const trimmed = line.trim();
446
+ if (!trimmed || trimmed.startsWith("#")) continue;
447
+
448
+ const colonIndex = trimmed.indexOf(":");
449
+ if (colonIndex === -1) continue;
450
+
451
+ const key = trimmed.substring(0, colonIndex).trim();
452
+ const value = trimmed.substring(colonIndex + 1).trim();
453
+
454
+ switch (key) {
455
+ case "project_name":
456
+ config.projectName = value.replace(/['"]/g, "");
457
+ break;
458
+ case "default_assignee":
459
+ config.defaultAssignee = value.replace(/['"]/g, "");
460
+ break;
461
+ case "default_reporter":
462
+ config.defaultReporter = value.replace(/['"]/g, "");
463
+ break;
464
+ case "default_status":
465
+ config.defaultStatus = value.replace(/['"]/g, "");
466
+ break;
467
+ case "statuses":
468
+ case "labels":
469
+ case "milestones":
470
+ if (value.startsWith("[") && value.endsWith("]")) {
471
+ const arrayContent = value.slice(1, -1);
472
+ config[key] = arrayContent
473
+ .split(",")
474
+ .map((item) => item.trim().replace(/['"]/g, ""))
475
+ .filter(Boolean);
476
+ }
477
+ break;
478
+ case "date_format":
479
+ config.dateFormat = value.replace(/['"]/g, "");
480
+ break;
481
+ case "max_column_width":
482
+ config.maxColumnWidth = Number.parseInt(value, 10);
483
+ break;
484
+ }
485
+ }
486
+
487
+ return {
488
+ projectName: config.projectName || "",
489
+ defaultAssignee: config.defaultAssignee,
490
+ defaultReporter: config.defaultReporter,
491
+ statuses: config.statuses || [...DEFAULT_STATUSES],
492
+ labels: config.labels || [],
493
+ milestones: config.milestones || [],
494
+ defaultStatus: config.defaultStatus,
495
+ dateFormat: config.dateFormat || "yyyy-mm-dd",
496
+ maxColumnWidth: config.maxColumnWidth,
497
+ };
498
+ }
499
+
500
+ private serializeConfig(config: BacklogConfig): string {
501
+ const lines = [
502
+ `project_name: "${config.projectName}"`,
503
+ ...(config.defaultAssignee ? [`default_assignee: "${config.defaultAssignee}"`] : []),
504
+ ...(config.defaultReporter ? [`default_reporter: "${config.defaultReporter}"`] : []),
505
+ ...(config.defaultStatus ? [`default_status: "${config.defaultStatus}"`] : []),
506
+ `statuses: [${config.statuses.map((s) => `"${s}"`).join(", ")}]`,
507
+ `labels: [${config.labels.map((l) => `"${l}"`).join(", ")}]`,
508
+ `milestones: [${config.milestones.map((m) => `"${m}"`).join(", ")}]`,
509
+ `date_format: ${config.dateFormat}`,
510
+ ...(config.maxColumnWidth ? [`max_column_width: ${config.maxColumnWidth}`] : []),
511
+ ];
512
+
513
+ return `${lines.join("\n")}\n`;
514
+ }
515
+ }
@@ -0,0 +1,189 @@
1
+ export class GitOperations {
2
+ private projectRoot: string;
3
+
4
+ constructor(projectRoot: string) {
5
+ this.projectRoot = projectRoot;
6
+ }
7
+
8
+ async addFile(filePath: string): Promise<void> {
9
+ await this.execGit(["add", filePath]);
10
+ }
11
+
12
+ async addFiles(filePaths: string[]): Promise<void> {
13
+ await this.execGit(["add", ...filePaths]);
14
+ }
15
+
16
+ async commitTaskChange(taskId: string, message: string): Promise<void> {
17
+ const commitMessage = `${taskId} - ${message}`;
18
+ await this.execGit(["commit", "-m", commitMessage]);
19
+ }
20
+
21
+ async commitChanges(message: string): Promise<void> {
22
+ await this.execGit(["commit", "-m", message]);
23
+ }
24
+
25
+ async getStatus(): Promise<string> {
26
+ const { stdout } = await this.execGit(["status", "--porcelain"]);
27
+ return stdout;
28
+ }
29
+
30
+ async isClean(): Promise<boolean> {
31
+ const status = await this.getStatus();
32
+ return status.trim() === "";
33
+ }
34
+
35
+ async getCurrentBranch(): Promise<string> {
36
+ const { stdout } = await this.execGit(["branch", "--show-current"]);
37
+ return stdout.trim();
38
+ }
39
+
40
+ async createBranch(branchName: string): Promise<void> {
41
+ await this.execGit(["checkout", "-b", branchName]);
42
+ }
43
+
44
+ async switchBranch(branchName: string): Promise<void> {
45
+ await this.execGit(["checkout", branchName]);
46
+ }
47
+
48
+ async hasUncommittedChanges(): Promise<boolean> {
49
+ const status = await this.getStatus();
50
+ return status.trim() !== "";
51
+ }
52
+
53
+ async getLastCommitMessage(): Promise<string> {
54
+ const { stdout } = await this.execGit(["log", "-1", "--pretty=format:%s"]);
55
+ return stdout.trim();
56
+ }
57
+
58
+ async fetch(remote = "origin"): Promise<void> {
59
+ await this.execGit(["fetch", remote]);
60
+ }
61
+
62
+ async listFilesInRemoteBranch(branch: string, path: string): Promise<string[]> {
63
+ const { stdout } = await this.execGit(["ls-tree", "-r", `origin/${branch}`, "--name-only", "--", path]);
64
+ return stdout
65
+ .split(/\r?\n/)
66
+ .map((l) => l.trim())
67
+ .filter(Boolean);
68
+ }
69
+
70
+ async addAndCommitTaskFile(taskId: string, filePath: string, action: "create" | "update" | "archive"): Promise<void> {
71
+ await this.addFile(filePath);
72
+
73
+ const actionMessages = {
74
+ create: `Create task ${taskId}`,
75
+ update: `Update task ${taskId}`,
76
+ archive: `Archive task ${taskId}`,
77
+ };
78
+
79
+ await this.commitTaskChange(taskId, actionMessages[action]);
80
+ }
81
+
82
+ async stageBacklogDirectory(): Promise<void> {
83
+ await this.execGit(["add", ".backlog/"]);
84
+ }
85
+
86
+ async commitBacklogChanges(message: string): Promise<void> {
87
+ await this.stageBacklogDirectory();
88
+
89
+ const hasChanges = !(await this.isClean());
90
+ if (hasChanges) {
91
+ await this.commitChanges(`backlog: ${message}`);
92
+ }
93
+ }
94
+
95
+ async listRemoteBranches(remote = "origin"): Promise<string[]> {
96
+ const { stdout } = await this.execGit(["branch", "-r", "--format=%(refname:strip=2)"]);
97
+ return stdout
98
+ .split("\n")
99
+ .map((l) => l.trim())
100
+ .filter((b) => b.startsWith(`${remote}/`))
101
+ .map((b) => b.replace(`${remote}/`, ""))
102
+ .filter(Boolean);
103
+ }
104
+
105
+ async listFilesInTree(ref: string, path: string): Promise<string[]> {
106
+ const { stdout } = await this.execGit(["ls-tree", "-r", "--name-only", ref, "--", path]);
107
+ return stdout
108
+ .split("\n")
109
+ .map((l) => l.trim())
110
+ .filter(Boolean);
111
+ }
112
+
113
+ async showFile(ref: string, filePath: string): Promise<string> {
114
+ const { stdout } = await this.execGit(["show", `${ref}:${filePath}`]);
115
+ return stdout;
116
+ }
117
+
118
+ async getFileLastModifiedTime(ref: string, filePath: string): Promise<Date | null> {
119
+ try {
120
+ // Get the last commit that modified this file in the given ref
121
+ const { stdout } = await this.execGit([
122
+ "log",
123
+ "-1",
124
+ "--format=%aI", // Author date in ISO 8601 format
125
+ ref,
126
+ "--",
127
+ filePath,
128
+ ]);
129
+ const timestamp = stdout.trim();
130
+ if (timestamp) {
131
+ return new Date(timestamp);
132
+ }
133
+ return null;
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ private async execGit(args: string[]): Promise<{ stdout: string; stderr: string }> {
140
+ try {
141
+ const proc = Bun.spawn(["git", ...args], {
142
+ cwd: this.projectRoot,
143
+ stdout: "pipe",
144
+ stderr: "pipe",
145
+ });
146
+
147
+ const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
148
+
149
+ const exitCode = await proc.exited;
150
+
151
+ if (exitCode !== 0) {
152
+ throw new Error(`Git command failed (exit code ${exitCode}): git ${args.join(" ")}\n${stderr}`);
153
+ }
154
+
155
+ return { stdout, stderr };
156
+ } catch (error: unknown) {
157
+ const message = error instanceof Error ? error.message : String(error);
158
+ throw new Error(`Git command failed: git ${args.join(" ")}\n${message}`);
159
+ }
160
+ }
161
+ }
162
+
163
+ export async function isGitRepository(projectRoot: string): Promise<boolean> {
164
+ try {
165
+ const proc = Bun.spawn(["git", "rev-parse", "--git-dir"], {
166
+ cwd: projectRoot,
167
+ stdout: "pipe",
168
+ stderr: "pipe",
169
+ });
170
+ const exitCode = await proc.exited;
171
+ return exitCode === 0;
172
+ } catch {
173
+ return false;
174
+ }
175
+ }
176
+
177
+ export async function initializeGitRepository(projectRoot: string): Promise<void> {
178
+ const proc = Bun.spawn(["git", "init"], {
179
+ cwd: projectRoot,
180
+ stdout: "pipe",
181
+ stderr: "pipe",
182
+ });
183
+
184
+ const exitCode = await proc.exited;
185
+ if (exitCode !== 0) {
186
+ const stderr = await new Response(proc.stderr).text();
187
+ throw new Error(`Failed to initialize git repository: ${stderr}`);
188
+ }
189
+ }