tmux-team 1.0.0 → 2.0.0-alpha.1
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/README.md +197 -24
- package/bin/tmux-team +31 -430
- package/package.json +26 -6
- package/src/cli.ts +212 -0
- package/src/commands/add.ts +38 -0
- package/src/commands/check.ts +34 -0
- package/src/commands/completion.ts +118 -0
- package/src/commands/help.ts +51 -0
- package/src/commands/init.ts +24 -0
- package/src/commands/list.ts +27 -0
- package/src/commands/remove.ts +25 -0
- package/src/commands/talk.test.ts +652 -0
- package/src/commands/talk.ts +261 -0
- package/src/commands/update.ts +47 -0
- package/src/config.test.ts +246 -0
- package/src/config.ts +159 -0
- package/src/context.ts +38 -0
- package/src/exits.ts +14 -0
- package/src/pm/commands.test.ts +158 -0
- package/src/pm/commands.ts +654 -0
- package/src/pm/manager.test.ts +377 -0
- package/src/pm/manager.ts +140 -0
- package/src/pm/storage/adapter.ts +55 -0
- package/src/pm/storage/fs.test.ts +384 -0
- package/src/pm/storage/fs.ts +255 -0
- package/src/pm/storage/github.ts +751 -0
- package/src/pm/types.ts +79 -0
- package/src/state.test.ts +311 -0
- package/src/state.ts +83 -0
- package/src/tmux.test.ts +205 -0
- package/src/tmux.ts +27 -0
- package/src/types.ts +86 -0
- package/src/ui.ts +67 -0
- package/src/version.ts +21 -0
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// FS Storage Adapter Tests
|
|
3
|
+
// ─────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import os from 'os';
|
|
9
|
+
import { FSAdapter, createFSAdapter } from './fs.js';
|
|
10
|
+
|
|
11
|
+
describe('FSAdapter', () => {
|
|
12
|
+
let testDir: string;
|
|
13
|
+
let adapter: FSAdapter;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
// Create a unique temp directory for each test
|
|
17
|
+
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tmux-team-test-'));
|
|
18
|
+
adapter = new FSAdapter(testDir);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
// Clean up temp directory
|
|
23
|
+
if (fs.existsSync(testDir)) {
|
|
24
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('Team Operations', () => {
|
|
29
|
+
it('creates team directory structure on initTeam', async () => {
|
|
30
|
+
await adapter.initTeam('Test Project');
|
|
31
|
+
|
|
32
|
+
expect(fs.existsSync(testDir)).toBe(true);
|
|
33
|
+
expect(fs.existsSync(path.join(testDir, 'milestones'))).toBe(true);
|
|
34
|
+
expect(fs.existsSync(path.join(testDir, 'tasks'))).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('writes team.json with id, name, createdAt', async () => {
|
|
38
|
+
const team = await adapter.initTeam('My Project', 'window-1');
|
|
39
|
+
|
|
40
|
+
expect(team.name).toBe('My Project');
|
|
41
|
+
expect(team.windowId).toBe('window-1');
|
|
42
|
+
expect(team.id).toBe(path.basename(testDir));
|
|
43
|
+
expect(team.createdAt).toBeDefined();
|
|
44
|
+
|
|
45
|
+
// Verify file contents
|
|
46
|
+
const teamFile = path.join(testDir, 'team.json');
|
|
47
|
+
const saved = JSON.parse(fs.readFileSync(teamFile, 'utf-8'));
|
|
48
|
+
expect(saved.name).toBe('My Project');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('returns team data from team.json', async () => {
|
|
52
|
+
await adapter.initTeam('Test Project');
|
|
53
|
+
const team = await adapter.getTeam();
|
|
54
|
+
|
|
55
|
+
expect(team).not.toBeNull();
|
|
56
|
+
expect(team?.name).toBe('Test Project');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('returns null when team.json does not exist', async () => {
|
|
60
|
+
const team = await adapter.getTeam();
|
|
61
|
+
expect(team).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('updates team data', async () => {
|
|
65
|
+
await adapter.initTeam('Original Name');
|
|
66
|
+
const updated = await adapter.updateTeam({ name: 'New Name' });
|
|
67
|
+
|
|
68
|
+
expect(updated.name).toBe('New Name');
|
|
69
|
+
|
|
70
|
+
// Verify persisted
|
|
71
|
+
const team = await adapter.getTeam();
|
|
72
|
+
expect(team?.name).toBe('New Name');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('throws error when updating non-existent team', async () => {
|
|
76
|
+
await expect(adapter.updateTeam({ name: 'New' })).rejects.toThrow('Team not initialized');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('Milestone Operations', () => {
|
|
81
|
+
beforeEach(async () => {
|
|
82
|
+
await adapter.initTeam('Test Project');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('creates milestone with auto-incremented ID', async () => {
|
|
86
|
+
const m1 = await adapter.createMilestone({ name: 'Phase 1' });
|
|
87
|
+
const m2 = await adapter.createMilestone({ name: 'Phase 2' });
|
|
88
|
+
const m3 = await adapter.createMilestone({ name: 'Phase 3' });
|
|
89
|
+
|
|
90
|
+
expect(m1.id).toBe('1');
|
|
91
|
+
expect(m2.id).toBe('2');
|
|
92
|
+
expect(m3.id).toBe('3');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('writes milestone to milestones/<id>.json', async () => {
|
|
96
|
+
const milestone = await adapter.createMilestone({ name: 'MVP' });
|
|
97
|
+
const filePath = path.join(testDir, 'milestones', `${milestone.id}.json`);
|
|
98
|
+
|
|
99
|
+
expect(fs.existsSync(filePath)).toBe(true);
|
|
100
|
+
const saved = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
101
|
+
expect(saved.name).toBe('MVP');
|
|
102
|
+
expect(saved.status).toBe('pending');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('lists all milestones sorted by ID', async () => {
|
|
106
|
+
await adapter.createMilestone({ name: 'Phase 3' });
|
|
107
|
+
await adapter.createMilestone({ name: 'Phase 1' });
|
|
108
|
+
await adapter.createMilestone({ name: 'Phase 2' });
|
|
109
|
+
|
|
110
|
+
const milestones = await adapter.listMilestones();
|
|
111
|
+
|
|
112
|
+
expect(milestones).toHaveLength(3);
|
|
113
|
+
expect(milestones[0].id).toBe('1');
|
|
114
|
+
expect(milestones[1].id).toBe('2');
|
|
115
|
+
expect(milestones[2].id).toBe('3');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('returns milestone by ID', async () => {
|
|
119
|
+
await adapter.createMilestone({ name: 'Test' });
|
|
120
|
+
const milestone = await adapter.getMilestone('1');
|
|
121
|
+
|
|
122
|
+
expect(milestone).not.toBeNull();
|
|
123
|
+
expect(milestone?.name).toBe('Test');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('returns null for non-existent milestone', async () => {
|
|
127
|
+
const milestone = await adapter.getMilestone('999');
|
|
128
|
+
expect(milestone).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('updates milestone status', async () => {
|
|
132
|
+
await adapter.createMilestone({ name: 'Test' });
|
|
133
|
+
const updated = await adapter.updateMilestone('1', { status: 'done' });
|
|
134
|
+
|
|
135
|
+
expect(updated.status).toBe('done');
|
|
136
|
+
|
|
137
|
+
const milestone = await adapter.getMilestone('1');
|
|
138
|
+
expect(milestone?.status).toBe('done');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('updates milestone updatedAt on change', async () => {
|
|
142
|
+
const original = await adapter.createMilestone({ name: 'Test' });
|
|
143
|
+
const originalUpdatedAt = original.updatedAt;
|
|
144
|
+
|
|
145
|
+
// Small delay to ensure different timestamp
|
|
146
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
147
|
+
|
|
148
|
+
const updated = await adapter.updateMilestone('1', { name: 'Updated' });
|
|
149
|
+
expect(updated.updatedAt).not.toBe(originalUpdatedAt);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('throws error when updating non-existent milestone', async () => {
|
|
153
|
+
await expect(adapter.updateMilestone('999', { name: 'New' })).rejects.toThrow(
|
|
154
|
+
'Milestone 999 not found'
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('deletes milestone', async () => {
|
|
159
|
+
await adapter.createMilestone({ name: 'Test' });
|
|
160
|
+
await adapter.deleteMilestone('1');
|
|
161
|
+
|
|
162
|
+
const milestone = await adapter.getMilestone('1');
|
|
163
|
+
expect(milestone).toBeNull();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('Task Operations', () => {
|
|
168
|
+
beforeEach(async () => {
|
|
169
|
+
await adapter.initTeam('Test Project');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('creates task with auto-incremented ID', async () => {
|
|
173
|
+
const t1 = await adapter.createTask({ title: 'Task 1' });
|
|
174
|
+
const t2 = await adapter.createTask({ title: 'Task 2' });
|
|
175
|
+
|
|
176
|
+
expect(t1.id).toBe('1');
|
|
177
|
+
expect(t2.id).toBe('2');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('writes task to tasks/<id>.json', async () => {
|
|
181
|
+
const task = await adapter.createTask({ title: 'Implement feature' });
|
|
182
|
+
const filePath = path.join(testDir, 'tasks', `${task.id}.json`);
|
|
183
|
+
|
|
184
|
+
expect(fs.existsSync(filePath)).toBe(true);
|
|
185
|
+
const saved = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
186
|
+
expect(saved.title).toBe('Implement feature');
|
|
187
|
+
expect(saved.status).toBe('pending');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('creates markdown doc file for task', async () => {
|
|
191
|
+
const task = await adapter.createTask({ title: 'My Task' });
|
|
192
|
+
const docPath = path.join(testDir, 'tasks', `${task.id}.md`);
|
|
193
|
+
|
|
194
|
+
expect(fs.existsSync(docPath)).toBe(true);
|
|
195
|
+
const content = fs.readFileSync(docPath, 'utf-8');
|
|
196
|
+
expect(content).toContain('# My Task');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('creates task with milestone and assignee', async () => {
|
|
200
|
+
await adapter.createMilestone({ name: 'Phase 1' });
|
|
201
|
+
const task = await adapter.createTask({
|
|
202
|
+
title: 'Test',
|
|
203
|
+
milestone: '1',
|
|
204
|
+
assignee: 'claude',
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
expect(task.milestone).toBe('1');
|
|
208
|
+
expect(task.assignee).toBe('claude');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('lists all tasks sorted by ID', async () => {
|
|
212
|
+
await adapter.createTask({ title: 'Third' });
|
|
213
|
+
await adapter.createTask({ title: 'First' });
|
|
214
|
+
await adapter.createTask({ title: 'Second' });
|
|
215
|
+
|
|
216
|
+
const tasks = await adapter.listTasks();
|
|
217
|
+
|
|
218
|
+
expect(tasks).toHaveLength(3);
|
|
219
|
+
expect(tasks[0].id).toBe('1');
|
|
220
|
+
expect(tasks[1].id).toBe('2');
|
|
221
|
+
expect(tasks[2].id).toBe('3');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('filters tasks by milestone', async () => {
|
|
225
|
+
await adapter.createTask({ title: 'Task 1', milestone: '1' });
|
|
226
|
+
await adapter.createTask({ title: 'Task 2', milestone: '2' });
|
|
227
|
+
await adapter.createTask({ title: 'Task 3', milestone: '1' });
|
|
228
|
+
|
|
229
|
+
const tasks = await adapter.listTasks({ milestone: '1' });
|
|
230
|
+
|
|
231
|
+
expect(tasks).toHaveLength(2);
|
|
232
|
+
expect(tasks.every((t) => t.milestone === '1')).toBe(true);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('filters tasks by status', async () => {
|
|
236
|
+
const t1 = await adapter.createTask({ title: 'Pending' });
|
|
237
|
+
await adapter.createTask({ title: 'Also Pending' });
|
|
238
|
+
await adapter.updateTask(t1.id, { status: 'done' });
|
|
239
|
+
|
|
240
|
+
const pending = await adapter.listTasks({ status: 'pending' });
|
|
241
|
+
const done = await adapter.listTasks({ status: 'done' });
|
|
242
|
+
|
|
243
|
+
expect(pending).toHaveLength(1);
|
|
244
|
+
expect(done).toHaveLength(1);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('filters tasks by assignee', async () => {
|
|
248
|
+
await adapter.createTask({ title: 'Task 1', assignee: 'claude' });
|
|
249
|
+
await adapter.createTask({ title: 'Task 2', assignee: 'codex' });
|
|
250
|
+
|
|
251
|
+
const tasks = await adapter.listTasks({ assignee: 'claude' });
|
|
252
|
+
|
|
253
|
+
expect(tasks).toHaveLength(1);
|
|
254
|
+
expect(tasks[0].assignee).toBe('claude');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('returns task by ID', async () => {
|
|
258
|
+
await adapter.createTask({ title: 'Test Task' });
|
|
259
|
+
const task = await adapter.getTask('1');
|
|
260
|
+
|
|
261
|
+
expect(task).not.toBeNull();
|
|
262
|
+
expect(task?.title).toBe('Test Task');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('returns null for non-existent task', async () => {
|
|
266
|
+
const task = await adapter.getTask('999');
|
|
267
|
+
expect(task).toBeNull();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('updates task status', async () => {
|
|
271
|
+
await adapter.createTask({ title: 'Test' });
|
|
272
|
+
const updated = await adapter.updateTask('1', { status: 'in_progress' });
|
|
273
|
+
|
|
274
|
+
expect(updated.status).toBe('in_progress');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('throws error when updating non-existent task', async () => {
|
|
278
|
+
await expect(adapter.updateTask('999', { status: 'done' })).rejects.toThrow(
|
|
279
|
+
'Task 999 not found'
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('deletes task and its doc file', async () => {
|
|
284
|
+
await adapter.createTask({ title: 'Test' });
|
|
285
|
+
await adapter.deleteTask('1');
|
|
286
|
+
|
|
287
|
+
expect(await adapter.getTask('1')).toBeNull();
|
|
288
|
+
expect(await adapter.getTaskDoc('1')).toBeNull();
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
describe('Task Documentation', () => {
|
|
293
|
+
beforeEach(async () => {
|
|
294
|
+
await adapter.initTeam('Test Project');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('gets task documentation', async () => {
|
|
298
|
+
await adapter.createTask({ title: 'Test' });
|
|
299
|
+
const doc = await adapter.getTaskDoc('1');
|
|
300
|
+
|
|
301
|
+
expect(doc).toContain('# Test');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('sets task documentation', async () => {
|
|
305
|
+
await adapter.createTask({ title: 'Test' });
|
|
306
|
+
await adapter.setTaskDoc('1', '# Updated\n\nNew content');
|
|
307
|
+
|
|
308
|
+
const doc = await adapter.getTaskDoc('1');
|
|
309
|
+
expect(doc).toBe('# Updated\n\nNew content');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('returns null for non-existent doc', async () => {
|
|
313
|
+
const doc = await adapter.getTaskDoc('999');
|
|
314
|
+
expect(doc).toBeNull();
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
describe('Audit Log', () => {
|
|
319
|
+
beforeEach(async () => {
|
|
320
|
+
await adapter.initTeam('Test Project');
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('appends event to events.jsonl', async () => {
|
|
324
|
+
await adapter.appendEvent({
|
|
325
|
+
event: 'task_created',
|
|
326
|
+
id: '1',
|
|
327
|
+
actor: 'human',
|
|
328
|
+
ts: new Date().toISOString(),
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const events = await adapter.getEvents();
|
|
332
|
+
expect(events).toHaveLength(1);
|
|
333
|
+
expect(events[0].event).toBe('task_created');
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('appends multiple events', async () => {
|
|
337
|
+
await adapter.appendEvent({ event: 'event1', id: '1', actor: 'human', ts: '' });
|
|
338
|
+
await adapter.appendEvent({ event: 'event2', id: '2', actor: 'claude', ts: '' });
|
|
339
|
+
await adapter.appendEvent({ event: 'event3', id: '3', actor: 'codex', ts: '' });
|
|
340
|
+
|
|
341
|
+
const events = await adapter.getEvents();
|
|
342
|
+
expect(events).toHaveLength(3);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('returns events with limit', async () => {
|
|
346
|
+
for (let i = 0; i < 10; i++) {
|
|
347
|
+
await adapter.appendEvent({ event: `event${i}`, id: String(i), actor: 'human', ts: '' });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const events = await adapter.getEvents(3);
|
|
351
|
+
expect(events).toHaveLength(3);
|
|
352
|
+
// Returns last 3 events
|
|
353
|
+
expect(events[0].event).toBe('event7');
|
|
354
|
+
expect(events[1].event).toBe('event8');
|
|
355
|
+
expect(events[2].event).toBe('event9');
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('returns empty array when no events', async () => {
|
|
359
|
+
const events = await adapter.getEvents();
|
|
360
|
+
expect(events).toEqual([]);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('handles malformed JSONL lines gracefully', async () => {
|
|
364
|
+
// Write some valid and invalid lines directly
|
|
365
|
+
const eventsFile = path.join(testDir, 'events.jsonl');
|
|
366
|
+
fs.writeFileSync(
|
|
367
|
+
eventsFile,
|
|
368
|
+
`{"event":"valid","id":"1","actor":"h","ts":""}\n` +
|
|
369
|
+
`not json\n` +
|
|
370
|
+
`{"event":"also_valid","id":"2","actor":"c","ts":""}\n`
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
const events = await adapter.getEvents();
|
|
374
|
+
expect(events).toHaveLength(2);
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
describe('createFSAdapter factory', () => {
|
|
379
|
+
it('creates FSAdapter instance', () => {
|
|
380
|
+
const adapter = createFSAdapter('/tmp/test');
|
|
381
|
+
expect(adapter).toBeInstanceOf(FSAdapter);
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
});
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// Filesystem storage adapter for PM
|
|
3
|
+
// ─────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import type { StorageAdapter } from './adapter.js';
|
|
8
|
+
import type {
|
|
9
|
+
Team,
|
|
10
|
+
Milestone,
|
|
11
|
+
Task,
|
|
12
|
+
AuditEvent,
|
|
13
|
+
CreateTaskInput,
|
|
14
|
+
UpdateTaskInput,
|
|
15
|
+
CreateMilestoneInput,
|
|
16
|
+
UpdateMilestoneInput,
|
|
17
|
+
ListTasksFilter,
|
|
18
|
+
} from '../types.js';
|
|
19
|
+
|
|
20
|
+
export class FSAdapter implements StorageAdapter {
|
|
21
|
+
private teamDir: string;
|
|
22
|
+
|
|
23
|
+
constructor(teamDir: string) {
|
|
24
|
+
this.teamDir = teamDir;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private get teamFile(): string {
|
|
28
|
+
return path.join(this.teamDir, 'team.json');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private get milestonesDir(): string {
|
|
32
|
+
return path.join(this.teamDir, 'milestones');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private get tasksDir(): string {
|
|
36
|
+
return path.join(this.teamDir, 'tasks');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private get eventsFile(): string {
|
|
40
|
+
return path.join(this.teamDir, 'events.jsonl');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private ensureDir(dir: string): void {
|
|
44
|
+
if (!fs.existsSync(dir)) {
|
|
45
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private readJson<T>(filePath: string): T | null {
|
|
50
|
+
if (!fs.existsSync(filePath)) return null;
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8')) as T;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private writeJson(filePath: string, data: unknown): void {
|
|
59
|
+
this.ensureDir(path.dirname(filePath));
|
|
60
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private now(): string {
|
|
64
|
+
return new Date().toISOString();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private nextId(dir: string): string {
|
|
68
|
+
this.ensureDir(dir);
|
|
69
|
+
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json'));
|
|
70
|
+
const ids = files.map((f) => parseInt(f.replace('.json', ''), 10)).filter((n) => !isNaN(n));
|
|
71
|
+
const max = ids.length > 0 ? Math.max(...ids) : 0;
|
|
72
|
+
return String(max + 1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Team operations
|
|
76
|
+
async initTeam(name: string, windowId?: string): Promise<Team> {
|
|
77
|
+
this.ensureDir(this.teamDir);
|
|
78
|
+
const team: Team = {
|
|
79
|
+
id: path.basename(this.teamDir),
|
|
80
|
+
name,
|
|
81
|
+
windowId,
|
|
82
|
+
createdAt: this.now(),
|
|
83
|
+
};
|
|
84
|
+
this.writeJson(this.teamFile, team);
|
|
85
|
+
this.ensureDir(this.milestonesDir);
|
|
86
|
+
this.ensureDir(this.tasksDir);
|
|
87
|
+
return team;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async getTeam(): Promise<Team | null> {
|
|
91
|
+
return this.readJson<Team>(this.teamFile);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async updateTeam(updates: Partial<Team>): Promise<Team> {
|
|
95
|
+
const team = await this.getTeam();
|
|
96
|
+
if (!team) throw new Error('Team not initialized');
|
|
97
|
+
const updated = { ...team, ...updates };
|
|
98
|
+
this.writeJson(this.teamFile, updated);
|
|
99
|
+
return updated;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Milestone operations
|
|
103
|
+
async createMilestone(input: CreateMilestoneInput): Promise<Milestone> {
|
|
104
|
+
const id = this.nextId(this.milestonesDir);
|
|
105
|
+
const milestone: Milestone = {
|
|
106
|
+
id,
|
|
107
|
+
name: input.name,
|
|
108
|
+
status: 'pending',
|
|
109
|
+
createdAt: this.now(),
|
|
110
|
+
updatedAt: this.now(),
|
|
111
|
+
};
|
|
112
|
+
this.writeJson(path.join(this.milestonesDir, `${id}.json`), milestone);
|
|
113
|
+
return milestone;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async getMilestone(id: string): Promise<Milestone | null> {
|
|
117
|
+
return this.readJson<Milestone>(path.join(this.milestonesDir, `${id}.json`));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async listMilestones(): Promise<Milestone[]> {
|
|
121
|
+
this.ensureDir(this.milestonesDir);
|
|
122
|
+
const files = fs.readdirSync(this.milestonesDir).filter((f) => f.endsWith('.json'));
|
|
123
|
+
const milestones: Milestone[] = [];
|
|
124
|
+
for (const file of files) {
|
|
125
|
+
const m = this.readJson<Milestone>(path.join(this.milestonesDir, file));
|
|
126
|
+
if (m) milestones.push(m);
|
|
127
|
+
}
|
|
128
|
+
return milestones.sort((a, b) => parseInt(a.id) - parseInt(b.id));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async updateMilestone(id: string, input: UpdateMilestoneInput): Promise<Milestone> {
|
|
132
|
+
const milestone = await this.getMilestone(id);
|
|
133
|
+
if (!milestone) throw new Error(`Milestone ${id} not found`);
|
|
134
|
+
const updated: Milestone = {
|
|
135
|
+
...milestone,
|
|
136
|
+
...input,
|
|
137
|
+
updatedAt: this.now(),
|
|
138
|
+
};
|
|
139
|
+
this.writeJson(path.join(this.milestonesDir, `${id}.json`), updated);
|
|
140
|
+
return updated;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async deleteMilestone(id: string): Promise<void> {
|
|
144
|
+
const filePath = path.join(this.milestonesDir, `${id}.json`);
|
|
145
|
+
if (fs.existsSync(filePath)) {
|
|
146
|
+
fs.unlinkSync(filePath);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Task operations
|
|
151
|
+
async createTask(input: CreateTaskInput): Promise<Task> {
|
|
152
|
+
const id = this.nextId(this.tasksDir);
|
|
153
|
+
const task: Task = {
|
|
154
|
+
id,
|
|
155
|
+
title: input.title,
|
|
156
|
+
milestone: input.milestone,
|
|
157
|
+
status: 'pending',
|
|
158
|
+
assignee: input.assignee,
|
|
159
|
+
docPath: `tasks/${id}.md`,
|
|
160
|
+
createdAt: this.now(),
|
|
161
|
+
updatedAt: this.now(),
|
|
162
|
+
};
|
|
163
|
+
this.writeJson(path.join(this.tasksDir, `${id}.json`), task);
|
|
164
|
+
// Create empty doc file
|
|
165
|
+
fs.writeFileSync(path.join(this.tasksDir, `${id}.md`), `# ${input.title}\n\n`);
|
|
166
|
+
return task;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async getTask(id: string): Promise<Task | null> {
|
|
170
|
+
return this.readJson<Task>(path.join(this.tasksDir, `${id}.json`));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async listTasks(filter?: ListTasksFilter): Promise<Task[]> {
|
|
174
|
+
this.ensureDir(this.tasksDir);
|
|
175
|
+
const files = fs.readdirSync(this.tasksDir).filter((f) => f.endsWith('.json'));
|
|
176
|
+
let tasks: Task[] = [];
|
|
177
|
+
for (const file of files) {
|
|
178
|
+
const t = this.readJson<Task>(path.join(this.tasksDir, file));
|
|
179
|
+
if (t) tasks.push(t);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Apply filters
|
|
183
|
+
if (filter?.milestone) {
|
|
184
|
+
tasks = tasks.filter((t) => t.milestone === filter.milestone);
|
|
185
|
+
}
|
|
186
|
+
if (filter?.status) {
|
|
187
|
+
tasks = tasks.filter((t) => t.status === filter.status);
|
|
188
|
+
}
|
|
189
|
+
if (filter?.assignee) {
|
|
190
|
+
tasks = tasks.filter((t) => t.assignee === filter.assignee);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return tasks.sort((a, b) => parseInt(a.id) - parseInt(b.id));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async updateTask(id: string, input: UpdateTaskInput): Promise<Task> {
|
|
197
|
+
const task = await this.getTask(id);
|
|
198
|
+
if (!task) throw new Error(`Task ${id} not found`);
|
|
199
|
+
const updated: Task = {
|
|
200
|
+
...task,
|
|
201
|
+
...input,
|
|
202
|
+
updatedAt: this.now(),
|
|
203
|
+
};
|
|
204
|
+
this.writeJson(path.join(this.tasksDir, `${id}.json`), updated);
|
|
205
|
+
return updated;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async deleteTask(id: string): Promise<void> {
|
|
209
|
+
const jsonPath = path.join(this.tasksDir, `${id}.json`);
|
|
210
|
+
const mdPath = path.join(this.tasksDir, `${id}.md`);
|
|
211
|
+
if (fs.existsSync(jsonPath)) fs.unlinkSync(jsonPath);
|
|
212
|
+
if (fs.existsSync(mdPath)) fs.unlinkSync(mdPath);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Documentation
|
|
216
|
+
async getTaskDoc(id: string): Promise<string | null> {
|
|
217
|
+
const docPath = path.join(this.tasksDir, `${id}.md`);
|
|
218
|
+
if (!fs.existsSync(docPath)) return null;
|
|
219
|
+
return fs.readFileSync(docPath, 'utf-8');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async setTaskDoc(id: string, content: string): Promise<void> {
|
|
223
|
+
const docPath = path.join(this.tasksDir, `${id}.md`);
|
|
224
|
+
fs.writeFileSync(docPath, content);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Audit log
|
|
228
|
+
async appendEvent(event: AuditEvent): Promise<void> {
|
|
229
|
+
this.ensureDir(this.teamDir);
|
|
230
|
+
fs.appendFileSync(this.eventsFile, JSON.stringify(event) + '\n', { flag: 'a' });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async getEvents(limit?: number): Promise<AuditEvent[]> {
|
|
234
|
+
if (!fs.existsSync(this.eventsFile)) return [];
|
|
235
|
+
const lines = fs.readFileSync(this.eventsFile, 'utf-8').trim().split('\n');
|
|
236
|
+
const events: AuditEvent[] = [];
|
|
237
|
+
for (const line of lines) {
|
|
238
|
+
if (line.trim()) {
|
|
239
|
+
try {
|
|
240
|
+
events.push(JSON.parse(line) as AuditEvent);
|
|
241
|
+
} catch {
|
|
242
|
+
// Skip malformed lines
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (limit) {
|
|
247
|
+
return events.slice(-limit);
|
|
248
|
+
}
|
|
249
|
+
return events;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function createFSAdapter(teamDir: string): StorageAdapter {
|
|
254
|
+
return new FSAdapter(teamDir);
|
|
255
|
+
}
|