@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,1254 @@
1
+ # Conflict Resolution
2
+
3
+ ## Problem Statement
4
+
5
+ Current `push` command compares local file against cached DB state, ignoring concurrent remote changes. This causes silent overwrites.
6
+
7
+ ### Example Scenario
8
+
9
+ ```
10
+ Last Sync: "Pictures of: Jacob Steve Gramins"
11
+ Local Edit: "Pictures of:\n- Jacob Steve Gramins" (added bullet)
12
+ Remote Edit: "**Pictures of**: Jacob Steve Gramins" (added bold)
13
+ Push Result: Remote gets local version, remote edit lost
14
+ ```
15
+
16
+ Both edits had semantic intent that should be preserved. Neither user wanted to lose the other's change.
17
+
18
+ ---
19
+
20
+ ## Core Issue: Three-Way State Problem
21
+
22
+ Push currently performs two-way diff:
23
+ ```
24
+ Local File → compare ← DB Cache (last synced state)
25
+ ```
26
+
27
+ Should perform three-way diff:
28
+ ```
29
+ DB Cache (base)
30
+
31
+ / \
32
+ Local File Remote Current
33
+ ```
34
+
35
+ Changes:
36
+ 1. Fetch remote state before push
37
+ 2. Detect divergence from common base
38
+ 3. Decide resolution strategy
39
+
40
+ ---
41
+
42
+ ## CRDT Limitations
43
+
44
+ CRDTs (Loro) work well when:
45
+ - Both sides use CRDT operations
46
+ - We control edit granularity
47
+
48
+ Here, we don't control remote inputs:
49
+ - GitHub UI sends full text replacements
50
+ - We receive before/after strings, not ops
51
+ - Character-level merge loses semantic intent
52
+
53
+ **Example Failure**:
54
+ ```
55
+ Base: "Task: Fix bug"
56
+ Local: "Task: Fix critical bug" (added "critical")
57
+ Remote: "Task: Fix the bug" (added "the")
58
+ CRDT: "Task: Fix criticalthe bug" (nonsense merge)
59
+ ```
60
+
61
+ CRDT is appropriate for:
62
+ - List operations (labels, assignees)
63
+ - Key-value fields (state, milestone)
64
+ - Concurrent local edits (multiple files)
65
+
66
+ Not ideal for:
67
+ - Free-text body merges from external sources
68
+ - Semantic conflict detection
69
+
70
+ ---
71
+
72
+ ## Semantic Diff Alternative
73
+
74
+ [diffsitter](https://github.com/afnanenayet/diffsitter) uses tree-sitter for AST-level diffs.
75
+
76
+ **Pros**:
77
+ - Understands structural changes
78
+ - Better signal-to-noise ratio
79
+ - Language-aware (code, markdown)
80
+
81
+ **Cons**:
82
+ - Markdown AST is shallow (less helpful)
83
+ - Adds tree-sitter dependency
84
+ - Still doesn't resolve semantic conflicts
85
+
86
+ **Verdict**: Useful for display/debugging, not primary merge strategy.
87
+
88
+ ---
89
+
90
+ ## Proposed Design
91
+
92
+ ### Phase 1: Conflict Detection
93
+
94
+ Modify `push` to fetch remote before comparing.
95
+
96
+ ```typescript
97
+ interface SyncState {
98
+ base: string; // DB cache (last sync)
99
+ local: string; // Current file
100
+ remote: string; // Fetched remote
101
+ }
102
+
103
+ enum ConflictStatus {
104
+ CLEAN, // local changed, remote unchanged
105
+ REMOTE_ONLY, // remote changed, local unchanged
106
+ DIVERGED, // both changed (conflict)
107
+ }
108
+
109
+ function detectConflict(state: SyncState): ConflictStatus {
110
+ const localChanged = state.local !== state.base;
111
+ const remoteChanged = state.remote !== state.base;
112
+
113
+ if (!localChanged && !remoteChanged) return ConflictStatus.CLEAN;
114
+ if (localChanged && !remoteChanged) return ConflictStatus.CLEAN;
115
+ if (!localChanged && remoteChanged) return ConflictStatus.REMOTE_ONLY;
116
+ return ConflictStatus.DIVERGED;
117
+ }
118
+ ```
119
+
120
+ ### Phase 2: Resolution Strategies
121
+
122
+ ```typescript
123
+ type ResolutionStrategy =
124
+ | 'auto-merge' // compatible changes, merge
125
+ | 'local-wins' // user flag: --force
126
+ | 'remote-wins' // discard local
127
+ | 'manual' // write conflict markers
128
+ | 'prompt' // interactive resolution
129
+ ```
130
+
131
+ **Auto-merge** (when possible):
132
+ - Changes don't overlap
133
+ - Different fields changed
134
+ - Additive operations (both add labels)
135
+
136
+ **Manual** (fallback):
137
+ - Write conflict file to `.hardcopy/conflicts/`
138
+ - Block push until resolved
139
+ - Show in `hardcopy status`
140
+
141
+ ### Phase 3: Conflict Markers
142
+
143
+ For diverged text fields, generate diff3-style markers:
144
+
145
+ ```markdown
146
+ <<<<<<< LOCAL
147
+ Pictures of:
148
+ - Jacob Steve Gramins
149
+ ||||||| BASE
150
+ Pictures of: Jacob Steve Gramins
151
+ =======
152
+ **Pictures of**: Jacob Steve Gramins
153
+ >>>>>>> REMOTE
154
+ ```
155
+
156
+ Store in `.hardcopy/conflicts/{nodeId}.md` with metadata.
157
+
158
+ ### Phase 4: CLI Integration
159
+
160
+ ```bash
161
+ hardcopy push [pattern] # fails on conflict
162
+ hardcopy push --force [pattern] # local-wins
163
+ hardcopy push --sync [pattern] # fetch, then push
164
+ hardcopy conflicts # list conflicts
165
+ hardcopy resolve <nodeId> # interactive resolution
166
+ ```
167
+
168
+ ---
169
+
170
+ ## Implementation Steps
171
+
172
+ ### Step 1: Remote Fetch Before Push
173
+
174
+ ```typescript
175
+ async push(pattern?: string): Promise<PushStats> {
176
+ const diffs = await this.diff(pattern);
177
+
178
+ for (const diff of diffs) {
179
+ // NEW: fetch current remote state
180
+ const remote = await provider.fetch({ nodeId: diff.nodeId });
181
+ const base = await db.getNode(diff.nodeId);
182
+
183
+ const status = detectConflict({
184
+ base: base.attrs.body,
185
+ local: diff.changes.find(c => c.field === 'body')?.newValue,
186
+ remote: remote.attrs.body,
187
+ });
188
+
189
+ if (status === ConflictStatus.DIVERGED) {
190
+ await this.writeConflict(diff.nodeId, { base, local, remote });
191
+ stats.conflicts++;
192
+ continue;
193
+ }
194
+
195
+ // ... existing push logic
196
+ }
197
+ }
198
+ ```
199
+
200
+ ### Step 2: Provider Interface Update
201
+
202
+ ```typescript
203
+ interface Provider {
204
+ // Existing
205
+ fetch(request: FetchRequest): Promise<FetchResult>;
206
+ push(node: Node, changes: Change[]): Promise<PushResult>;
207
+
208
+ // New: single-node fetch for conflict check
209
+ fetchNode(nodeId: string): Promise<Node | null>;
210
+ }
211
+ ```
212
+
213
+ ### Step 3: Conflict Storage
214
+
215
+ ```
216
+ .hardcopy/
217
+ ├── db.sqlite
218
+ ├── crdt/
219
+ └── conflicts/
220
+ └── github:owner:repo#42.md
221
+ ```
222
+
223
+ Conflict file format:
224
+ ```yaml
225
+ ---
226
+ nodeId: github:owner/repo#42
227
+ type: github.Issue
228
+ field: body
229
+ detectedAt: 2026-02-21T10:30:00Z
230
+ ---
231
+ <<<<<<< LOCAL
232
+ ...
233
+ ```
234
+
235
+ ### Step 4: Status Integration
236
+
237
+ ```bash
238
+ $ hardcopy status
239
+ Conflicts:
240
+ (use "hardcopy resolve <id>" to resolve)
241
+
242
+ conflict: github:owner/repo#42 (body)
243
+
244
+ Changes not pushed:
245
+ ...
246
+ ```
247
+
248
+ ---
249
+
250
+ ## Alternative: Operational Transform
251
+
252
+ If we controlled both ends, OT would work:
253
+ ```
254
+ local ops: INSERT(10, "critical ")
255
+ remote ops: INSERT(6, "the ")
256
+ transform: INSERT(10 + len("the "), "critical ")
257
+ ```
258
+
259
+ Not viable here—we only get resulting text from remote.
260
+
261
+ ---
262
+
263
+ ## Decision Matrix
264
+
265
+ | Approach | Pros | Cons | Use When |
266
+ |----------|------|------|----------|
267
+ | CRDT | Auto-merge, offline-first | Semantic blindness | Lists, metadata |
268
+ | Semantic diff | Structural awareness | Doesn't resolve | Debug, display |
269
+ | Three-way diff | Standard, familiar | Manual resolution | Text conflicts |
270
+ | LLM resolution | Understands intent | Cost, latency | Complex conflicts |
271
+
272
+ **Recommendation**: Hybrid approach
273
+ 1. Three-way diff for detection
274
+ 2. CRDT for compatible merges (lists, maps)
275
+ 3. Conflict markers + manual for text divergence
276
+ 4. Optional LLM for suggested resolution
277
+
278
+ ---
279
+
280
+ ## Milestones
281
+
282
+ - [ ] Add `fetchNode` to provider interface
283
+ - [ ] Implement three-way conflict detection in `push`
284
+ - [ ] Create `.hardcopy/conflicts/` storage
285
+ - [ ] Add `hardcopy conflicts` command
286
+ - [ ] Add `hardcopy resolve` command
287
+ - [ ] Update `hardcopy status` to show conflicts
288
+ - [ ] Add `--force` flag for local-wins
289
+ - [ ] Document conflict resolution workflow
290
+
291
+ ---
292
+
293
+ # Technical Implementation Plan
294
+
295
+ ## Overview
296
+
297
+ Implement hybrid conflict resolution:
298
+ 1. **Three-way diff** for conflict detection
299
+ 2. **CRDT** for list/map fields (labels, assignees, metadata)
300
+ 3. **Conflict markers** for text field divergence
301
+ 4. **CLI commands** for conflict management
302
+
303
+ ---
304
+
305
+ ## Task 1: Types and Interfaces
306
+
307
+ **File**: `src/types.ts` (create if needed, or add to existing)
308
+
309
+ ```typescript
310
+ // Conflict detection
311
+ export enum ConflictStatus {
312
+ CLEAN = 'clean', // No conflict, safe to push
313
+ REMOTE_ONLY = 'remote', // Remote changed, local unchanged
314
+ DIVERGED = 'diverged', // Both changed, conflict
315
+ }
316
+
317
+ export interface ThreeWayState {
318
+ base: unknown; // DB cached value (last sync)
319
+ local: unknown; // Current file value
320
+ remote: unknown; // Fetched remote value
321
+ }
322
+
323
+ export interface FieldConflict {
324
+ field: string;
325
+ status: ConflictStatus;
326
+ base: unknown;
327
+ local: unknown;
328
+ remote: unknown;
329
+ canAutoMerge: boolean; // true for lists, false for text
330
+ }
331
+
332
+ export interface ConflictInfo {
333
+ nodeId: string;
334
+ nodeType: string;
335
+ filePath: string;
336
+ detectedAt: number;
337
+ fields: FieldConflict[];
338
+ }
339
+
340
+ // Extended push stats
341
+ export interface PushStats {
342
+ pushed: number;
343
+ skipped: number;
344
+ conflicts: number; // NEW
345
+ errors: string[];
346
+ }
347
+ ```
348
+
349
+ ---
350
+
351
+ ## Task 2: Provider Interface Extension
352
+
353
+ **File**: `src/provider.ts`
354
+
355
+ Add `fetchNode` method to Provider interface:
356
+
357
+ ```typescript
358
+ interface Provider {
359
+ name: string;
360
+ nodeTypes: string[];
361
+ edgeTypes: string[];
362
+
363
+ fetch(request: FetchRequest): Promise<FetchResult>;
364
+ push(node: Node, changes: Change[]): Promise<PushResult>;
365
+
366
+ // NEW: Fetch single node for conflict detection
367
+ // Returns null if node doesn't exist remotely
368
+ fetchNode(nodeId: string): Promise<Node | null>;
369
+ }
370
+ ```
371
+
372
+ **Implementation for GitHub provider** (`src/providers/github.ts`):
373
+
374
+ ```typescript
375
+ async fetchNode(nodeId: string): Promise<Node | null> {
376
+ // nodeId format: "github:owner/repo#123"
377
+ const match = nodeId.match(/^github:([^/]+)\/([^#]+)#(\d+)$/);
378
+ if (!match) return null;
379
+
380
+ const [, owner, repo, number] = match;
381
+
382
+ try {
383
+ const response = await this.octokit.issues.get({
384
+ owner,
385
+ repo,
386
+ issue_number: parseInt(number, 10),
387
+ });
388
+
389
+ return this.issueToNode(response.data);
390
+ } catch (err) {
391
+ if (err.status === 404) return null;
392
+ throw err;
393
+ }
394
+ }
395
+ ```
396
+
397
+ ---
398
+
399
+ ## Task 3: Conflict Detection Module
400
+
401
+ **File**: `src/conflict.ts` (new file)
402
+
403
+ ```typescript
404
+ import { FieldConflict, ConflictStatus, ThreeWayState, ConflictInfo } from './types';
405
+ import { Node, Change } from './provider';
406
+
407
+ /**
408
+ * Detect conflict status for a single field
409
+ */
410
+ export function detectFieldConflict(
411
+ field: string,
412
+ state: ThreeWayState,
413
+ ): FieldConflict {
414
+ const localChanged = !valuesEqual(state.local, state.base);
415
+ const remoteChanged = !valuesEqual(state.remote, state.base);
416
+
417
+ let status: ConflictStatus;
418
+ if (!localChanged && !remoteChanged) {
419
+ status = ConflictStatus.CLEAN;
420
+ } else if (localChanged && !remoteChanged) {
421
+ status = ConflictStatus.CLEAN;
422
+ } else if (!localChanged && remoteChanged) {
423
+ status = ConflictStatus.REMOTE_ONLY;
424
+ } else {
425
+ // Both changed - check if they changed to same value
426
+ status = valuesEqual(state.local, state.remote)
427
+ ? ConflictStatus.CLEAN
428
+ : ConflictStatus.DIVERGED;
429
+ }
430
+
431
+ return {
432
+ field,
433
+ status,
434
+ base: state.base,
435
+ local: state.local,
436
+ remote: state.remote,
437
+ canAutoMerge: isListField(field),
438
+ };
439
+ }
440
+
441
+ /**
442
+ * List fields can be auto-merged via CRDT
443
+ */
444
+ function isListField(field: string): boolean {
445
+ return ['labels', 'assignees'].includes(field);
446
+ }
447
+
448
+ /**
449
+ * Deep equality check
450
+ */
451
+ function valuesEqual(a: unknown, b: unknown): boolean {
452
+ if (a === b) return true;
453
+ if (a == null && b == null) return true;
454
+ if (Array.isArray(a) && Array.isArray(b)) {
455
+ if (a.length !== b.length) return false;
456
+ const sortedA = [...a].sort();
457
+ const sortedB = [...b].sort();
458
+ return sortedA.every((v, i) => valuesEqual(v, sortedB[i]));
459
+ }
460
+ return JSON.stringify(a) === JSON.stringify(b);
461
+ }
462
+
463
+ /**
464
+ * Detect conflicts for all editable fields
465
+ */
466
+ export function detectConflicts(
467
+ baseNode: Node,
468
+ localParsed: { attrs: Record<string, unknown>; body: string },
469
+ remoteNode: Node,
470
+ editableFields: string[],
471
+ ): FieldConflict[] {
472
+ const conflicts: FieldConflict[] = [];
473
+ const baseAttrs = baseNode.attrs as Record<string, unknown>;
474
+ const remoteAttrs = remoteNode.attrs as Record<string, unknown>;
475
+
476
+ for (const field of editableFields) {
477
+ const state: ThreeWayState = {
478
+ base: field === 'body' ? baseAttrs.body : baseAttrs[field],
479
+ local: field === 'body' ? localParsed.body : localParsed.attrs[field],
480
+ remote: field === 'body' ? remoteAttrs.body : remoteAttrs[field],
481
+ };
482
+
483
+ const conflict = detectFieldConflict(field, state);
484
+ if (conflict.status !== ConflictStatus.CLEAN) {
485
+ conflicts.push(conflict);
486
+ }
487
+ }
488
+
489
+ return conflicts;
490
+ }
491
+
492
+ /**
493
+ * Check if any conflicts require manual resolution
494
+ */
495
+ export function hasUnresolvableConflicts(conflicts: FieldConflict[]): boolean {
496
+ return conflicts.some(c =>
497
+ c.status === ConflictStatus.DIVERGED && !c.canAutoMerge
498
+ );
499
+ }
500
+
501
+ /**
502
+ * Auto-merge list fields using set union
503
+ * Returns merged value for lists, null for non-mergeable
504
+ */
505
+ export function autoMergeField(conflict: FieldConflict): unknown | null {
506
+ if (!conflict.canAutoMerge || conflict.status !== ConflictStatus.DIVERGED) {
507
+ return null;
508
+ }
509
+
510
+ // Set union for lists
511
+ const baseSet = new Set(conflict.base as unknown[] ?? []);
512
+ const localAdded = (conflict.local as unknown[] ?? []).filter(v => !baseSet.has(v));
513
+ const remoteAdded = (conflict.remote as unknown[] ?? []).filter(v => !baseSet.has(v));
514
+
515
+ // Union: keep all from base, add new from both
516
+ const merged = [...baseSet, ...localAdded, ...remoteAdded];
517
+ return [...new Set(merged)]; // dedupe
518
+ }
519
+
520
+ /**
521
+ * Generate diff3-style conflict markers for text
522
+ */
523
+ export function generateConflictMarkers(conflict: FieldConflict): string {
524
+ const local = String(conflict.local ?? '');
525
+ const base = String(conflict.base ?? '');
526
+ const remote = String(conflict.remote ?? '');
527
+
528
+ return `<<<<<<< LOCAL
529
+ ${local}
530
+ ||||||| BASE
531
+ ${base}
532
+ =======
533
+ ${remote}
534
+ >>>>>>> REMOTE`;
535
+ }
536
+ ```
537
+
538
+ ---
539
+
540
+ ## Task 4: Conflict Storage
541
+
542
+ **File**: `src/conflict-store.ts` (new file)
543
+
544
+ ```typescript
545
+ import { mkdir, writeFile, readFile, readdir, unlink } from 'fs/promises';
546
+ import { join, basename } from 'path';
547
+ import { ConflictInfo, FieldConflict } from './types';
548
+ import { generateConflictMarkers } from './conflict';
549
+
550
+ export class ConflictStore {
551
+ private conflictsDir: string;
552
+
553
+ constructor(hardcopyDir: string) {
554
+ this.conflictsDir = join(hardcopyDir, 'conflicts');
555
+ }
556
+
557
+ async initialize(): Promise<void> {
558
+ await mkdir(this.conflictsDir, { recursive: true });
559
+ }
560
+
561
+ /**
562
+ * Write conflict to file
563
+ * Filename: sanitized nodeId + .conflict.md
564
+ */
565
+ async write(info: ConflictInfo): Promise<string> {
566
+ const filename = this.nodeIdToFilename(info.nodeId);
567
+ const filepath = join(this.conflictsDir, filename);
568
+
569
+ const content = this.formatConflict(info);
570
+ await writeFile(filepath, content, 'utf-8');
571
+
572
+ return filepath;
573
+ }
574
+
575
+ /**
576
+ * List all conflicts
577
+ */
578
+ async list(): Promise<ConflictInfo[]> {
579
+ try {
580
+ const files = await readdir(this.conflictsDir);
581
+ const conflicts: ConflictInfo[] = [];
582
+
583
+ for (const file of files) {
584
+ if (!file.endsWith('.conflict.md')) continue;
585
+ const content = await readFile(join(this.conflictsDir, file), 'utf-8');
586
+ const info = this.parseConflict(content);
587
+ if (info) conflicts.push(info);
588
+ }
589
+
590
+ return conflicts;
591
+ } catch {
592
+ return [];
593
+ }
594
+ }
595
+
596
+ /**
597
+ * Get specific conflict
598
+ */
599
+ async get(nodeId: string): Promise<ConflictInfo | null> {
600
+ const filename = this.nodeIdToFilename(nodeId);
601
+ const filepath = join(this.conflictsDir, filename);
602
+
603
+ try {
604
+ const content = await readFile(filepath, 'utf-8');
605
+ return this.parseConflict(content);
606
+ } catch {
607
+ return null;
608
+ }
609
+ }
610
+
611
+ /**
612
+ * Delete conflict (after resolution)
613
+ */
614
+ async delete(nodeId: string): Promise<void> {
615
+ const filename = this.nodeIdToFilename(nodeId);
616
+ const filepath = join(this.conflictsDir, filename);
617
+
618
+ try {
619
+ await unlink(filepath);
620
+ } catch {
621
+ // Ignore if doesn't exist
622
+ }
623
+ }
624
+
625
+ /**
626
+ * Check if node has unresolved conflict
627
+ */
628
+ async has(nodeId: string): Promise<boolean> {
629
+ const conflict = await this.get(nodeId);
630
+ return conflict !== null;
631
+ }
632
+
633
+ private nodeIdToFilename(nodeId: string): string {
634
+ // Sanitize: github:owner/repo#42 -> github_owner_repo_42.conflict.md
635
+ return nodeId.replace(/[:/# ]/g, '_') + '.conflict.md';
636
+ }
637
+
638
+ private formatConflict(info: ConflictInfo): string {
639
+ const frontmatter = [
640
+ '---',
641
+ `nodeId: "${info.nodeId}"`,
642
+ `type: ${info.nodeType}`,
643
+ `filePath: "${info.filePath}"`,
644
+ `detectedAt: ${new Date(info.detectedAt).toISOString()}`,
645
+ `fields: [${info.fields.map(f => `"${f.field}"`).join(', ')}]`,
646
+ '---',
647
+ '',
648
+ ].join('\n');
649
+
650
+ const sections = info.fields
651
+ .filter(f => f.status === 'diverged')
652
+ .map(f => `## ${f.field}\n\n${generateConflictMarkers(f)}`)
653
+ .join('\n\n');
654
+
655
+ return frontmatter + sections;
656
+ }
657
+
658
+ private parseConflict(content: string): ConflictInfo | null {
659
+ // Parse YAML frontmatter
660
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
661
+ if (!match) return null;
662
+
663
+ const frontmatter = match[1];
664
+ const nodeIdMatch = frontmatter.match(/nodeId:\s*"([^"]+)"/);
665
+ const typeMatch = frontmatter.match(/type:\s*(\S+)/);
666
+ const filePathMatch = frontmatter.match(/filePath:\s*"([^"]+)"/);
667
+ const detectedAtMatch = frontmatter.match(/detectedAt:\s*(\S+)/);
668
+ const fieldsMatch = frontmatter.match(/fields:\s*\[(.*?)\]/);
669
+
670
+ if (!nodeIdMatch) return null;
671
+
672
+ return {
673
+ nodeId: nodeIdMatch[1],
674
+ nodeType: typeMatch?.[1] ?? 'unknown',
675
+ filePath: filePathMatch?.[1] ?? '',
676
+ detectedAt: detectedAtMatch ? new Date(detectedAtMatch[1]).getTime() : Date.now(),
677
+ fields: [], // Full parsing would require parsing the body sections
678
+ };
679
+ }
680
+ }
681
+ ```
682
+
683
+ ---
684
+
685
+ ## Task 5: Update Push Logic
686
+
687
+ **File**: `src/hardcopy.ts`
688
+
689
+ Modify the `push` method to integrate conflict detection:
690
+
691
+ ```typescript
692
+ import { detectConflicts, hasUnresolvableConflicts, autoMergeField } from './conflict';
693
+ import { ConflictStore } from './conflict-store';
694
+ import { ConflictInfo, ConflictStatus } from './types';
695
+
696
+ // Add to Hardcopy class:
697
+ private conflictStore: ConflictStore | null = null;
698
+
699
+ private getConflictStore(): ConflictStore {
700
+ if (!this.conflictStore) {
701
+ this.conflictStore = new ConflictStore(join(this.root, '.hardcopy'));
702
+ }
703
+ return this.conflictStore;
704
+ }
705
+
706
+ // Replace push method:
707
+ async push(filePath?: string, options: { force?: boolean } = {}): Promise<PushStats> {
708
+ const config = await this.loadConfig();
709
+ const db = this.getDatabase();
710
+ const crdt = this.getCRDTStore();
711
+ const conflictStore = this.getConflictStore();
712
+ await conflictStore.initialize();
713
+
714
+ const stats: PushStats = { pushed: 0, skipped: 0, conflicts: 0, errors: [] };
715
+ const diffs = await this.diff(filePath);
716
+
717
+ for (const diff of diffs) {
718
+ if (diff.changes.length === 0) {
719
+ stats.skipped++;
720
+ continue;
721
+ }
722
+
723
+ const provider = this.findProviderForNode(diff.nodeId);
724
+ if (!provider) {
725
+ stats.errors.push(`No provider for ${diff.nodeId}`);
726
+ continue;
727
+ }
728
+
729
+ const dbNode = await db.getNode(diff.nodeId);
730
+ if (!dbNode) {
731
+ stats.errors.push(`Node not found: ${diff.nodeId}`);
732
+ continue;
733
+ }
734
+
735
+ // Check for existing unresolved conflict
736
+ if (await conflictStore.has(diff.nodeId)) {
737
+ stats.errors.push(`Unresolved conflict for ${diff.nodeId}. Run 'hardcopy resolve' first.`);
738
+ stats.conflicts++;
739
+ continue;
740
+ }
741
+
742
+ try {
743
+ // FETCH REMOTE STATE
744
+ const remoteNode = await provider.fetchNode(diff.nodeId);
745
+ if (!remoteNode) {
746
+ stats.errors.push(`Remote node not found: ${diff.nodeId}`);
747
+ continue;
748
+ }
749
+
750
+ // DETECT CONFLICTS
751
+ const format = getFormat(dbNode.type);
752
+ if (!format) {
753
+ stats.errors.push(`Unknown format: ${dbNode.type}`);
754
+ continue;
755
+ }
756
+
757
+ // Parse local file for comparison
758
+ const localContent = await readFile(diff.filePath, 'utf-8');
759
+ const localParsed = parseFile(localContent, 'generic');
760
+
761
+ const fieldConflicts = detectConflicts(
762
+ dbNode,
763
+ localParsed,
764
+ remoteNode,
765
+ format.editableFields,
766
+ );
767
+
768
+ // Handle conflicts
769
+ if (fieldConflicts.length > 0) {
770
+ if (options.force) {
771
+ // Force mode: local wins, skip conflict handling
772
+ console.log(`Forcing push for ${diff.nodeId} (local-wins)`);
773
+ } else if (hasUnresolvableConflicts(fieldConflicts)) {
774
+ // Unresolvable conflict: write conflict file, skip push
775
+ const conflictInfo: ConflictInfo = {
776
+ nodeId: diff.nodeId,
777
+ nodeType: dbNode.type,
778
+ filePath: diff.filePath,
779
+ detectedAt: Date.now(),
780
+ fields: fieldConflicts,
781
+ };
782
+ await conflictStore.write(conflictInfo);
783
+ console.log(`Conflict detected for ${diff.nodeId}. Run 'hardcopy conflicts' to view.`);
784
+ stats.conflicts++;
785
+ continue;
786
+ } else {
787
+ // All conflicts are auto-mergeable (lists)
788
+ for (const conflict of fieldConflicts) {
789
+ const merged = autoMergeField(conflict);
790
+ if (merged !== null) {
791
+ // Update the change with merged value
792
+ const change = diff.changes.find(c => c.field === conflict.field);
793
+ if (change) {
794
+ change.newValue = merged;
795
+ }
796
+ }
797
+ }
798
+ }
799
+ }
800
+
801
+ // PUSH
802
+ const result = await provider.push(dbNode, diff.changes);
803
+ if (result.success) {
804
+ // Update local node with changes
805
+ const updatedAttrs = { ...dbNode.attrs };
806
+ for (const change of diff.changes) {
807
+ updatedAttrs[change.field] = change.newValue;
808
+ }
809
+ await db.upsertNode({
810
+ ...dbNode,
811
+ attrs: updatedAttrs,
812
+ syncedAt: Date.now(),
813
+ });
814
+
815
+ // Update CRDT
816
+ const doc = await crdt.loadOrCreate(diff.nodeId);
817
+ if (format) {
818
+ const bodyChange = diff.changes.find((c) => c.field === 'body');
819
+ if (bodyChange) {
820
+ setDocContent(doc, bodyChange.newValue as string);
821
+ }
822
+ }
823
+ await crdt.save(diff.nodeId, doc);
824
+
825
+ // Clear any resolved conflict
826
+ await conflictStore.delete(diff.nodeId);
827
+
828
+ stats.pushed++;
829
+ } else {
830
+ stats.errors.push(`Push failed for ${diff.nodeId}: ${result.error}`);
831
+ }
832
+ } catch (err) {
833
+ stats.errors.push(`Error pushing ${diff.nodeId}: ${err}`);
834
+ }
835
+ }
836
+
837
+ return stats;
838
+ }
839
+ ```
840
+
841
+ ---
842
+
843
+ ## Task 6: CLI Commands
844
+
845
+ **File**: `src/cli.ts`
846
+
847
+ ### Update push command:
848
+
849
+ ```typescript
850
+ program
851
+ .command('push [pattern]')
852
+ .description('Push local changes to remotes (supports glob patterns)')
853
+ .option('--dry-run', 'Show what would be pushed without actually pushing')
854
+ .option('--force', 'Force push, overwriting remote changes (local-wins)')
855
+ .action(async (pattern?: string, options?: { dryRun?: boolean; force?: boolean }) => {
856
+ const hc = new Hardcopy({ root: process.cwd() });
857
+ await hc.initialize();
858
+ try {
859
+ await hc.loadConfig();
860
+
861
+ if (options?.dryRun) {
862
+ // ... existing dry-run logic
863
+ }
864
+
865
+ const stats = await hc.push(pattern, { force: options?.force });
866
+
867
+ console.log(`Pushed ${stats.pushed} changes, skipped ${stats.skipped}`);
868
+
869
+ if (stats.conflicts > 0) {
870
+ console.log(`\n${stats.conflicts} conflict(s) detected.`);
871
+ console.log(' (use "hardcopy conflicts" to view)');
872
+ console.log(' (use "hardcopy push --force" to override)');
873
+ }
874
+
875
+ if (stats.errors.length > 0) {
876
+ console.error('Errors:');
877
+ for (const err of stats.errors) {
878
+ console.error(` ${err}`);
879
+ }
880
+ }
881
+ } finally {
882
+ await hc.close();
883
+ }
884
+ });
885
+ ```
886
+
887
+ ### Add conflicts command:
888
+
889
+ ```typescript
890
+ program
891
+ .command('conflicts')
892
+ .description('List unresolved conflicts')
893
+ .action(async () => {
894
+ const hc = new Hardcopy({ root: process.cwd() });
895
+ await hc.initialize();
896
+ try {
897
+ const conflicts = await hc.listConflicts();
898
+
899
+ if (conflicts.length === 0) {
900
+ console.log('No conflicts.');
901
+ return;
902
+ }
903
+
904
+ console.log('Unresolved conflicts:\n');
905
+ console.log(' (use "hardcopy resolve <nodeId>" to resolve)');
906
+ console.log(' (use "hardcopy push --force" to override with local)\n');
907
+
908
+ for (const conflict of conflicts) {
909
+ const fields = conflict.fields.map(f => f.field).join(', ');
910
+ console.log(` ${conflict.nodeId}`);
911
+ console.log(` fields: ${fields}`);
912
+ console.log(` file: ${conflict.filePath}`);
913
+ console.log();
914
+ }
915
+ } finally {
916
+ await hc.close();
917
+ }
918
+ });
919
+ ```
920
+
921
+ ### Add resolve command:
922
+
923
+ ```typescript
924
+ program
925
+ .command('resolve <nodeId>')
926
+ .description('Resolve a conflict')
927
+ .option('--local', 'Accept local version')
928
+ .option('--remote', 'Accept remote version')
929
+ .option('--show', 'Show conflict details without resolving')
930
+ .action(async (nodeId: string, options?: { local?: boolean; remote?: boolean; show?: boolean }) => {
931
+ const hc = new Hardcopy({ root: process.cwd() });
932
+ await hc.initialize();
933
+ try {
934
+ const conflict = await hc.getConflict(nodeId);
935
+
936
+ if (!conflict) {
937
+ console.log(`No conflict found for ${nodeId}`);
938
+ return;
939
+ }
940
+
941
+ if (options?.show) {
942
+ // Display conflict details
943
+ console.log(`Conflict: ${conflict.nodeId}`);
944
+ console.log(`Type: ${conflict.nodeType}`);
945
+ console.log(`File: ${conflict.filePath}`);
946
+ console.log(`Detected: ${new Date(conflict.detectedAt).toISOString()}\n`);
947
+
948
+ for (const field of conflict.fields) {
949
+ console.log(`--- ${field.field} ---`);
950
+ console.log(`Base:\n${field.base}\n`);
951
+ console.log(`Local:\n${field.local}\n`);
952
+ console.log(`Remote:\n${field.remote}\n`);
953
+ }
954
+ return;
955
+ }
956
+
957
+ if (options?.local) {
958
+ await hc.resolveConflict(nodeId, 'local');
959
+ console.log(`Resolved ${nodeId} with local version.`);
960
+ console.log('Run "hardcopy push" to push changes.');
961
+ } else if (options?.remote) {
962
+ await hc.resolveConflict(nodeId, 'remote');
963
+ console.log(`Resolved ${nodeId} with remote version.`);
964
+ console.log('Local file updated.');
965
+ } else {
966
+ // Interactive mode (future: could prompt user)
967
+ console.log('Specify --local or --remote to resolve, or --show to view details.');
968
+ }
969
+ } finally {
970
+ await hc.close();
971
+ }
972
+ });
973
+ ```
974
+
975
+ ---
976
+
977
+ ## Task 7: Hardcopy Class Methods
978
+
979
+ **File**: `src/hardcopy.ts`
980
+
981
+ Add these methods to the Hardcopy class:
982
+
983
+ ```typescript
984
+ /**
985
+ * List all unresolved conflicts
986
+ */
987
+ async listConflicts(): Promise<ConflictInfo[]> {
988
+ const store = this.getConflictStore();
989
+ await store.initialize();
990
+ return store.list();
991
+ }
992
+
993
+ /**
994
+ * Get a specific conflict
995
+ */
996
+ async getConflict(nodeId: string): Promise<ConflictInfo | null> {
997
+ const store = this.getConflictStore();
998
+ await store.initialize();
999
+ return store.get(nodeId);
1000
+ }
1001
+
1002
+ /**
1003
+ * Resolve a conflict
1004
+ * @param nodeId - The node with conflict
1005
+ * @param resolution - 'local' keeps local, 'remote' pulls remote
1006
+ */
1007
+ async resolveConflict(
1008
+ nodeId: string,
1009
+ resolution: 'local' | 'remote',
1010
+ ): Promise<void> {
1011
+ const store = this.getConflictStore();
1012
+ const db = this.getDatabase();
1013
+ const conflict = await store.get(nodeId);
1014
+
1015
+ if (!conflict) {
1016
+ throw new Error(`No conflict found for ${nodeId}`);
1017
+ }
1018
+
1019
+ if (resolution === 'local') {
1020
+ // Local wins: just delete the conflict, push will proceed
1021
+ await store.delete(nodeId);
1022
+ } else {
1023
+ // Remote wins: update local file with remote content
1024
+ const provider = this.findProviderForNode(nodeId);
1025
+ if (!provider) {
1026
+ throw new Error(`No provider for ${nodeId}`);
1027
+ }
1028
+
1029
+ const remoteNode = await provider.fetchNode(nodeId);
1030
+ if (!remoteNode) {
1031
+ throw new Error(`Remote node not found: ${nodeId}`);
1032
+ }
1033
+
1034
+ // Update DB with remote
1035
+ await db.upsertNode({
1036
+ ...remoteNode,
1037
+ syncedAt: Date.now(),
1038
+ });
1039
+
1040
+ // Re-render the file from remote
1041
+ const config = await this.loadConfig();
1042
+ for (const view of config.views) {
1043
+ if (conflict.filePath.startsWith(view.path)) {
1044
+ await this.renderNodeToFile(remoteNode, view, join(this.root, view.path));
1045
+ break;
1046
+ }
1047
+ }
1048
+
1049
+ // Delete conflict
1050
+ await store.delete(nodeId);
1051
+ }
1052
+ }
1053
+ ```
1054
+
1055
+ ---
1056
+
1057
+ ## Task 8: Update Status Command
1058
+
1059
+ **File**: `src/cli.ts`
1060
+
1061
+ Modify status to show conflicts:
1062
+
1063
+ ```typescript
1064
+ .action(async (options: { short?: boolean }) => {
1065
+ const hc = new Hardcopy({ root: process.cwd() });
1066
+ await hc.initialize();
1067
+ try {
1068
+ await hc.loadConfig();
1069
+ const status = await hc.status();
1070
+ const conflicts = await hc.listConflicts();
1071
+
1072
+ if (options.short) {
1073
+ // Git-like short status
1074
+ for (const conflict of conflicts) {
1075
+ console.log(`C ${conflict.filePath}`);
1076
+ }
1077
+ for (const file of status.changedFiles) {
1078
+ const marker = file.status === 'new' ? 'A' : 'M';
1079
+ console.log(`${marker} ${file.path}`);
1080
+ }
1081
+ return;
1082
+ }
1083
+
1084
+ // Full status
1085
+ if (conflicts.length > 0) {
1086
+ console.log('Conflicts:');
1087
+ console.log(' (use "hardcopy resolve <id>" to resolve)\n');
1088
+ for (const conflict of conflicts) {
1089
+ const fields = conflict.fields.map(f => f.field).join(', ');
1090
+ console.log(` conflict: ${conflict.nodeId} (${fields})`);
1091
+ }
1092
+ console.log();
1093
+ }
1094
+
1095
+ if (status.changedFiles.length > 0) {
1096
+ console.log('Changes not pushed:');
1097
+ console.log(' (use "hardcopy push <file>" to push changes)');
1098
+ console.log(' (use "hardcopy diff <file>" to see changes)\n');
1099
+ for (const file of status.changedFiles) {
1100
+ const marker = file.status === 'new' ? 'new file:' : 'modified:';
1101
+ console.log(` ${marker} ${file.path}`);
1102
+ }
1103
+ console.log();
1104
+ } else if (conflicts.length === 0) {
1105
+ console.log('No local changes\n');
1106
+ }
1107
+
1108
+ // ... rest of status
1109
+ } finally {
1110
+ await hc.close();
1111
+ }
1112
+ });
1113
+ ```
1114
+
1115
+ ---
1116
+
1117
+ ## Task 9: Tests
1118
+
1119
+ **File**: `src/__tests__/conflict.test.ts` (new file)
1120
+
1121
+ ```typescript
1122
+ import { describe, it, expect } from 'vitest';
1123
+ import {
1124
+ detectFieldConflict,
1125
+ detectConflicts,
1126
+ hasUnresolvableConflicts,
1127
+ autoMergeField,
1128
+ generateConflictMarkers,
1129
+ } from '../conflict';
1130
+ import { ConflictStatus } from '../types';
1131
+
1132
+ describe('detectFieldConflict', () => {
1133
+ it('returns CLEAN when nothing changed', () => {
1134
+ const result = detectFieldConflict('body', {
1135
+ base: 'hello',
1136
+ local: 'hello',
1137
+ remote: 'hello',
1138
+ });
1139
+ expect(result.status).toBe(ConflictStatus.CLEAN);
1140
+ });
1141
+
1142
+ it('returns CLEAN when only local changed', () => {
1143
+ const result = detectFieldConflict('body', {
1144
+ base: 'hello',
1145
+ local: 'hello world',
1146
+ remote: 'hello',
1147
+ });
1148
+ expect(result.status).toBe(ConflictStatus.CLEAN);
1149
+ });
1150
+
1151
+ it('returns REMOTE_ONLY when only remote changed', () => {
1152
+ const result = detectFieldConflict('body', {
1153
+ base: 'hello',
1154
+ local: 'hello',
1155
+ remote: 'hello world',
1156
+ });
1157
+ expect(result.status).toBe(ConflictStatus.REMOTE_ONLY);
1158
+ });
1159
+
1160
+ it('returns DIVERGED when both changed differently', () => {
1161
+ const result = detectFieldConflict('body', {
1162
+ base: 'hello',
1163
+ local: 'hello local',
1164
+ remote: 'hello remote',
1165
+ });
1166
+ expect(result.status).toBe(ConflictStatus.DIVERGED);
1167
+ });
1168
+
1169
+ it('returns CLEAN when both changed to same value', () => {
1170
+ const result = detectFieldConflict('body', {
1171
+ base: 'hello',
1172
+ local: 'hello world',
1173
+ remote: 'hello world',
1174
+ });
1175
+ expect(result.status).toBe(ConflictStatus.CLEAN);
1176
+ });
1177
+ });
1178
+
1179
+ describe('autoMergeField', () => {
1180
+ it('merges list additions from both sides', () => {
1181
+ const conflict = {
1182
+ field: 'labels',
1183
+ status: ConflictStatus.DIVERGED,
1184
+ base: ['bug'],
1185
+ local: ['bug', 'urgent'],
1186
+ remote: ['bug', 'help-wanted'],
1187
+ canAutoMerge: true,
1188
+ };
1189
+ const result = autoMergeField(conflict);
1190
+ expect(result).toEqual(expect.arrayContaining(['bug', 'urgent', 'help-wanted']));
1191
+ });
1192
+
1193
+ it('returns null for non-mergeable fields', () => {
1194
+ const conflict = {
1195
+ field: 'body',
1196
+ status: ConflictStatus.DIVERGED,
1197
+ base: 'hello',
1198
+ local: 'hello local',
1199
+ remote: 'hello remote',
1200
+ canAutoMerge: false,
1201
+ };
1202
+ expect(autoMergeField(conflict)).toBeNull();
1203
+ });
1204
+ });
1205
+
1206
+ describe('generateConflictMarkers', () => {
1207
+ it('generates diff3-style markers', () => {
1208
+ const conflict = {
1209
+ field: 'body',
1210
+ status: ConflictStatus.DIVERGED,
1211
+ base: 'base text',
1212
+ local: 'local text',
1213
+ remote: 'remote text',
1214
+ canAutoMerge: false,
1215
+ };
1216
+ const result = generateConflictMarkers(conflict);
1217
+ expect(result).toContain('<<<<<<< LOCAL');
1218
+ expect(result).toContain('local text');
1219
+ expect(result).toContain('||||||| BASE');
1220
+ expect(result).toContain('base text');
1221
+ expect(result).toContain('=======');
1222
+ expect(result).toContain('remote text');
1223
+ expect(result).toContain('>>>>>>> REMOTE');
1224
+ });
1225
+ });
1226
+ ```
1227
+
1228
+ ---
1229
+
1230
+ ## File Summary
1231
+
1232
+ | File | Action | Description |
1233
+ |------|--------|-------------|
1234
+ | `src/types.ts` | Create/Update | Add ConflictStatus, FieldConflict, ConflictInfo types |
1235
+ | `src/provider.ts` | Update | Add `fetchNode` to Provider interface |
1236
+ | `src/providers/github.ts` | Update | Implement `fetchNode` |
1237
+ | `src/conflict.ts` | Create | Core conflict detection and merge logic |
1238
+ | `src/conflict-store.ts` | Create | File-based conflict storage |
1239
+ | `src/hardcopy.ts` | Update | Integrate conflict detection into push, add resolve methods |
1240
+ | `src/cli.ts` | Update | Add `conflicts`, `resolve` commands; update `status`, `push` |
1241
+ | `src/__tests__/conflict.test.ts` | Create | Unit tests |
1242
+
1243
+ ---
1244
+
1245
+ ## Execution Order
1246
+
1247
+ 1. **Types** (zero dependencies)
1248
+ 2. **Conflict module** (depends on types)
1249
+ 3. **Conflict store** (depends on types, conflict)
1250
+ 4. **Provider interface** (zero dependencies)
1251
+ 5. **GitHub provider** (depends on provider interface)
1252
+ 6. **Hardcopy class** (depends on all above)
1253
+ 7. **CLI commands** (depends on hardcopy class)
1254
+ 8. **Tests** (parallel with above)