@aprovan/hardcopy 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 (53) hide show
  1. package/.eslintrc.json +22 -0
  2. package/.github/workflows/publish.yml +41 -0
  3. package/.prettierignore +17 -0
  4. package/LICENSE +21 -0
  5. package/README.md +183 -0
  6. package/dist/cli.d.ts +1 -0
  7. package/dist/cli.js +2950 -0
  8. package/dist/index.d.ts +406 -0
  9. package/dist/index.js +2737 -0
  10. package/dist/mcp-server.d.ts +7 -0
  11. package/dist/mcp-server.js +2665 -0
  12. package/docs/research/crdt.md +777 -0
  13. package/docs/research/github-issues.md +684 -0
  14. package/docs/research/gql.md +876 -0
  15. package/docs/research/index.md +19 -0
  16. package/docs/specs/conflict-resolution.md +1254 -0
  17. package/docs/specs/hardcopy.md +742 -0
  18. package/docs/specs/patchwork-integration.md +227 -0
  19. package/docs/specs/plugin-architecture.md +747 -0
  20. package/mcp.json +8 -0
  21. package/package.json +64 -0
  22. package/scripts/install-graphqlite.ts +156 -0
  23. package/src/cli.ts +356 -0
  24. package/src/config.ts +104 -0
  25. package/src/conflict-store.ts +136 -0
  26. package/src/conflict.ts +147 -0
  27. package/src/crdt.ts +100 -0
  28. package/src/db.ts +600 -0
  29. package/src/env.ts +34 -0
  30. package/src/format.ts +72 -0
  31. package/src/formats/github-issue.ts +55 -0
  32. package/src/hardcopy/core.ts +78 -0
  33. package/src/hardcopy/diff.ts +188 -0
  34. package/src/hardcopy/index.ts +67 -0
  35. package/src/hardcopy/init.ts +24 -0
  36. package/src/hardcopy/push.ts +444 -0
  37. package/src/hardcopy/sync.ts +37 -0
  38. package/src/hardcopy/types.ts +49 -0
  39. package/src/hardcopy/views.ts +199 -0
  40. package/src/hardcopy.ts +1 -0
  41. package/src/index.ts +13 -0
  42. package/src/llm-merge.ts +109 -0
  43. package/src/mcp-server.ts +388 -0
  44. package/src/merge.ts +75 -0
  45. package/src/provider.ts +40 -0
  46. package/src/providers/a2a/index.ts +166 -0
  47. package/src/providers/git/index.ts +212 -0
  48. package/src/providers/github/index.ts +236 -0
  49. package/src/providers/github/issues.ts +66 -0
  50. package/src/providers.ts +7 -0
  51. package/src/types.ts +101 -0
  52. package/tsconfig.json +21 -0
  53. package/tsup.config.ts +10 -0
