@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,742 @@
1
+ # Hardcopy: Local-Remote Sync System
2
+
3
+ ## Overview
4
+
5
+ Hardcopy synchronizes remote resources (GitHub, Jira, Google Docs, A2A agents, Git) to a local file tree with bi-directional editing. Uses a graph database for relationships and CRDT for conflict-free merges.
6
+
7
+ ---
8
+
9
+ ## Architecture
10
+
11
+ ```
12
+ ┌─────────────────────────────────────────────────────────────────┐
13
+ │ Providers │
14
+ │ github │ jira │ linear │ a2a │ git │ gdocs │ confluence │
15
+ └────────────────────────┬────────────────────────────────────────┘
16
+ │ fetch / push
17
+
18
+ ┌─────────────────────────────────────────────────────────────────┐
19
+ │ LibSQL + GraphQLite │
20
+ │ (Node/Edge attributes, sync state, pagination) │
21
+ └────────────────────────┬────────────────────────────────────────┘
22
+
23
+ ┌──────────────┴──────────────┐
24
+ ▼ ▼
25
+ ┌──────────────────┐ ┌──────────────────────────────────┐
26
+ │ CRDT Store │ │ File Tree │
27
+ │ (Loro snapshots) │ │ (Markdown bodies, blobs, diffs) │
28
+ └──────────────────┘ └──────────────────────────────────┘
29
+ ```
30
+
31
+ ### Storage Split
32
+
33
+ | Data Type | Storage Location | Rationale |
34
+ |-----------|------------------|-----------|
35
+ | Node attributes (title, state, labels) | LibSQL | Fast queries, indexes |
36
+ | Edge relationships | LibSQL (GraphQLite) | Graph traversal |
37
+ | Sync state (version tokens, cursors) | LibSQL | Durability |
38
+ | Document bodies (Markdown, rich text) | File tree | Editable, diffable |
39
+ | CRDT snapshots (per-node) | `.hardcopy/crdt/{node_id}` | Granular conflict resolution |
40
+ | Binary blobs | File tree | Direct access |
41
+
42
+ ### CRDT Strategy: Per-Node
43
+
44
+ Each node gets its own CRDT document stored at `.hardcopy/crdt/{encoded_node_id}.loro`. This enables:
45
+ - Granular sync — only changed nodes need conflict resolution
46
+ - Independent versioning — nodes sync at different rates
47
+ - Isolated failures — one conflict doesn't block others
48
+
49
+ Tradeoff: more files, but nodes are typically small and compression helps.
50
+
51
+ ---
52
+
53
+ ## Namespaced Types
54
+
55
+ Types and relationships are namespaced by provider to avoid collisions:
56
+
57
+ ```
58
+ github.Issue # GitHub issue
59
+ jira.Issue # Jira issue
60
+ linear.Issue # Linear issue
61
+ a2a.Task # Agent task
62
+ git.Branch # Git branch
63
+ git.Worktree # Git worktree
64
+ gdocs.Document # Google Doc
65
+ ```
66
+
67
+ ### Edge Types (also namespaced)
68
+
69
+ ```
70
+ github.ASSIGNED_TO # Issue -> User
71
+ github.HAS_LABEL # Issue -> Label
72
+ github.REFERENCES # Issue -> Issue (cross-reference)
73
+ a2a.TRACKS # Task -> github.Issue
74
+ git.TRACKS # Branch -> a2a.Task
75
+ ```
76
+
77
+ ### Cross-Provider Links
78
+
79
+ ```cypher
80
+ -- Agent task linked to GitHub issue, tracked by Git branch
81
+ MATCH (t:a2a.Task)-[:a2a.TRACKS]->(i:github.Issue)
82
+ MATCH (b:git.Branch)-[:git.TRACKS]->(t)
83
+ RETURN t, i, b
84
+ ```
85
+
86
+ ---
87
+
88
+ ## Query Engine: GraphQLite on LibSQL
89
+
90
+ Use [graphqlite](https://github.com/colliery-io/graphqlite) SQLite extension on LibSQL for embedded graph queries.
91
+
92
+ ### Schema
93
+
94
+ ```sql
95
+ -- Nodes table (all types)
96
+ CREATE TABLE nodes (
97
+ id TEXT PRIMARY KEY, -- "github:owner/repo#42"
98
+ type TEXT NOT NULL, -- "github.Issue"
99
+ attrs JSONB NOT NULL, -- { title, state, labels, ... }
100
+ synced_at INTEGER, -- Unix timestamp
101
+ version_token TEXT, -- Provider-managed cache/version token
102
+ cursor TEXT -- Pagination cursor for children
103
+ );
104
+
105
+ CREATE INDEX idx_nodes_type ON nodes(type);
106
+ CREATE INDEX idx_nodes_synced ON nodes(synced_at);
107
+
108
+ -- Edges table
109
+ CREATE TABLE edges (
110
+ id INTEGER PRIMARY KEY,
111
+ type TEXT NOT NULL, -- "github.ASSIGNED_TO"
112
+ from_id TEXT NOT NULL REFERENCES nodes(id),
113
+ to_id TEXT NOT NULL REFERENCES nodes(id),
114
+ attrs JSONB, -- Edge properties
115
+ UNIQUE(type, from_id, to_id)
116
+ );
117
+
118
+ CREATE INDEX idx_edges_from ON edges(from_id);
119
+ CREATE INDEX idx_edges_to ON edges(to_id);
120
+ CREATE INDEX idx_edges_type ON edges(type);
121
+ ```
122
+
123
+ ### Query Translation
124
+
125
+ GraphQLite provides Cypher-like syntax over SQLite:
126
+
127
+ ```cypher
128
+ -- Find my open tasks with linked issues and branches
129
+ MATCH (t:a2a.Task {status: 'in-progress'})
130
+ MATCH (t)-[:a2a.TRACKS]->(i:github.Issue {state: 'open'})
131
+ OPTIONAL MATCH (b:git.Branch)-[:git.TRACKS]->(t)
132
+ WHERE i.attrs->>'assignee' = $me
133
+ RETURN t, i, b
134
+ ```
135
+
136
+ ---
137
+
138
+ ## POC Config
139
+
140
+ ```yaml
141
+ # hardcopy/hardcopy.yaml
142
+ sources:
143
+ - name: github
144
+ provider: github
145
+ orgs: [AprovanLabs, JacobSampson]
146
+
147
+ - name: agents
148
+ provider: a2a
149
+ # Populated by A2A protocol - tracks agent execution, tasks, progress
150
+ # Links to GitHub issues via explicit task metadata
151
+ links:
152
+ - edge: a2a.TRACKS
153
+ to: github.Issue
154
+ # Task metadata includes github.issue_number and github.repository
155
+ match: "github:{{task.meta.github.repository}}#{{task.meta.github.issue_number}}"
156
+
157
+ - name: git
158
+ provider: git
159
+ repositories:
160
+ - path: ~/AprovanLabs/**
161
+ # Explicit linking config — don't rely on branch naming conventions
162
+ links:
163
+ - edge: git.TRACKS
164
+ to: a2a.Task
165
+ # Option 1: Parse from branch name (if convention is used)
166
+ # match: "a2a:{{branch.name | regex_extract: 'task-([0-9]+)'}}"
167
+ # Option 2: Use A2A session metadata (preferred)
168
+ match: "a2a:{{branch.meta.a2a.task_id}}"
169
+
170
+ views:
171
+ - path: my-tasks
172
+ description: "Open agent tasks with linked GitHub issues and Git branches"
173
+ query: |
174
+ MATCH (t:a2a.Task)
175
+ WHERE t.attrs->>'status' <> 'completed'
176
+ MATCH (t)-[:a2a.TRACKS]->(i:github.Issue)
177
+ WHERE i.attrs->>'state' = 'open'
178
+ AND i.attrs->>'assignee' = $me
179
+ OPTIONAL MATCH (b:git.Branch)-[:git.TRACKS]->(t)
180
+ RETURN t, i, b
181
+ ORDER BY t.attrs->>'updated_at' DESC
182
+
183
+ partition:
184
+ by: b.attrs->>'name'
185
+ fallback: _untracked
186
+
187
+ render:
188
+ - path: status.md
189
+ template: |
190
+ # {{t.attrs.name}}
191
+
192
+ **Status:** {{t.attrs.status}}
193
+ **Branch:** {{b.attrs.name | default: "No branch"}}
194
+
195
+ ## Linked Issue
196
+ - [#{{i.attrs.number}}]({{i.attrs.url}}) {{i.attrs.title}}
197
+
198
+ - path: "{{i.attrs.number}}.github.issue.md"
199
+ type: github.issue
200
+
201
+ - path: diff.patch
202
+ type: git.diff
203
+ args:
204
+ ref: "{{b.attrs.name}}"
205
+ base: HEAD
206
+
207
+ - path: zolvery
208
+ description: "Open issues in zolvery repo"
209
+ query: |
210
+ MATCH (i:github.Issue)
211
+ WHERE i.attrs->>'repository' = 'zolvery'
212
+ AND i.attrs->>'state' = 'open'
213
+ RETURN i
214
+ ORDER BY i.attrs->>'updated_at' DESC
215
+
216
+ render:
217
+ - path: "{{i.attrs.number}}.github.issue.md"
218
+ type: github.issue
219
+ ```
220
+
221
+ ---
222
+
223
+ ## Lazy Loading Strategy
224
+
225
+ ### File Tree as Discovery Mechanism
226
+
227
+ ```
228
+ hardcopy/
229
+ ├── hardcopy.yaml
230
+ ├── .hardcopy/
231
+ │ ├── db.sqlite # LibSQL database
232
+ │ └── crdt/ # CRDT snapshots
233
+ ├── my-tasks/ # View directory (metadata only until opened)
234
+ │ ├── .index # Pagination state, total count
235
+ │ ├── feature/
236
+ │ │ └── auth-refactor/
237
+ │ │ ├── status.md
238
+ │ │ ├── 123.github.issue.md
239
+ │ │ └── diff.patch
240
+ │ └── feature/
241
+ │ └── new-api/
242
+ │ └── ...
243
+ └── zolvery/
244
+ ├── .index # { cursor: "abc", total: 47, loaded: 10 }
245
+ ├── 101.github.issue.md
246
+ ├── 102.github.issue.md
247
+ └── ... # Only first page loaded
248
+ ```
249
+
250
+ ### Loading Behavior
251
+
252
+ 1. **View directory exists** → Show folders from cached index, don't fetch
253
+ 2. **User opens folder** → Fetch first page of children, create `.index`
254
+ 3. **User scrolls/requests more** → Load next page, update cursor
255
+ 4. **TTL expires** → Re-fetch on next access, merge with CRDT
256
+
257
+ ### Index File
258
+
259
+ ```yaml
260
+ # zolvery/.index
261
+ cursor: "Y3Vyc29yOnYyOpK5MjAyNi0wMi0yMVQxMDowMDowMFo"
262
+ total: 47
263
+ loaded: 10
264
+ page_size: 10
265
+ last_fetch: 2026-02-21T10:30:00Z
266
+ ttl: 300 # seconds
267
+ ```
268
+
269
+ ---
270
+
271
+ ## Bi-Directional Sync
272
+
273
+ ### Sync Flow
274
+
275
+ ```
276
+ ┌─────────────┐ edit ┌─────────────┐ save ┌─────────────┐
277
+ │ File Tree │ ─────────> │ CRDT Merge │ ─────────> │ Decide │
278
+ └─────────────┘ └─────────────┘ └──────┬──────┘
279
+
280
+ ┌─────────────────────────────────┼─────────────────────────────────┐
281
+ │ │ │
282
+ ▼ ▼ ▼
283
+ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
284
+ │ API Push │ │ LLM Resolve │ │ User Alert │
285
+ │ (auto) │ │ (via UTCP) │ │ (conflict) │
286
+ └─────────────┘ └─────────────┘ └─────────────┘
287
+ ```
288
+
289
+ ### Decision Logic
290
+
291
+ ```typescript
292
+ interface SyncDecision {
293
+ strategy: 'auto' | 'llm' | 'manual';
294
+ reason: string;
295
+ }
296
+
297
+ function decideSyncStrategy(
298
+ localCRDT: Loro,
299
+ remoteCRDT: Loro,
300
+ provider: Provider
301
+ ): SyncDecision {
302
+ // 1. Try CRDT merge
303
+ const merged = localCRDT.fork();
304
+ merged.import(remoteCRDT.export({ mode: 'update' }));
305
+
306
+ // 2. Check for conflicts
307
+ const conflicts = detectConflicts(localCRDT, remoteCRDT, merged);
308
+
309
+ if (conflicts.length === 0) {
310
+ // Clean merge - check if API supports direct update
311
+ if (provider.supportsAtomicUpdate()) {
312
+ return { strategy: 'auto', reason: 'Clean merge, API supports update' };
313
+ }
314
+ return { strategy: 'llm', reason: 'Clean merge, but API needs orchestration' };
315
+ }
316
+
317
+ // 3. Conflicts exist - can LLM resolve?
318
+ if (conflicts.every(c => c.resolvable)) {
319
+ return { strategy: 'llm', reason: `${conflicts.length} resolvable conflicts` };
320
+ }
321
+
322
+ // 4. Unresolvable - escalate to user
323
+ return { strategy: 'manual', reason: 'Unresolvable conflicts detected' };
324
+ }
325
+ ```
326
+
327
+ ### LLM Resolution (via UTCP)
328
+
329
+ When CRDT can't auto-merge or API needs orchestration:
330
+
331
+ ```typescript
332
+ interface ReconciliationRequest {
333
+ local: {
334
+ content: string; // Current local file
335
+ crdt: Uint8Array; // CRDT state
336
+ };
337
+ remote: {
338
+ content: string; // Fetched remote state
339
+ crdt: Uint8Array;
340
+ };
341
+ diff: string; // Unified diff
342
+ resourceType: string; // "github.issue"
343
+ resourceId: string; // "github:owner/repo#42"
344
+ }
345
+
346
+ // LLM has access to provider tools via UTCP
347
+ const tools = [
348
+ 'github.updateIssue',
349
+ 'github.addLabels',
350
+ 'github.removeLabels',
351
+ 'github.updateIssueBody',
352
+ // ...
353
+ ];
354
+
355
+ // Prompt template
356
+ const prompt = `
357
+ Reconcile the following local and remote changes to a ${request.resourceType}.
358
+
359
+ ## Local Version
360
+ ${request.local.content}
361
+
362
+ ## Remote Version
363
+ ${request.remote.content}
364
+
365
+ ## Diff
366
+ ${request.diff}
367
+
368
+ Use the available tools to update the remote resource to reflect the intended changes.
369
+ If you cannot determine the user's intent, explain the conflict and suggest options.
370
+ `;
371
+ ```
372
+
373
+ ### Error Handling
374
+
375
+ ```typescript
376
+ interface SyncError {
377
+ resourceId: string;
378
+ strategy: 'auto' | 'llm';
379
+ error: string;
380
+ llmExplanation?: string; // If LLM attempted resolution
381
+ suggestedActions?: string[];
382
+ }
383
+
384
+ // Surface to user
385
+ function reportSyncError(error: SyncError): void {
386
+ // Write to .hardcopy/errors/{resourceId}.md
387
+ // Notify via configured channel (file watcher, webhook, etc.)
388
+ }
389
+ ```
390
+
391
+ ---
392
+
393
+ ## Provider Interface
394
+
395
+ ```typescript
396
+ interface Provider {
397
+ name: string;
398
+ nodeTypes: string[]; // ["github.Issue", "github.Label", ...]
399
+ edgeTypes: string[]; // ["github.ASSIGNED_TO", ...]
400
+
401
+ // Fetch with optional caching
402
+ // Provider manages its own caching strategy (ETags, timestamps, etc.)
403
+ fetch(request: FetchRequest): Promise<FetchResult>;
404
+
405
+ // Push changes to remote
406
+ push(node: Node, changes: Change[]): Promise<PushResult>;
407
+
408
+ // Tools (for LLM reconciliation)
409
+ getTools(): Tool[];
410
+ }
411
+
412
+ interface FetchRequest {
413
+ query: NodeQuery;
414
+ cursor?: string;
415
+ pageSize?: number;
416
+ // Cached version token from previous fetch (provider-specific format)
417
+ versionToken?: string;
418
+ }
419
+
420
+ interface FetchResult {
421
+ nodes: Node[];
422
+ edges: Edge[];
423
+ cursor?: string;
424
+ hasMore: boolean;
425
+ // Provider returns new version token for caching
426
+ // null if provider doesn't support caching, undefined if unchanged
427
+ versionToken?: string | null;
428
+ // True if data unchanged from cache (provider handled internally)
429
+ cached?: boolean;
430
+ }
431
+ ```
432
+
433
+ ### Git Provider (Example)
434
+
435
+ ```typescript
436
+ const gitProvider: Provider = {
437
+ name: 'git',
438
+ nodeTypes: ['git.Branch', 'git.Worktree', 'git.Commit'],
439
+ edgeTypes: ['git.TRACKS', 'git.CONTAINS', 'git.WORKTREE_OF'],
440
+
441
+ async fetch(request: FetchRequest): Promise<FetchResult> {
442
+ const results: FetchResult = { nodes: [], edges: [], hasMore: false };
443
+
444
+ for (const repo of config.repositories) {
445
+ // Version token for git is the HEAD commit SHA
446
+ const currentHead = await execGit(repo.path, 'rev-parse', 'HEAD');
447
+ if (request.versionToken === currentHead) {
448
+ return { ...results, cached: true, versionToken: currentHead };
449
+ }
450
+
451
+ const worktrees = await execGit(repo.path, 'worktree', 'list', '--porcelain');
452
+ const branches = await execGit(repo.path, 'branch', '-a', '--format=%(refname:short)');
453
+
454
+ // Add worktree nodes
455
+ for (const wt of worktrees) {
456
+ results.nodes.push({
457
+ id: `git:worktree:${wt.path}`,
458
+ type: 'git.Worktree',
459
+ attrs: {
460
+ path: wt.path,
461
+ branch: wt.branch,
462
+ bare: wt.bare,
463
+ // A2A metadata if present (set by agent when creating worktree)
464
+ meta: await readWorktreeMeta(wt.path),
465
+ }
466
+ });
467
+ }
468
+
469
+ // Add branch nodes
470
+ for (const branch of branches) {
471
+ const branchNode = {
472
+ id: `git:branch:${repo.path}:${branch}`,
473
+ type: 'git.Branch',
474
+ attrs: {
475
+ name: branch,
476
+ repository: repo.path,
477
+ lastCommit: await getLastCommit(repo.path, branch),
478
+ // Check if any worktree is on this branch
479
+ worktreePath: worktrees.find(wt => wt.branch === branch)?.path,
480
+ }
481
+ };
482
+ results.nodes.push(branchNode);
483
+
484
+ // Create links based on explicit config
485
+ const taskId = await resolveTaskLink(branch, config.links);
486
+ if (taskId) {
487
+ results.edges.push({
488
+ type: 'git.TRACKS',
489
+ from_id: branchNode.id,
490
+ to_id: taskId,
491
+ });
492
+ }
493
+ }
494
+ }
495
+
496
+ // Return new version token (latest HEAD)
497
+ const latestHead = await execGit(config.repositories[0].path, 'rev-parse', 'HEAD');
498
+ return { ...results, versionToken: latestHead };
499
+ },
500
+
501
+ async push(node, changes) {
502
+ // For diff generation, switch to worktree directory if needed
503
+ const workdir = node.attrs.worktreePath || node.attrs.repository;
504
+ return execGit(workdir, 'push', ...args);
505
+ },
506
+
507
+ // Generate diff from worktree location for accurate results
508
+ async getDiff(branch: string, base: string): Promise<string> {
509
+ const node = await getNode(`git:branch:*:${branch}`);
510
+ const workdir = node.attrs.worktreePath || node.attrs.repository;
511
+ return execGit(workdir, 'diff', base, branch);
512
+ },
513
+
514
+ getTools: () => [
515
+ { name: 'git.checkout', description: 'Checkout branch' },
516
+ { name: 'git.push', description: 'Push changes' },
517
+ { name: 'git.createBranch', description: 'Create new branch' },
518
+ { name: 'git.createWorktree', description: 'Create worktree for branch' },
519
+ ],
520
+ };
521
+
522
+ // Helper: Read A2A metadata from worktree (if agent left it)
523
+ async function readWorktreeMeta(path: string): Promise<Record<string, any> | null> {
524
+ const metaPath = join(path, '.a2a', 'session.json');
525
+ if (await exists(metaPath)) {
526
+ return JSON.parse(await readFile(metaPath, 'utf-8'));
527
+ }
528
+ return null;
529
+ }
530
+
531
+ // Helper: Resolve task link from config
532
+ async function resolveTaskLink(
533
+ branch: { name: string; meta?: Record<string, any> },
534
+ links: LinkConfig[]
535
+ ): Promise<string | null> {
536
+ for (const link of links) {
537
+ if (link.edge === 'git.TRACKS') {
538
+ // Try A2A metadata first (preferred)
539
+ if (branch.meta?.a2a?.task_id) {
540
+ return `a2a:${branch.meta.a2a.task_id}`;
541
+ }
542
+ // Fallback to branch name pattern if configured
543
+ if (link.match.includes('regex_extract')) {
544
+ const pattern = extractPattern(link.match);
545
+ const match = branch.name.match(pattern);
546
+ if (match) return `a2a:${match[1]}`;
547
+ }
548
+ }
549
+ }
550
+ return null;
551
+ }
552
+ ```
553
+
554
+ ---
555
+
556
+ ## CLI Commands
557
+
558
+ ```bash
559
+ # Initialize hardcopy in current directory
560
+ hardcopy init
561
+
562
+ # Manual sync (fetch all sources, update graph)
563
+ hardcopy sync
564
+
565
+ # Refresh specific view (lazy-load first page)
566
+ hardcopy refresh my-tasks
567
+
568
+ # Push local changes to remotes
569
+ hardcopy push
570
+
571
+ # Push specific file
572
+ hardcopy push my-tasks/feature/auth/123.github.issue.md
573
+
574
+ # Show sync status (pending changes, conflicts)
575
+ hardcopy status
576
+
577
+ # Show rate limit status
578
+ hardcopy rate-limit
579
+ ```
580
+
581
+ ---
582
+
583
+ ## POC Milestones
584
+
585
+ ### Phase 1: Core Infrastructure
586
+ - [x] LibSQL setup and schema
587
+ - [x] Provider interface definition
588
+ - [x] Config parser (YAML → sources + views)
589
+ - [x] CLI skeleton (`init`, `sync`, `status`, `refresh`, `push`)
590
+ - [x] Per-node CRDT storage structure
591
+
592
+ ### Phase 2: GitHub Provider
593
+ - [x] Fetch issues with pagination
594
+ - [x] Node/edge creation in LibSQL
595
+ - [ ] Conditional requests (304 caching with ETags)
596
+ - [x] `github.issue.md` format handler
597
+
598
+ ### Phase 3: A2A Provider
599
+ - [x] Task fetching from A2A protocol (skeleton)
600
+ - [x] Explicit link config parsing
601
+ - [x] Edge creation (a2a.TRACKS → github.Issue)
602
+ - [ ] Session metadata in worktrees
603
+
604
+ ### Phase 4: Git Provider
605
+ - [x] Branch/worktree discovery (single sync per repo)
606
+ - [x] Worktree metadata reading (`.a2a/session.json`)
607
+ - [x] Explicit link resolution (metadata > branch name)
608
+ - [ ] Diff generation from worktree directory
609
+
610
+ ### Phase 5: View Rendering
611
+ - [ ] Cypher query execution via GraphQLite
612
+ - [ ] Partition logic (group by field)
613
+ - [x] File tree generation
614
+ - [x] Lazy loading with `.index` files
615
+
616
+ ### Phase 6: Bi-Directional Sync
617
+ - [x] CRDT integration (Loro) per-node
618
+ - [ ] File watcher for local edits
619
+ - [ ] Auto-push for clean CRDT merges
620
+ - [ ] LLM reconciliation via UTCP
621
+
622
+ ### Phase 7: Conflict Handling
623
+ - [ ] Conflict detection
624
+ - [ ] Error file generation (`.hardcopy/errors/`)
625
+ - [ ] Manual resolution workflow
626
+ - [ ] `hardcopy status` conflict display
627
+
628
+ ---
629
+
630
+ ## File Format: github.issue.md
631
+
632
+ ```markdown
633
+ ---
634
+ _type: github.issue
635
+ _id: "github:AprovanLabs/zolvery#123"
636
+ _synced: 2026-02-21T10:30:00Z
637
+ number: 123
638
+ title: "Implement auth flow"
639
+ state: open
640
+ labels: [enhancement, auth]
641
+ assignee: jsampson
642
+ milestone: "v1.0"
643
+ created_at: 2026-02-15T08:00:00Z
644
+ updated_at: 2026-02-20T14:30:00Z
645
+ url: "https://github.com/AprovanLabs/zolvery/issues/123"
646
+ ---
647
+
648
+ Issue body in Markdown...
649
+
650
+ ## Acceptance Criteria
651
+ - [ ] OAuth2 integration
652
+ - [ ] Token refresh
653
+ ```
654
+
655
+ ### Format Handler
656
+
657
+ ```typescript
658
+ interface FormatHandler {
659
+ type: string; // "github.issue"
660
+
661
+ // Node → File content
662
+ render(node: Node): string;
663
+
664
+ // File content → Node changes
665
+ parse(content: string): { attrs: Record<string, any>; body: string };
666
+
667
+ // Which fields are editable locally
668
+ editableFields: string[]; // ["title", "body", "labels", "assignee"]
669
+ }
670
+ ```
671
+
672
+ ---
673
+
674
+ ## Design Decisions
675
+
676
+ ### 1. CRDT Granularity: Per-Node
677
+
678
+ Each node has its own CRDT document. This allows independent sync rates, isolated conflict resolution, and granular failure handling. Storage overhead is acceptable given typical node sizes.
679
+
680
+ ### 2. Branch → Task Linking: Explicit Config
681
+
682
+ Don't rely on branch naming conventions. Instead, use explicit configuration:
683
+
684
+ ```yaml
685
+ links:
686
+ - edge: git.TRACKS
687
+ to: a2a.Task
688
+ # Option A: A2A session metadata (preferred)
689
+ match: "a2a:{{branch.meta.a2a.task_id}}"
690
+ # Option B: Branch name pattern (fallback)
691
+ # match: "a2a:{{branch.name | regex_extract: 'task-([0-9]+)'}}"
692
+ ```
693
+
694
+ The A2A provider writes metadata to `.a2a/session.json` in worktrees it creates, which the Git provider reads.
695
+
696
+ ### 3. Worktree Support: Single Sync, Branch-Centric
697
+
698
+ - `git worktree list` from any repo directory returns all worktrees
699
+ - Single sync operation per repository discovers all branches and worktrees
700
+ - Worktrees are nodes with a `path` attribute; branches reference their worktree if one exists
701
+ - For diff generation, the Git provider switches to the worktree directory to ensure accurate results
702
+ - Changes flow through branches (push to remote), keeping a central source of truth
703
+
704
+ ### 4. Caching: Manual Refresh + Provider-Managed Tokens
705
+
706
+ **Refresh is manual by default.** Users explicitly trigger `hardcopy sync` or `hardcopy refresh <view>`.
707
+
708
+ The core stores a generic `version_token` per node. Providers manage their own caching:
709
+
710
+ ```typescript
711
+ // Core sync logic — provider-agnostic
712
+ async function syncNode(provider: Provider, nodeId: string): Promise<void> {
713
+ const cached = await db.get('SELECT version_token FROM nodes WHERE id = ?', nodeId);
714
+
715
+ const result = await provider.fetch({
716
+ query: { id: nodeId },
717
+ versionToken: cached?.version_token,
718
+ });
719
+
720
+ if (result.cached) {
721
+ // Provider determined data unchanged — skip update
722
+ return;
723
+ }
724
+
725
+ // Update node and store new version token
726
+ await db.run(
727
+ 'UPDATE nodes SET attrs = ?, synced_at = ?, version_token = ? WHERE id = ?',
728
+ result.nodes[0].attrs,
729
+ Date.now(),
730
+ result.versionToken,
731
+ nodeId
732
+ );
733
+ }
734
+ ```
735
+
736
+ **Provider implementations vary:**
737
+ - **GitHub**: Uses ETags via `If-None-Match` header (304 = cached)
738
+ - **Jira**: Uses `updated` timestamp comparison
739
+ - **Git**: Uses commit SHA comparison
740
+ - **Google Docs**: Uses revision ID
741
+
742
+ This abstraction lets each provider optimize for its API while core remains generic.