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.
@@ -0,0 +1,751 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // GitHub storage adapter for PM (Phase 5)
3
+ // Uses GitHub Issues for tasks, GitHub Milestones for milestones
4
+ // ─────────────────────────────────────────────────────────────
5
+
6
+ import { spawnSync } from 'child_process';
7
+ import fs from 'fs';
8
+ import path from 'path';
9
+ import type { StorageAdapter } from './adapter.js';
10
+ import type {
11
+ Team,
12
+ Milestone,
13
+ Task,
14
+ AuditEvent,
15
+ CreateTaskInput,
16
+ UpdateTaskInput,
17
+ CreateMilestoneInput,
18
+ UpdateMilestoneInput,
19
+ ListTasksFilter,
20
+ TaskStatus,
21
+ } from '../types.js';
22
+
23
+ // ─────────────────────────────────────────────────────────────
24
+ // Labels used for task status tracking
25
+ // ─────────────────────────────────────────────────────────────
26
+
27
+ const LABELS = {
28
+ // Base label for all tmux-team managed issues
29
+ TASK: 'tmux-team:task',
30
+ // Status labels
31
+ PENDING: 'tmux-team:pending',
32
+ IN_PROGRESS: 'tmux-team:in_progress',
33
+ DELETED: 'tmux-team:deleted',
34
+ // 'done' status uses closed issue state, no label needed
35
+ } as const;
36
+
37
+ // ─────────────────────────────────────────────────────────────
38
+ // GitHub Issue/Milestone JSON types (from gh CLI)
39
+ // ─────────────────────────────────────────────────────────────
40
+
41
+ interface GHIssue {
42
+ number: number;
43
+ title: string;
44
+ body: string;
45
+ state: 'OPEN' | 'CLOSED';
46
+ labels: Array<{ name: string }>;
47
+ milestone?: { number: number; title: string } | null;
48
+ assignees: Array<{ login: string }>;
49
+ createdAt: string;
50
+ updatedAt: string;
51
+ }
52
+
53
+ interface GHMilestone {
54
+ number: number;
55
+ title: string;
56
+ state: 'OPEN' | 'CLOSED';
57
+ createdAt: string;
58
+ }
59
+
60
+ // ─────────────────────────────────────────────────────────────
61
+ // Local cache for ID mapping (task ID -> issue number)
62
+ // ─────────────────────────────────────────────────────────────
63
+
64
+ const CACHE_VERSION = 1;
65
+
66
+ interface IdCache {
67
+ version: number; // Cache format version for migrations
68
+ repo: string; // Associated repo to detect cross-repo drift
69
+ tasks: Record<string, number>; // task ID -> issue number
70
+ milestones: Record<string, number>; // milestone ID -> milestone number
71
+ nextTaskId: number;
72
+ nextMilestoneId: number;
73
+ }
74
+
75
+ // ─────────────────────────────────────────────────────────────
76
+ // GitHub Adapter Implementation
77
+ // ─────────────────────────────────────────────────────────────
78
+
79
+ export class GitHubAdapter implements StorageAdapter {
80
+ private teamDir: string;
81
+ private repo: string;
82
+
83
+ constructor(teamDir: string, repo: string) {
84
+ this.teamDir = teamDir;
85
+ this.repo = repo;
86
+ }
87
+
88
+ // ─────────────────────────────────────────────────────────────
89
+ // Helper: Execute gh CLI command safely (no shell injection)
90
+ // ─────────────────────────────────────────────────────────────
91
+
92
+ private gh(args: string[]): string {
93
+ const fullArgs = [...args];
94
+ if (this.repo) {
95
+ fullArgs.push('--repo', this.repo);
96
+ }
97
+ // Use spawnSync with array args to avoid shell injection
98
+ const result = spawnSync('gh', fullArgs, {
99
+ encoding: 'utf-8',
100
+ stdio: ['pipe', 'pipe', 'pipe'],
101
+ });
102
+
103
+ if (result.error) {
104
+ throw new Error(`gh command failed: ${result.error.message}`);
105
+ }
106
+
107
+ if (result.status !== 0) {
108
+ const stderr = result.stderr?.trim() || 'Unknown error';
109
+ // Surface auth errors clearly
110
+ if (stderr.includes('gh auth login') || stderr.includes('not logged in')) {
111
+ throw new Error(`GitHub authentication required. Run: gh auth login`);
112
+ }
113
+ throw new Error(`gh command failed: ${stderr}`);
114
+ }
115
+
116
+ return (result.stdout || '').trim();
117
+ }
118
+
119
+ private ghJson<T>(args: string[]): T {
120
+ const result = this.gh(args);
121
+ return JSON.parse(result) as T;
122
+ }
123
+
124
+ // ─────────────────────────────────────────────────────────────
125
+ // Helper: Local ID cache management
126
+ // ─────────────────────────────────────────────────────────────
127
+
128
+ private get cacheFile(): string {
129
+ return path.join(this.teamDir, 'github-cache.json');
130
+ }
131
+
132
+ private loadCache(): IdCache {
133
+ if (fs.existsSync(this.cacheFile)) {
134
+ try {
135
+ const data = JSON.parse(fs.readFileSync(this.cacheFile, 'utf-8')) as IdCache;
136
+
137
+ // Validate cache integrity
138
+ if (data.version !== CACHE_VERSION) {
139
+ // Version mismatch - reset cache (future: migration logic)
140
+ console.error(`[tmux-team] Cache version mismatch, resetting cache`);
141
+ return this.createEmptyCache();
142
+ }
143
+
144
+ if (data.repo !== this.repo) {
145
+ // Repo mismatch - cache is for different repo, reset
146
+ console.error(
147
+ `[tmux-team] Cache repo mismatch (${data.repo} vs ${this.repo}), resetting cache`
148
+ );
149
+ return this.createEmptyCache();
150
+ }
151
+
152
+ // Validate nextId counters are consistent
153
+ const maxTaskId = Math.max(0, ...Object.keys(data.tasks).map((k) => parseInt(k, 10) || 0));
154
+ const maxMilestoneId = Math.max(
155
+ 0,
156
+ ...Object.keys(data.milestones).map((k) => parseInt(k, 10) || 0)
157
+ );
158
+
159
+ if (data.nextTaskId <= maxTaskId) {
160
+ data.nextTaskId = maxTaskId + 1;
161
+ }
162
+ if (data.nextMilestoneId <= maxMilestoneId) {
163
+ data.nextMilestoneId = maxMilestoneId + 1;
164
+ }
165
+
166
+ return data;
167
+ } catch {
168
+ // Corrupted cache, reset
169
+ console.error(`[tmux-team] Cache corrupted, resetting`);
170
+ }
171
+ }
172
+ return this.createEmptyCache();
173
+ }
174
+
175
+ private createEmptyCache(): IdCache {
176
+ return {
177
+ version: CACHE_VERSION,
178
+ repo: this.repo,
179
+ tasks: {},
180
+ milestones: {},
181
+ nextTaskId: 1,
182
+ nextMilestoneId: 1,
183
+ };
184
+ }
185
+
186
+ private saveCache(cache: IdCache): void {
187
+ fs.mkdirSync(path.dirname(this.cacheFile), { recursive: true });
188
+ fs.writeFileSync(this.cacheFile, JSON.stringify(cache, null, 2) + '\n');
189
+ }
190
+
191
+ private getIssueNumber(taskId: string): number | null {
192
+ const cache = this.loadCache();
193
+ return cache.tasks[taskId] ?? null;
194
+ }
195
+
196
+ private getMilestoneNumber(milestoneId: string): number | null {
197
+ const cache = this.loadCache();
198
+ return cache.milestones[milestoneId] ?? null;
199
+ }
200
+
201
+ // ─────────────────────────────────────────────────────────────
202
+ // Helper: Status <-> Label conversion
203
+ // ─────────────────────────────────────────────────────────────
204
+
205
+ private statusToLabel(status: TaskStatus): string | null {
206
+ switch (status) {
207
+ case 'pending':
208
+ return LABELS.PENDING;
209
+ case 'in_progress':
210
+ return LABELS.IN_PROGRESS;
211
+ case 'done':
212
+ return null; // Use closed state
213
+ }
214
+ }
215
+
216
+ private issueToTask(issue: GHIssue, taskId: string, cache?: IdCache): Task {
217
+ let status: TaskStatus = 'pending';
218
+ if (issue.state === 'CLOSED') {
219
+ status = 'done';
220
+ } else if (issue.labels.some((l) => l.name === LABELS.IN_PROGRESS)) {
221
+ status = 'in_progress';
222
+ }
223
+
224
+ // Look up local milestone ID from GitHub milestone number
225
+ let milestoneId: string | undefined;
226
+ if (issue.milestone?.number) {
227
+ const c = cache || this.loadCache();
228
+ const ghMilestoneNum = issue.milestone.number;
229
+ milestoneId = Object.entries(c.milestones).find(([, num]) => num === ghMilestoneNum)?.[0];
230
+ }
231
+
232
+ return {
233
+ id: taskId,
234
+ title: issue.title,
235
+ milestone: milestoneId,
236
+ status,
237
+ assignee: issue.assignees[0]?.login,
238
+ docPath: `github:issue/${issue.number}`,
239
+ createdAt: issue.createdAt,
240
+ updatedAt: issue.updatedAt,
241
+ };
242
+ }
243
+
244
+ private milestoneToMilestone(ghMilestone: GHMilestone, id: string): Milestone {
245
+ return {
246
+ id,
247
+ name: ghMilestone.title,
248
+ status: ghMilestone.state === 'CLOSED' ? 'done' : 'pending',
249
+ createdAt: ghMilestone.createdAt,
250
+ updatedAt: ghMilestone.createdAt, // GH milestones don't have updatedAt
251
+ };
252
+ }
253
+
254
+ // ─────────────────────────────────────────────────────────────
255
+ // Helper: Ensure labels exist
256
+ // ─────────────────────────────────────────────────────────────
257
+
258
+ private async ensureLabels(): Promise<void> {
259
+ const labels = [
260
+ { name: LABELS.TASK, color: '0366d6', desc: 'tmux-team managed task' },
261
+ { name: LABELS.PENDING, color: 'fbca04', desc: 'Task pending' },
262
+ { name: LABELS.IN_PROGRESS, color: '1d76db', desc: 'Task in progress' },
263
+ { name: LABELS.DELETED, color: 'b60205', desc: 'Task deleted' },
264
+ ];
265
+ for (const label of labels) {
266
+ try {
267
+ this.gh([
268
+ 'label',
269
+ 'create',
270
+ label.name,
271
+ '--force',
272
+ '--color',
273
+ label.color,
274
+ '--description',
275
+ label.desc,
276
+ ]);
277
+ } catch {
278
+ // Label might already exist, ignore
279
+ }
280
+ }
281
+ }
282
+
283
+ // ─────────────────────────────────────────────────────────────
284
+ // Team operations
285
+ // ─────────────────────────────────────────────────────────────
286
+
287
+ private get teamFile(): string {
288
+ return path.join(this.teamDir, 'team.json');
289
+ }
290
+
291
+ private get eventsFile(): string {
292
+ return path.join(this.teamDir, 'events.jsonl');
293
+ }
294
+
295
+ private now(): string {
296
+ return new Date().toISOString();
297
+ }
298
+
299
+ async initTeam(name: string, windowId?: string): Promise<Team> {
300
+ fs.mkdirSync(this.teamDir, { recursive: true });
301
+
302
+ // Ensure labels exist in the repo
303
+ await this.ensureLabels();
304
+
305
+ const team: Team = {
306
+ id: path.basename(this.teamDir),
307
+ name,
308
+ windowId,
309
+ createdAt: this.now(),
310
+ };
311
+
312
+ fs.writeFileSync(this.teamFile, JSON.stringify(team, null, 2) + '\n');
313
+
314
+ // Initialize empty cache with version and repo
315
+ this.saveCache(this.createEmptyCache());
316
+
317
+ return team;
318
+ }
319
+
320
+ async getTeam(): Promise<Team | null> {
321
+ if (!fs.existsSync(this.teamFile)) return null;
322
+ try {
323
+ return JSON.parse(fs.readFileSync(this.teamFile, 'utf-8')) as Team;
324
+ } catch {
325
+ return null;
326
+ }
327
+ }
328
+
329
+ async updateTeam(updates: Partial<Team>): Promise<Team> {
330
+ const team = await this.getTeam();
331
+ if (!team) throw new Error('Team not initialized');
332
+ const updated = { ...team, ...updates };
333
+ fs.writeFileSync(this.teamFile, JSON.stringify(updated, null, 2) + '\n');
334
+ return updated;
335
+ }
336
+
337
+ // ─────────────────────────────────────────────────────────────
338
+ // Milestone operations (GitHub Milestones)
339
+ // ─────────────────────────────────────────────────────────────
340
+
341
+ async createMilestone(input: CreateMilestoneInput): Promise<Milestone> {
342
+ // Create milestone in GitHub
343
+ const result = this.gh([
344
+ 'api',
345
+ `repos/${this.repo}/milestones`,
346
+ '-X',
347
+ 'POST',
348
+ '-f',
349
+ `title=${input.name}`,
350
+ '-f',
351
+ 'state=open',
352
+ ]);
353
+ const ghMilestone = JSON.parse(result) as { number: number; title: string; created_at: string };
354
+
355
+ // Cache the ID mapping
356
+ const cache = this.loadCache();
357
+ const id = String(cache.nextMilestoneId++);
358
+ cache.milestones[id] = ghMilestone.number;
359
+ this.saveCache(cache);
360
+
361
+ return {
362
+ id,
363
+ name: ghMilestone.title,
364
+ status: 'pending',
365
+ createdAt: ghMilestone.created_at,
366
+ updatedAt: ghMilestone.created_at,
367
+ };
368
+ }
369
+
370
+ async getMilestone(id: string): Promise<Milestone | null> {
371
+ const number = this.getMilestoneNumber(id);
372
+ if (!number) return null;
373
+
374
+ try {
375
+ const result = this.gh(['api', `repos/${this.repo}/milestones/${number}`]);
376
+ const ghMilestone = JSON.parse(result) as GHMilestone;
377
+ return this.milestoneToMilestone(ghMilestone, id);
378
+ } catch (error) {
379
+ const err = error as Error;
380
+ if (err.message.includes('auth')) {
381
+ throw err; // Re-throw auth errors
382
+ }
383
+ return null; // Not found or other error
384
+ }
385
+ }
386
+
387
+ async listMilestones(): Promise<Milestone[]> {
388
+ const cache = this.loadCache();
389
+ const milestones: Milestone[] = [];
390
+
391
+ try {
392
+ // Get all milestones (open and closed)
393
+ const result = this.gh([
394
+ 'api',
395
+ `repos/${this.repo}/milestones`,
396
+ '-X',
397
+ 'GET',
398
+ '--jq',
399
+ '.',
400
+ '-f',
401
+ 'state=all',
402
+ ]);
403
+ const ghMilestones = JSON.parse(result) as GHMilestone[];
404
+
405
+ // Match with cached IDs, or create new mappings
406
+ for (const ghm of ghMilestones) {
407
+ let id = Object.entries(cache.milestones).find(([, num]) => num === ghm.number)?.[0];
408
+ if (!id) {
409
+ // New milestone from GitHub, assign ID
410
+ id = String(cache.nextMilestoneId++);
411
+ cache.milestones[id] = ghm.number;
412
+ }
413
+ milestones.push(this.milestoneToMilestone(ghm, id));
414
+ }
415
+
416
+ this.saveCache(cache);
417
+ } catch (error) {
418
+ // Surface auth errors instead of silently returning empty array
419
+ const err = error as Error;
420
+ if (err.message.includes('auth')) {
421
+ throw err; // Re-throw auth errors
422
+ }
423
+ // Other errors (no milestones, network issues) return empty array
424
+ }
425
+
426
+ return milestones.sort((a, b) => parseInt(a.id) - parseInt(b.id));
427
+ }
428
+
429
+ async updateMilestone(id: string, input: UpdateMilestoneInput): Promise<Milestone> {
430
+ const number = this.getMilestoneNumber(id);
431
+ if (!number) throw new Error(`Milestone ${id} not found`);
432
+
433
+ const args = ['api', `repos/${this.repo}/milestones/${number}`, '-X', 'PATCH'];
434
+ if (input.name) args.push('-f', `title=${input.name}`);
435
+ if (input.status) {
436
+ args.push('-f', `state=${input.status === 'done' ? 'closed' : 'open'}`);
437
+ }
438
+
439
+ const result = this.gh(args);
440
+ const ghMilestone = JSON.parse(result) as GHMilestone;
441
+ return this.milestoneToMilestone(ghMilestone, id);
442
+ }
443
+
444
+ async deleteMilestone(id: string): Promise<void> {
445
+ const number = this.getMilestoneNumber(id);
446
+ if (!number) return;
447
+
448
+ try {
449
+ this.gh(['api', `repos/${this.repo}/milestones/${number}`, '-X', 'DELETE']);
450
+ } catch {
451
+ // Already deleted or not found
452
+ }
453
+
454
+ // Remove from cache
455
+ const cache = this.loadCache();
456
+ delete cache.milestones[id];
457
+ this.saveCache(cache);
458
+ }
459
+
460
+ // ─────────────────────────────────────────────────────────────
461
+ // Task operations (GitHub Issues)
462
+ // ─────────────────────────────────────────────────────────────
463
+
464
+ async createTask(input: CreateTaskInput): Promise<Task> {
465
+ // Always add both TASK (base) and PENDING (status) labels
466
+ const args = [
467
+ 'issue',
468
+ 'create',
469
+ '--title',
470
+ input.title,
471
+ '--label',
472
+ LABELS.TASK,
473
+ '--label',
474
+ LABELS.PENDING,
475
+ ];
476
+
477
+ // Add milestone if specified
478
+ if (input.milestone) {
479
+ const milestoneNum = this.getMilestoneNumber(input.milestone);
480
+ if (milestoneNum) {
481
+ args.push('--milestone', String(milestoneNum));
482
+ }
483
+ }
484
+
485
+ // Add assignee if specified
486
+ if (input.assignee) {
487
+ args.push('--assignee', input.assignee);
488
+ }
489
+
490
+ // Create issue and get its number
491
+ const url = this.gh(args);
492
+ const issueNumber = parseInt(url.split('/').pop() || '0', 10);
493
+
494
+ // Cache the ID mapping
495
+ const cache = this.loadCache();
496
+ const id = String(cache.nextTaskId++);
497
+ cache.tasks[id] = issueNumber;
498
+ this.saveCache(cache);
499
+
500
+ // Fetch the created issue for full details
501
+ const issue = this.ghJson<GHIssue>([
502
+ 'issue',
503
+ 'view',
504
+ String(issueNumber),
505
+ '--json',
506
+ 'number,title,body,state,labels,milestone,assignees,createdAt,updatedAt',
507
+ ]);
508
+
509
+ return this.issueToTask(issue, id);
510
+ }
511
+
512
+ async getTask(id: string): Promise<Task | null> {
513
+ const number = this.getIssueNumber(id);
514
+ if (!number) return null;
515
+
516
+ try {
517
+ const issue = this.ghJson<GHIssue>([
518
+ 'issue',
519
+ 'view',
520
+ String(number),
521
+ '--json',
522
+ 'number,title,body,state,labels,milestone,assignees,createdAt,updatedAt',
523
+ ]);
524
+ return this.issueToTask(issue, id);
525
+ } catch {
526
+ return null;
527
+ }
528
+ }
529
+
530
+ async listTasks(filter?: ListTasksFilter): Promise<Task[]> {
531
+ const cache = this.loadCache();
532
+ const args = [
533
+ 'issue',
534
+ 'list',
535
+ '--json',
536
+ 'number,title,body,state,labels,milestone,assignees,createdAt,updatedAt',
537
+ '--limit',
538
+ '1000',
539
+ ];
540
+
541
+ // Filter by status
542
+ if (filter?.status === 'done') {
543
+ args.push('--state', 'closed');
544
+ } else if (filter?.status) {
545
+ args.push('--state', 'open');
546
+ args.push('--label', filter.status === 'in_progress' ? LABELS.IN_PROGRESS : LABELS.PENDING);
547
+ } else {
548
+ args.push('--state', 'all');
549
+ }
550
+
551
+ // Filter by milestone
552
+ if (filter?.milestone) {
553
+ const milestoneNum = this.getMilestoneNumber(filter.milestone);
554
+ if (milestoneNum) {
555
+ args.push('--milestone', String(milestoneNum));
556
+ }
557
+ }
558
+
559
+ // Filter by assignee
560
+ if (filter?.assignee) {
561
+ args.push('--assignee', filter.assignee);
562
+ }
563
+
564
+ // Only get tmux-team managed issues (all have TASK label)
565
+ args.push('--label', LABELS.TASK);
566
+
567
+ try {
568
+ const issues = this.ghJson<GHIssue[]>(args);
569
+ const tasks: Task[] = [];
570
+
571
+ for (const issue of issues) {
572
+ // Find or create ID mapping
573
+ let id = Object.entries(cache.tasks).find(([, num]) => num === issue.number)?.[0];
574
+ if (!id) {
575
+ id = String(cache.nextTaskId++);
576
+ cache.tasks[id] = issue.number;
577
+ }
578
+ // Pass cache for efficient milestone lookup
579
+ tasks.push(this.issueToTask(issue, id, cache));
580
+ }
581
+
582
+ this.saveCache(cache);
583
+ return tasks.sort((a, b) => parseInt(a.id) - parseInt(b.id));
584
+ } catch (error) {
585
+ // Surface errors instead of silently returning empty array
586
+ const err = error as Error;
587
+ if (err.message.includes('auth')) {
588
+ throw err; // Re-throw auth errors
589
+ }
590
+ return [];
591
+ }
592
+ }
593
+
594
+ async updateTask(id: string, input: UpdateTaskInput): Promise<Task> {
595
+ const number = this.getIssueNumber(id);
596
+ if (!number) throw new Error(`Task ${id} not found`);
597
+
598
+ const args = ['issue', 'edit', String(number)];
599
+
600
+ if (input.title) {
601
+ args.push('--title', input.title);
602
+ }
603
+
604
+ if (input.assignee) {
605
+ args.push('--add-assignee', input.assignee);
606
+ }
607
+
608
+ if (input.milestone) {
609
+ const milestoneNum = this.getMilestoneNumber(input.milestone);
610
+ if (milestoneNum) {
611
+ args.push('--milestone', String(milestoneNum));
612
+ }
613
+ }
614
+
615
+ // Handle status change via labels
616
+ if (input.status) {
617
+ if (input.status === 'done') {
618
+ // Close the issue
619
+ this.gh(['issue', 'close', String(number)]);
620
+ // Remove status labels
621
+ this.gh(['issue', 'edit', String(number), '--remove-label', LABELS.PENDING]);
622
+ this.gh(['issue', 'edit', String(number), '--remove-label', LABELS.IN_PROGRESS]);
623
+ } else {
624
+ // Reopen if needed
625
+ this.gh(['issue', 'reopen', String(number)]);
626
+ // Update labels
627
+ const newLabel = this.statusToLabel(input.status);
628
+ const oldLabel = input.status === 'pending' ? LABELS.IN_PROGRESS : LABELS.PENDING;
629
+ if (newLabel) {
630
+ this.gh([
631
+ 'issue',
632
+ 'edit',
633
+ String(number),
634
+ '--add-label',
635
+ newLabel,
636
+ '--remove-label',
637
+ oldLabel,
638
+ ]);
639
+ }
640
+ }
641
+ }
642
+
643
+ // Apply other edits
644
+ if (args.length > 3) {
645
+ this.gh(args);
646
+ }
647
+
648
+ // Return updated task
649
+ const task = await this.getTask(id);
650
+ if (!task) throw new Error(`Task ${id} not found after update`);
651
+ return task;
652
+ }
653
+
654
+ async deleteTask(id: string): Promise<void> {
655
+ const number = this.getIssueNumber(id);
656
+ if (!number) return;
657
+
658
+ // Close the issue (GitHub doesn't allow deleting issues via API)
659
+ try {
660
+ this.gh(['issue', 'close', String(number)]);
661
+ // Add a label to indicate it was deleted
662
+ this.gh(['issue', 'edit', String(number), '--add-label', LABELS.DELETED]);
663
+ } catch {
664
+ // Already closed or not found
665
+ }
666
+
667
+ // Remove from cache
668
+ const cache = this.loadCache();
669
+ delete cache.tasks[id];
670
+ this.saveCache(cache);
671
+ }
672
+
673
+ // ─────────────────────────────────────────────────────────────
674
+ // Documentation (Issue body)
675
+ // ─────────────────────────────────────────────────────────────
676
+
677
+ async getTaskDoc(id: string): Promise<string | null> {
678
+ const number = this.getIssueNumber(id);
679
+ if (!number) return null;
680
+
681
+ try {
682
+ const issue = this.ghJson<GHIssue>(['issue', 'view', String(number), '--json', 'body']);
683
+ return issue.body || null;
684
+ } catch {
685
+ return null;
686
+ }
687
+ }
688
+
689
+ async setTaskDoc(id: string, content: string): Promise<void> {
690
+ const number = this.getIssueNumber(id);
691
+ if (!number) throw new Error(`Task ${id} not found`);
692
+
693
+ this.gh(['issue', 'edit', String(number), '--body', content]);
694
+ }
695
+
696
+ // ─────────────────────────────────────────────────────────────
697
+ // Audit log (Issue Comments)
698
+ // ─────────────────────────────────────────────────────────────
699
+
700
+ async appendEvent(event: AuditEvent): Promise<void> {
701
+ // Also append to local JSONL for offline access
702
+ fs.mkdirSync(this.teamDir, { recursive: true });
703
+ fs.appendFileSync(this.eventsFile, JSON.stringify(event) + '\n', { flag: 'a' });
704
+
705
+ // If event is related to a task, add comment to the issue
706
+ // Events are named: task_created, task_updated, etc.
707
+ if (event.id && event.event.startsWith('task_')) {
708
+ const number = this.getIssueNumber(event.id);
709
+ if (number) {
710
+ // Format a readable comment
711
+ let comment = `[tmux-team] **${event.actor}** - \`${event.event}\``;
712
+ if (event.field && event.from !== undefined && event.to !== undefined) {
713
+ comment += `\n\n${event.field}: ${event.from} → ${event.to}`;
714
+ }
715
+ try {
716
+ this.gh(['issue', 'comment', String(number), '--body', comment]);
717
+ } catch {
718
+ // Ignore comment failures (might be rate limited)
719
+ }
720
+ }
721
+ }
722
+ }
723
+
724
+ async getEvents(limit?: number): Promise<AuditEvent[]> {
725
+ // Read from local JSONL (primary source)
726
+ if (!fs.existsSync(this.eventsFile)) return [];
727
+ const lines = fs.readFileSync(this.eventsFile, 'utf-8').trim().split('\n');
728
+ const events: AuditEvent[] = [];
729
+ for (const line of lines) {
730
+ if (line.trim()) {
731
+ try {
732
+ events.push(JSON.parse(line) as AuditEvent);
733
+ } catch {
734
+ // Skip malformed lines
735
+ }
736
+ }
737
+ }
738
+ if (limit) {
739
+ return events.slice(-limit);
740
+ }
741
+ return events;
742
+ }
743
+ }
744
+
745
+ // ─────────────────────────────────────────────────────────────
746
+ // Factory function
747
+ // ─────────────────────────────────────────────────────────────
748
+
749
+ export function createGitHubAdapter(teamDir: string, repo: string): StorageAdapter {
750
+ return new GitHubAdapter(teamDir, repo);
751
+ }