@@ -0,0 +1,747 @@
1
+ # Generic Sync Plugin Architecture
2
+
3
+ ## Overview
4
+
5
+ This document outlines the architecture for a generic bi-directional sync system that can adapt to multiple data sources (GitHub Issues, Figma, Google Calendar, Gmail, Google Docs, etc.) while maintaining:
6
+
7
+ 1. **Consistent local file representation** (Markdown/YAML)
8
+ 2. **CRDT-based conflict resolution** using Loro
9
+ 3. **Graph-queryable data** using GQL/Cypher patterns
10
+ 4. **Extensible adapter pattern** for new data sources
11
+
12
+ ---
13
+
14
+ ## Architecture Layers
15
+
16
+ ```
17
+ ┌─────────────────────────────────────────────────────────────┐
18
+ │ User Interface │
19
+ │ CLI Commands | File Watcher | VS Code Extension | Web UI │
20
+ ├─────────────────────────────────────────────────────────────┤
21
+ │ Query Engine │
22
+ │ GQL Parser | Graph Store | View Loader │
23
+ ├─────────────────────────────────────────────────────────────┤
24
+ │ Sync Engine │
25
+ │ Conflict Resolution | Delta Detection | State Tracking │
26
+ ├─────────────────────────────────────────────────────────────┤
27
+ │ CRDT Layer │
28
+ │ Loro Documents | Version Tracking | Merge │
29
+ ├─────────────────────────────────────────────────────────────┤
30
+ │ Adapter Interface │
31
+ │ Source Adapters (GitHub, Figma, Google, etc.) │
32
+ ├─────────────────────────────────────────────────────────────┤
33
+ │ Storage Layer │
34
+ │ Local Files | CRDT Persistence | Cache │
35
+ └─────────────────────────────────────────────────────────────┘
36
+ ```
37
+
38
+ ---
39
+
40
+ ## Core Interfaces
41
+
42
+ ### Source Adapter Interface
43
+
44
+ ```typescript
45
+ interface SourceAdapter<TItem, TMetadata = unknown> {
46
+ // Identity
47
+ readonly id: string; // e.g., "github-issues"
48
+ readonly displayName: string; // e.g., "GitHub Issues"
49
+ readonly icon: string; // e.g., "github"
50
+
51
+ // Configuration
52
+ configure(config: AdapterConfig): Promise<void>;
53
+ validateConfig(): Promise<ValidationResult>;
54
+
55
+ // Data Operations
56
+ fetchAll(options?: FetchOptions): AsyncGenerator<TItem>;
57
+ fetchOne(id: string): Promise<TItem>;
58
+ fetchMetadata(): Promise<TMetadata>;
59
+
60
+ create(data: CreateInput<TItem>): Promise<TItem>;
61
+ update(id: string, data: UpdateInput<TItem>): Promise<TItem>;
62
+ delete(id: string): Promise<void>;
63
+
64
+ // CRDT Integration
65
+ itemToCRDT(item: TItem): LoroDocument;
66
+ crdtToItem(doc: LoroDocument): Partial<TItem>;
67
+
68
+ // File Format
69
+ itemToFile(item: TItem): FileContent;
70
+ fileToItem(file: FileContent): ParseResult<TItem>;
71
+
72
+ // Graph Model
73
+ itemToNodes(item: TItem): GraphNode[];
74
+ itemToEdges(item: TItem): GraphEdge[];
75
+
76
+ // Change Detection
77
+ getLastModified(item: TItem): Date;
78
+ getETag?(item: TItem): string;
79
+
80
+ // Webhooks (optional)
81
+ webhookHandler?: WebhookHandler<TItem>;
82
+ }
83
+ ```
84
+
85
+ ### Sync State Interface
86
+
87
+ ```typescript
88
+ interface SyncState {
89
+ // Item tracking
90
+ items: Map<string, ItemSyncState>;
91
+
92
+ // Global metadata
93
+ lastFullSync: Date | null;
94
+ lastIncrementalSync: Date | null;
95
+
96
+ // Adapter-specific state
97
+ adapterState: Record<string, unknown>;
98
+ }
99
+
100
+ interface ItemSyncState {
101
+ id: string;
102
+ localPath: string;
103
+
104
+ // Versions
105
+ remoteVersion: string; // ETag or updated_at
106
+ localVersion: Uint8Array; // CRDT frontiers
107
+
108
+ // Timestamps
109
+ remoteUpdatedAt: Date;
110
+ localUpdatedAt: Date;
111
+ lastSyncAt: Date;
112
+
113
+ // Status
114
+ status: 'synced' | 'local_ahead' | 'remote_ahead' | 'conflict' | 'error';
115
+ errorMessage?: string;
116
+ }
117
+ ```
118
+
119
+ ### File Content Interface
120
+
121
+ ```typescript
122
+ interface FileContent {
123
+ // Front matter (YAML)
124
+ frontmatter: Record<string, unknown>;
125
+
126
+ // Body content
127
+ body: string;
128
+
129
+ // Optional structured data
130
+ data?: Record<string, unknown>;
131
+ }
132
+
133
+ interface ParseResult<T> {
134
+ success: boolean;
135
+ item?: Partial<T>;
136
+ errors?: ParseError[];
137
+ }
138
+ ```
139
+
140
+ ---
141
+
142
+ ## Adapter Implementations
143
+
144
+ ### GitHub Issues Adapter
145
+
146
+ ```typescript
147
+ class GitHubIssuesAdapter implements SourceAdapter<GitHubIssue, RepoMetadata> {
148
+ readonly id = 'github-issues';
149
+ readonly displayName = 'GitHub Issues';
150
+ readonly icon = 'github';
151
+
152
+ private octokit: Octokit;
153
+ private owner: string;
154
+ private repo: string;
155
+
156
+ async *fetchAll(options?: FetchOptions): AsyncGenerator<GitHubIssue> {
157
+ const since = options?.since?.toISOString();
158
+
159
+ for await (const response of this.octokit.paginate.iterator(
160
+ this.octokit.rest.issues.listForRepo,
161
+ {
162
+ owner: this.owner,
163
+ repo: this.repo,
164
+ state: 'all',
165
+ since,
166
+ per_page: 100,
167
+ }
168
+ )) {
169
+ for (const issue of response.data) {
170
+ if (!issue.pull_request) { // Skip PRs
171
+ yield issue as GitHubIssue;
172
+ }
173
+ }
174
+ }
175
+ }
176
+
177
+ itemToCRDT(issue: GitHubIssue): LoroDocument {
178
+ const doc = new Loro();
179
+
180
+ doc.getText('title').insert(0, issue.title);
181
+ doc.getText('body').insert(0, issue.body || '');
182
+
183
+ const labels = doc.getList('labels');
184
+ for (const label of issue.labels) {
185
+ labels.push(typeof label === 'string' ? label : label.name);
186
+ }
187
+
188
+ const assignees = doc.getList('assignees');
189
+ for (const assignee of issue.assignees || []) {
190
+ assignees.push(assignee.login);
191
+ }
192
+
193
+ const meta = doc.getMap('metadata');
194
+ meta.set('state', issue.state);
195
+ meta.set('state_reason', issue.state_reason);
196
+ meta.set('number', issue.number);
197
+ meta.set('id', issue.id);
198
+ meta.set('node_id', issue.node_id);
199
+ meta.set('url', issue.html_url);
200
+ meta.set('milestone', issue.milestone?.title || null);
201
+
202
+ doc.commit();
203
+ return doc;
204
+ }
205
+
206
+ itemToFile(issue: GitHubIssue): FileContent {
207
+ return {
208
+ frontmatter: {
209
+ id: issue.id,
210
+ node_id: issue.node_id,
211
+ number: issue.number,
212
+ url: issue.html_url,
213
+ state: issue.state,
214
+ state_reason: issue.state_reason,
215
+ labels: issue.labels.map(l => typeof l === 'string' ? l : l.name),
216
+ assignees: issue.assignees?.map(a => a.login) || [],
217
+ milestone: issue.milestone?.title || null,
218
+ created_at: issue.created_at,
219
+ updated_at: issue.updated_at,
220
+ },
221
+ body: `# ${issue.title}\n\n${issue.body || ''}`,
222
+ };
223
+ }
224
+
225
+ itemToNodes(issue: GitHubIssue): GraphNode[] {
226
+ const nodes: GraphNode[] = [];
227
+
228
+ // Issue node
229
+ nodes.push({
230
+ _type: 'Issue',
231
+ _id: `github:${this.owner}/${this.repo}#${issue.number}`,
232
+ number: issue.number,
233
+ title: issue.title,
234
+ body: issue.body || '',
235
+ state: issue.state,
236
+ created_at: issue.created_at,
237
+ updated_at: issue.updated_at,
238
+ url: issue.html_url,
239
+ source: 'github',
240
+ });
241
+
242
+ // Label nodes
243
+ for (const label of issue.labels) {
244
+ const labelObj = typeof label === 'string' ? { name: label } : label;
245
+ nodes.push({
246
+ _type: 'Label',
247
+ _id: `github:${this.owner}/${this.repo}:label:${labelObj.name}`,
248
+ name: labelObj.name,
249
+ color: (labelObj as any).color || '',
250
+ description: (labelObj as any).description || null,
251
+ });
252
+ }
253
+
254
+ // User nodes
255
+ for (const assignee of issue.assignees || []) {
256
+ nodes.push({
257
+ _type: 'User',
258
+ _id: `github:${assignee.login}`,
259
+ login: assignee.login,
260
+ avatar_url: assignee.avatar_url,
261
+ source: 'github',
262
+ });
263
+ }
264
+
265
+ return nodes;
266
+ }
267
+
268
+ itemToEdges(issue: GitHubIssue): GraphEdge[] {
269
+ const edges: GraphEdge[] = [];
270
+ const issueId = `github:${this.owner}/${this.repo}#${issue.number}`;
271
+
272
+ // Label edges
273
+ for (const label of issue.labels) {
274
+ const labelName = typeof label === 'string' ? label : label.name;
275
+ edges.push({
276
+ _type: 'HAS_LABEL',
277
+ _from: issueId,
278
+ _to: `github:${this.owner}/${this.repo}:label:${labelName}`,
279
+ });
280
+ }
281
+
282
+ // Assignee edges
283
+ for (const assignee of issue.assignees || []) {
284
+ edges.push({
285
+ _type: 'ASSIGNED_TO',
286
+ _from: issueId,
287
+ _to: `github:${assignee.login}`,
288
+ });
289
+ }
290
+
291
+ // Reference edges (from body parsing)
292
+ for (const ref of this.parseReferences(issue.body || '')) {
293
+ edges.push({
294
+ _type: 'REFERENCES',
295
+ _from: issueId,
296
+ _to: ref,
297
+ });
298
+ }
299
+
300
+ return edges;
301
+ }
302
+ }
303
+ ```
304
+
305
+ ### Figma Comments Adapter (Example)
306
+
307
+ ```typescript
308
+ class FigmaCommentsAdapter implements SourceAdapter<FigmaComment, FileMetadata> {
309
+ readonly id = 'figma-comments';
310
+ readonly displayName = 'Figma Comments';
311
+ readonly icon = 'figma';
312
+
313
+ async *fetchAll(): AsyncGenerator<FigmaComment> {
314
+ const response = await fetch(
315
+ `https://api.figma.com/v1/files/${this.fileKey}/comments`,
316
+ { headers: { 'X-Figma-Token': this.token } }
317
+ );
318
+ const data = await response.json();
319
+ yield* data.comments;
320
+ }
321
+
322
+ itemToFile(comment: FigmaComment): FileContent {
323
+ return {
324
+ frontmatter: {
325
+ id: comment.id,
326
+ file_key: this.fileKey,
327
+ node_id: comment.client_meta?.node_id,
328
+ author: comment.user.handle,
329
+ created_at: comment.created_at,
330
+ resolved_at: comment.resolved_at,
331
+ },
332
+ body: comment.message,
333
+ };
334
+ }
335
+
336
+ // ... other methods
337
+ }
338
+ ```
339
+
340
+ ### Google Calendar Events Adapter (Example)
341
+
342
+ ```typescript
343
+ class GoogleCalendarAdapter implements SourceAdapter<CalendarEvent, CalendarMetadata> {
344
+ readonly id = 'google-calendar';
345
+ readonly displayName = 'Google Calendar';
346
+ readonly icon = 'calendar';
347
+
348
+ async *fetchAll(options?: FetchOptions): AsyncGenerator<CalendarEvent> {
349
+ const calendar = google.calendar({ version: 'v3', auth: this.auth });
350
+
351
+ let pageToken: string | undefined;
352
+ do {
353
+ const response = await calendar.events.list({
354
+ calendarId: this.calendarId,
355
+ timeMin: options?.since?.toISOString(),
356
+ maxResults: 100,
357
+ pageToken,
358
+ singleEvents: true,
359
+ orderBy: 'startTime',
360
+ });
361
+
362
+ for (const event of response.data.items || []) {
363
+ yield event as CalendarEvent;
364
+ }
365
+
366
+ pageToken = response.data.nextPageToken || undefined;
367
+ } while (pageToken);
368
+ }
369
+
370
+ itemToFile(event: CalendarEvent): FileContent {
371
+ return {
372
+ frontmatter: {
373
+ id: event.id,
374
+ calendar_id: this.calendarId,
375
+ status: event.status,
376
+ summary: event.summary,
377
+ start: event.start?.dateTime || event.start?.date,
378
+ end: event.end?.dateTime || event.end?.date,
379
+ location: event.location,
380
+ attendees: event.attendees?.map(a => a.email),
381
+ recurrence: event.recurrence,
382
+ updated_at: event.updated,
383
+ },
384
+ body: event.description || '',
385
+ };
386
+ }
387
+
388
+ itemToNodes(event: CalendarEvent): GraphNode[] {
389
+ return [{
390
+ _type: 'CalendarEvent',
391
+ _id: `gcal:${this.calendarId}:${event.id}`,
392
+ summary: event.summary || '',
393
+ start: event.start?.dateTime || event.start?.date || '',
394
+ end: event.end?.dateTime || event.end?.date || '',
395
+ location: event.location || null,
396
+ source: 'google-calendar',
397
+ }];
398
+ }
399
+ }
400
+ ```
401
+
402
+ ---
403
+
404
+ ## Sync Engine
405
+
406
+ ```typescript
407
+ class SyncEngine {
408
+ constructor(
409
+ private adapters: Map<string, SourceAdapter<any>>,
410
+ private storage: StorageProvider,
411
+ private stateManager: SyncStateManager,
412
+ private graphStore: GraphStore,
413
+ ) {}
414
+
415
+ // Full sync - pull everything
416
+ async fullSync(adapterId: string): Promise<SyncResult> {
417
+ const adapter = this.adapters.get(adapterId);
418
+ if (!adapter) throw new Error(`Unknown adapter: ${adapterId}`);
419
+
420
+ const results: SyncItemResult[] = [];
421
+
422
+ for await (const item of adapter.fetchAll()) {
423
+ const result = await this.syncItem(adapter, item, 'pull');
424
+ results.push(result);
425
+ }
426
+
427
+ // Update metadata
428
+ const metadata = await adapter.fetchMetadata();
429
+ await this.storage.saveMetadata(adapterId, metadata);
430
+
431
+ // Rebuild graph
432
+ await this.rebuildGraph(adapterId);
433
+
434
+ // Update state
435
+ await this.stateManager.setLastFullSync(adapterId, new Date());
436
+
437
+ return { adapterId, results };
438
+ }
439
+
440
+ // Incremental sync - only changed items
441
+ async incrementalSync(adapterId: string): Promise<SyncResult> {
442
+ const adapter = this.adapters.get(adapterId);
443
+ if (!adapter) throw new Error(`Unknown adapter: ${adapterId}`);
444
+
445
+ const lastSync = await this.stateManager.getLastSync(adapterId);
446
+ const results: SyncItemResult[] = [];
447
+
448
+ // Pull remote changes
449
+ for await (const item of adapter.fetchAll({ since: lastSync })) {
450
+ const result = await this.syncItem(adapter, item, 'pull');
451
+ results.push(result);
452
+ }
453
+
454
+ // Push local changes
455
+ const localChanges = await this.stateManager.getLocalChanges(adapterId);
456
+ for (const itemId of localChanges) {
457
+ const result = await this.pushLocalChange(adapter, itemId);
458
+ results.push(result);
459
+ }
460
+
461
+ // Update graph incrementally
462
+ await this.updateGraph(adapterId, results);
463
+
464
+ await this.stateManager.setLastSync(adapterId, new Date());
465
+
466
+ return { adapterId, results };
467
+ }
468
+
469
+ // Sync single item
470
+ private async syncItem<T>(
471
+ adapter: SourceAdapter<T>,
472
+ remoteItem: T,
473
+ direction: 'pull' | 'push' | 'both'
474
+ ): Promise<SyncItemResult> {
475
+ const itemId = this.getItemId(adapter, remoteItem);
476
+ const state = await this.stateManager.getItemState(adapter.id, itemId);
477
+
478
+ // Load or create CRDT
479
+ let doc = await this.storage.loadCRDT(adapter.id, itemId);
480
+ if (!doc) {
481
+ doc = adapter.itemToCRDT(remoteItem);
482
+ }
483
+
484
+ // Check sync status
485
+ const remoteUpdatedAt = adapter.getLastModified(remoteItem);
486
+ const status = this.computeSyncStatus(state, remoteUpdatedAt, doc);
487
+
488
+ switch (status) {
489
+ case 'synced':
490
+ return { itemId, status: 'unchanged' };
491
+
492
+ case 'remote_ahead':
493
+ // Merge remote into local
494
+ const remoteDoc = adapter.itemToCRDT(remoteItem);
495
+ doc.import(remoteDoc.export({ mode: 'update' }));
496
+ break;
497
+
498
+ case 'local_ahead':
499
+ // Push local to remote
500
+ const updates = adapter.crdtToItem(doc);
501
+ await adapter.update(itemId, updates);
502
+ break;
503
+
504
+ case 'conflict':
505
+ // CRDT merge + push
506
+ const remoteDocConflict = adapter.itemToCRDT(remoteItem);
507
+ doc.import(remoteDocConflict.export({ mode: 'update' }));
508
+ const mergedUpdates = adapter.crdtToItem(doc);
509
+ await adapter.update(itemId, mergedUpdates);
510
+ break;
511
+ }
512
+
513
+ // Save CRDT
514
+ await this.storage.saveCRDT(adapter.id, itemId, doc);
515
+
516
+ // Save file
517
+ const fileContent = adapter.itemToFile(remoteItem);
518
+ await this.storage.saveFile(adapter.id, itemId, fileContent);
519
+
520
+ // Update state
521
+ await this.stateManager.updateItemState(adapter.id, itemId, {
522
+ remoteVersion: adapter.getETag?.(remoteItem) || remoteUpdatedAt.toISOString(),
523
+ localVersion: doc.export({ mode: 'snapshot' }),
524
+ remoteUpdatedAt,
525
+ localUpdatedAt: new Date(),
526
+ lastSyncAt: new Date(),
527
+ status: 'synced',
528
+ });
529
+
530
+ return { itemId, status: 'updated' };
531
+ }
532
+ }
533
+ ```
534
+
535
+ ---
536
+
537
+ ## Storage Provider
538
+
539
+ ```typescript
540
+ interface StorageProvider {
541
+ // CRDT storage
542
+ loadCRDT(adapterId: string, itemId: string): Promise<LoroDocument | null>;
543
+ saveCRDT(adapterId: string, itemId: string, doc: LoroDocument): Promise<void>;
544
+
545
+ // File storage
546
+ loadFile(adapterId: string, itemId: string): Promise<FileContent | null>;
547
+ saveFile(adapterId: string, itemId: string, content: FileContent): Promise<void>;
548
+ deleteFile(adapterId: string, itemId: string): Promise<void>;
549
+ listFiles(adapterId: string): AsyncGenerator<string>;
550
+
551
+ // Metadata storage
552
+ loadMetadata(adapterId: string): Promise<unknown>;
553
+ saveMetadata(adapterId: string, metadata: unknown): Promise<void>;
554
+ }
555
+
556
+ class FileSystemStorageProvider implements StorageProvider {
557
+ constructor(private baseDir: string) {}
558
+
559
+ private getPath(adapterId: string, itemId: string, ext: string): string {
560
+ const sanitized = itemId.replace(/[^a-zA-Z0-9-]/g, '_');
561
+ return path.join(this.baseDir, adapterId, `${sanitized}.${ext}`);
562
+ }
563
+
564
+ async saveCRDT(adapterId: string, itemId: string, doc: LoroDocument): Promise<void> {
565
+ const filePath = this.getPath(adapterId, itemId, 'loro');
566
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
567
+ await fs.writeFile(filePath, Buffer.from(doc.export({ mode: 'snapshot' })));
568
+ }
569
+
570
+ async saveFile(adapterId: string, itemId: string, content: FileContent): Promise<void> {
571
+ const filePath = this.getPath(adapterId, itemId, 'md');
572
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
573
+
574
+ const yaml = YAML.stringify(content.frontmatter);
575
+ const fileContent = `---\n${yaml}---\n\n${content.body}`;
576
+
577
+ await fs.writeFile(filePath, fileContent, 'utf-8');
578
+ }
579
+ }
580
+ ```
581
+
582
+ ---
583
+
584
+ ## Directory Structure
585
+
586
+ ```
587
+ .hardcopy/
588
+ ├── hardcopy.yaml # Global configuration
589
+ ├── sync-state.yaml # Sync state tracking
590
+ ├── crdt/ # CRDT binary files
591
+ │ ├── github-issues/
592
+ │ │ ├── issue-42.loro
593
+ │ │ └── issue-43.loro
594
+ │ ├── figma-comments/
595
+ │ └── google-calendar/
596
+ ├── adapters/
597
+ │ ├── github-issues/
598
+ │ │ ├── issues/
599
+ │ │ │ ├── 042-fix-login-bug.md
600
+ │ │ │ └── 043-add-dark-mode.md
601
+ │ │ ├── projects/
602
+ │ │ │ └── sprint-board/
603
+ │ │ │ ├── metadata.yaml
604
+ │ │ │ └── view.yaml
605
+ │ │ └── metadata/
606
+ │ │ ├── labels.yaml
607
+ │ │ ├── milestones.yaml
608
+ │ │ └── users.yaml
609
+ │ ├── figma-comments/
610
+ │ │ └── comments/
611
+ │ └── google-calendar/
612
+ │ └── events/
613
+ ├── views/ # Custom views
614
+ │ ├── my-issues.yaml
615
+ │ ├── sprint-board.yaml
616
+ │ └── roadmap.yaml
617
+ └── graph/ # Graph exports
618
+ └── graph.json
619
+ ```
620
+
621
+ ---
622
+
623
+ ## Configuration
624
+
625
+ ```yaml
626
+ # .hardcopy/hardcopy.yaml
627
+ version: 1
628
+
629
+ # Adapter configurations
630
+ adapters:
631
+ github-issues:
632
+ enabled: true
633
+ owner: myorg
634
+ repo: myrepo
635
+ sync_interval: 5m
636
+ include_prs: false
637
+
638
+ github-projects:
639
+ enabled: true
640
+ project_numbers: [1, 2]
641
+
642
+ figma-comments:
643
+ enabled: true
644
+ file_keys:
645
+ - abc123
646
+ - def456
647
+
648
+ google-calendar:
649
+ enabled: false
650
+ calendar_id: primary
651
+
652
+ # Sync settings
653
+ sync:
654
+ auto_sync: true
655
+ interval: 5m
656
+ on_file_change: true
657
+ conflict_strategy: crdt_merge # crdt_merge | local_wins | remote_wins | prompt
658
+
659
+ # File settings
660
+ files:
661
+ format: markdown # markdown | yaml | json
662
+ naming: "{number}-{title}"
663
+ max_title_length: 50
664
+
665
+ # Graph settings
666
+ graph:
667
+ auto_rebuild: true
668
+ include_references: true
669
+ max_depth: 5
670
+ ```
671
+
672
+ ---
673
+
674
+ ## CLI Commands
675
+
676
+ ```bash
677
+ # Initialize hardcopy in current directory
678
+ hardcopy init
679
+
680
+ # Configure an adapter
681
+ hardcopy config github-issues --owner myorg --repo myrepo
682
+
683
+ # Full sync
684
+ hardcopy sync
685
+ hardcopy sync github-issues
686
+
687
+ # Watch for changes
688
+ hardcopy watch
689
+
690
+ # Query data
691
+ hardcopy query "state:open labels:bug"
692
+ hardcopy query "MATCH (i:Issue)-[:BLOCKS]->(b:Issue) RETURN i, b"
693
+
694
+ # Show sync status
695
+ hardcopy status
696
+ hardcopy status github-issues
697
+
698
+ # Show dependency graph
699
+ hardcopy deps 42
700
+
701
+ # Export graph
702
+ hardcopy export graph.json
703
+ ```
704
+
705
+ ---
706
+
707
+ ## Event Flow
708
+
709
+ ```
710
+ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
711
+ │ Remote │────▶│ Adapter │────▶│ CRDT │────▶│ File │
712
+ │ API │ │ │ │ Merge │ │ Write │
713
+ └──────────┘ └──────────┘ └──────────┘ └──────────┘
714
+
715
+
716
+ ┌──────────┐
717
+ │ Graph │
718
+ │ Update │
719
+ └──────────┘
720
+
721
+ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
722
+ │ File │────▶│ Parser │────▶│ CRDT │────▶│ Adapter │
723
+ │ Change │ │ │ │ Update │ │ Push │
724
+ └──────────┘ └──────────┘ └──────────┘ └──────────┘
725
+ ```
726
+
727
+ ---
728
+
729
+ ## Extension Points
730
+
731
+ 1. **Custom Adapters**: Implement `SourceAdapter` interface for new data sources
732
+ 2. **Custom File Formats**: Override `itemToFile`/`fileToItem` methods
733
+ 3. **Custom Graph Nodes**: Add domain-specific node/edge types
734
+ 4. **Custom Views**: Define YAML view configurations
735
+ 5. **Webhooks**: Implement real-time sync via webhooks
736
+ 6. **Transformers**: Add pre/post processing hooks
737
+
738
+ ---
739
+
740
+ ## Next Steps
741
+
742
+ 1. Implement core `SyncEngine` with GitHub Issues adapter
743
+ 2. Add file watcher for local change detection
744
+ 3. Build graph query interface
745
+ 4. Create VS Code extension for visual board views
746
+ 5. Add Figma and Google Calendar adapters
747
+ 6. Implement webhook support for real-time sync