@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,444 @@
1
+ import { join } from "path";
2
+ import { readFile, writeFile } from "fs/promises";
3
+ import matter from "gray-matter";
4
+ import { setDocContent } from "../crdt";
5
+ import {
6
+ autoMergeField,
7
+ detectConflicts,
8
+ hasUnresolvableConflicts,
9
+ parseConflictMarkers,
10
+ } from "../conflict";
11
+ import { mergeText } from "../merge";
12
+ import { parseFile, getFormat } from "../format";
13
+ import type { Provider } from "../provider";
14
+ import {
15
+ ConflictStatus,
16
+ type Change,
17
+ type ConflictInfo,
18
+ type FieldConflict,
19
+ } from "../types";
20
+ import type { Hardcopy } from "./core";
21
+ import type { PushStats, DiffResult, StatusInfo } from "./types";
22
+ import { diff } from "./diff";
23
+ import { detectChanges } from "./diff";
24
+
25
+ export async function push(
26
+ this: Hardcopy,
27
+ filePath?: string,
28
+ options: { force?: boolean } = {},
29
+ ): Promise<PushStats> {
30
+ await this.loadConfig();
31
+ const db = this.getDatabase();
32
+ const crdt = this.getCRDTStore();
33
+ const stats: PushStats = {
34
+ pushed: 0,
35
+ skipped: 0,
36
+ conflicts: 0,
37
+ errors: [],
38
+ };
39
+
40
+ const diffs = await diff.call(this, filePath);
41
+
42
+ for (const diffResult of diffs) {
43
+ if (diffResult.changes.length === 0) {
44
+ stats.skipped++;
45
+ continue;
46
+ }
47
+
48
+ const provider = findProviderForNode.call(this, diffResult.nodeId);
49
+ if (!provider) {
50
+ stats.errors.push(`No provider for ${diffResult.nodeId}`);
51
+ continue;
52
+ }
53
+
54
+ const dbNode = await db.getNode(diffResult.nodeId);
55
+ if (!dbNode) {
56
+ stats.errors.push(`Node not found: ${diffResult.nodeId}`);
57
+ continue;
58
+ }
59
+
60
+ const format = getFormat(dbNode.type);
61
+ if (!format) {
62
+ stats.errors.push(`No format for ${dbNode.type}`);
63
+ continue;
64
+ }
65
+
66
+ try {
67
+ const localParsed = await parseLocalFile(diffResult.filePath);
68
+ if (!localParsed) {
69
+ stats.errors.push(`Failed to parse ${diffResult.filePath}`);
70
+ continue;
71
+ }
72
+
73
+ const remoteNode = await provider.fetchNode(diffResult.nodeId);
74
+ let changes = diffResult.changes;
75
+
76
+ if (remoteNode && !options.force) {
77
+ let conflicts = detectConflicts(
78
+ dbNode,
79
+ localParsed,
80
+ remoteNode,
81
+ format.editableFields,
82
+ );
83
+
84
+ const semanticMerges = await trySemanticMerges.call(
85
+ this,
86
+ conflicts,
87
+ diffResult.filePath,
88
+ );
89
+ if (semanticMerges.size > 0) {
90
+ changes = applySemanticMerges(changes, semanticMerges);
91
+ conflicts = conflicts.map((conflict) =>
92
+ semanticMerges.has(conflict.field)
93
+ ? { ...conflict, status: ConflictStatus.CLEAN }
94
+ : conflict,
95
+ );
96
+ }
97
+
98
+ if (hasUnresolvableConflicts(conflicts)) {
99
+ await saveConflict.call(this, diffResult, conflicts, dbNode.type);
100
+ stats.conflicts++;
101
+ continue;
102
+ }
103
+
104
+ changes = applyAutoMerges(changes, conflicts);
105
+ }
106
+
107
+ const result = await provider.push(dbNode, changes);
108
+ if (result.success) {
109
+ const updatedAttrs = { ...dbNode.attrs };
110
+ for (const change of changes) {
111
+ updatedAttrs[change.field] = change.newValue;
112
+ }
113
+ await db.upsertNode({
114
+ ...dbNode,
115
+ attrs: updatedAttrs,
116
+ syncedAt: Date.now(),
117
+ });
118
+
119
+ const doc = await crdt.loadOrCreate(diffResult.nodeId);
120
+ const bodyChange = changes.find((c) => c.field === "body");
121
+ if (bodyChange) {
122
+ setDocContent(doc, bodyChange.newValue as string);
123
+ }
124
+ await crdt.save(diffResult.nodeId, doc);
125
+
126
+ try {
127
+ await updateLocalFileAfterPush(diffResult.filePath, changes);
128
+ } catch (err) {
129
+ stats.errors.push(
130
+ `Failed to update local file ${diffResult.filePath}: ${err}`,
131
+ );
132
+ }
133
+
134
+ stats.pushed++;
135
+ } else {
136
+ stats.errors.push(`Push failed for ${diffResult.nodeId}: ${result.error}`);
137
+ }
138
+ } catch (err) {
139
+ stats.errors.push(`Error pushing ${diffResult.nodeId}: ${err}`);
140
+ }
141
+ }
142
+
143
+ return stats;
144
+ }
145
+
146
+ export async function status(this: Hardcopy): Promise<StatusInfo> {
147
+ const db = this.getDatabase();
148
+ const nodes = await db.queryNodes();
149
+ const [edges] = await Promise.all([db.getEdges()]);
150
+
151
+ const byType = new Map<string, number>();
152
+ for (const node of nodes) {
153
+ byType.set(node.type, (byType.get(node.type) ?? 0) + 1);
154
+ }
155
+
156
+ const { getChangedFiles } = await import("./diff");
157
+ const changedFiles = await getChangedFiles.call(this);
158
+ const conflicts = await listConflicts.call(this);
159
+
160
+ return {
161
+ totalNodes: nodes.length,
162
+ totalEdges: edges.length,
163
+ nodesByType: Object.fromEntries(byType),
164
+ changedFiles,
165
+ conflicts,
166
+ };
167
+ }
168
+
169
+ export async function listConflicts(this: Hardcopy): Promise<ConflictInfo[]> {
170
+ return this.getConflictStore().list();
171
+ }
172
+
173
+ export async function getConflict(
174
+ this: Hardcopy,
175
+ nodeId: string,
176
+ ): Promise<ConflictInfo | null> {
177
+ return this.getConflictStore().get(nodeId);
178
+ }
179
+
180
+ export async function getConflictDetail(
181
+ this: Hardcopy,
182
+ nodeId: string,
183
+ ): Promise<{
184
+ info: ConflictInfo;
185
+ body: string;
186
+ artifactPath: string;
187
+ } | null> {
188
+ const store = this.getConflictStore();
189
+ const detail = await store.read(nodeId);
190
+ if (!detail) return null;
191
+ return {
192
+ info: detail.info,
193
+ body: detail.body,
194
+ artifactPath: store.getArtifactPath(nodeId),
195
+ };
196
+ }
197
+
198
+ export async function resolveConflict(
199
+ this: Hardcopy,
200
+ nodeId: string,
201
+ resolution: Record<string, "local" | "remote">,
202
+ ): Promise<void> {
203
+ const store = this.getConflictStore();
204
+ const conflict = await store.read(nodeId);
205
+ if (!conflict) throw new Error(`Conflict not found: ${nodeId}`);
206
+
207
+ const db = this.getDatabase();
208
+ const dbNode = await db.getNode(nodeId);
209
+ if (!dbNode) throw new Error(`Node not found: ${nodeId}`);
210
+
211
+ const provider = findProviderForNode.call(this, nodeId);
212
+ if (!provider) throw new Error(`No provider for ${nodeId}`);
213
+
214
+ const format = getFormat(dbNode.type);
215
+ if (!format) throw new Error(`No format for ${dbNode.type}`);
216
+
217
+ const blocks = parseConflictBlocks(conflict.body);
218
+ if (blocks.size === 0) {
219
+ throw new Error(`No conflict markers found for ${nodeId}`);
220
+ }
221
+
222
+ const fileContent = await readFile(conflict.info.filePath, "utf-8");
223
+ const parsed = matter(fileContent);
224
+ const attrs = parsed.data as Record<string, unknown>;
225
+ let body = parsed.content.trim();
226
+
227
+ const updatedAttrs = { ...dbNode.attrs };
228
+
229
+ for (const [field, choice] of Object.entries(resolution)) {
230
+ const block = blocks.get(field);
231
+ if (!block) continue;
232
+ const value = choice === "local" ? block.local : block.remote;
233
+
234
+ if (field === "body") {
235
+ body = value;
236
+ updatedAttrs["body"] = value;
237
+ } else {
238
+ attrs[field] = value;
239
+ updatedAttrs[field] = value;
240
+ }
241
+ }
242
+
243
+ const nextContent = matter.stringify(body, attrs);
244
+ await writeFile(conflict.info.filePath, nextContent);
245
+
246
+ const parsedForChanges = { attrs, body };
247
+ const changes = detectChanges(
248
+ parsedForChanges,
249
+ dbNode,
250
+ format.editableFields,
251
+ );
252
+
253
+ if (changes.length > 0) {
254
+ const result = await provider.push(dbNode, changes);
255
+ if (!result.success) {
256
+ throw new Error(`Push failed for ${nodeId}: ${result.error}`);
257
+ }
258
+ }
259
+
260
+ await db.upsertNode({
261
+ ...dbNode,
262
+ attrs: updatedAttrs,
263
+ syncedAt: Date.now(),
264
+ });
265
+
266
+ const crdt = this.getCRDTStore();
267
+ const doc = await crdt.loadOrCreate(nodeId);
268
+ if (updatedAttrs["body"] !== undefined) {
269
+ setDocContent(doc, String(updatedAttrs["body"] ?? ""));
270
+ }
271
+ await crdt.save(nodeId, doc);
272
+
273
+ await store.remove(nodeId);
274
+ }
275
+
276
+ function findProviderForNode(this: Hardcopy, nodeId: string): Provider | undefined {
277
+ const [providerPrefix] = nodeId.split(":");
278
+ for (const [, provider] of this.getProviders()) {
279
+ if (provider.name === providerPrefix) return provider;
280
+ }
281
+ return undefined;
282
+ }
283
+
284
+ async function parseLocalFile(
285
+ fullPath: string,
286
+ ): Promise<{ attrs: Record<string, unknown>; body: string } | null> {
287
+ try {
288
+ const content = await readFile(fullPath, "utf-8");
289
+ return parseFile(content, "generic");
290
+ } catch {
291
+ return null;
292
+ }
293
+ }
294
+
295
+ async function updateLocalFileAfterPush(
296
+ fullPath: string,
297
+ changes: Change[],
298
+ ): Promise<void> {
299
+ const content = await readFile(fullPath, "utf-8");
300
+ const parsed = matter(content);
301
+ const attrs = parsed.data as Record<string, unknown>;
302
+ let body = parsed.content;
303
+
304
+ for (const change of changes) {
305
+ if (change.field === "body") {
306
+ body = String(change.newValue ?? "");
307
+ continue;
308
+ }
309
+ if (change.newValue === undefined || change.newValue === null) {
310
+ delete attrs[change.field];
311
+ } else {
312
+ attrs[change.field] = change.newValue;
313
+ }
314
+ }
315
+
316
+ const nextContent = matter.stringify(body, attrs);
317
+ await writeFile(fullPath, nextContent);
318
+ }
319
+
320
+ async function saveConflict(
321
+ this: Hardcopy,
322
+ diffResult: DiffResult,
323
+ conflicts: FieldConflict[],
324
+ nodeType: string,
325
+ ): Promise<void> {
326
+ const store = this.getConflictStore();
327
+ await store.save({
328
+ nodeId: diffResult.nodeId,
329
+ nodeType,
330
+ filePath: diffResult.filePath,
331
+ detectedAt: Date.now(),
332
+ fields: conflicts,
333
+ });
334
+ }
335
+
336
+ function applyAutoMerges(
337
+ changes: Change[],
338
+ conflicts: FieldConflict[],
339
+ ): Change[] {
340
+ const mergedByField = new Map<string, unknown>();
341
+ for (const conflict of conflicts) {
342
+ const merged = autoMergeField(conflict);
343
+ if (merged !== null) {
344
+ mergedByField.set(conflict.field, merged);
345
+ }
346
+ }
347
+
348
+ if (mergedByField.size === 0) return changes;
349
+
350
+ const mergedChanges = changes.map((change) => {
351
+ if (!mergedByField.has(change.field)) return change;
352
+ return {
353
+ ...change,
354
+ newValue: mergedByField.get(change.field),
355
+ };
356
+ });
357
+
358
+ for (const [field, value] of mergedByField) {
359
+ if (!mergedChanges.find((change) => change.field === field)) {
360
+ mergedChanges.push({ field, oldValue: undefined, newValue: value });
361
+ }
362
+ }
363
+
364
+ return mergedChanges;
365
+ }
366
+
367
+ async function trySemanticMerges(
368
+ this: Hardcopy,
369
+ conflicts: FieldConflict[],
370
+ filePath: string,
371
+ ): Promise<Map<string, string>> {
372
+ const result = new Map<string, string>();
373
+ const bodyConflict = conflicts.find(
374
+ (conflict) =>
375
+ conflict.field === "body" &&
376
+ conflict.status === ConflictStatus.DIVERGED &&
377
+ !conflict.canAutoMerge,
378
+ );
379
+
380
+ if (!bodyConflict) return result;
381
+
382
+ const base = String(bodyConflict.base ?? "");
383
+ const local = String(bodyConflict.local ?? "");
384
+ const remote = String(bodyConflict.remote ?? "");
385
+
386
+ const merged = await mergeText(base, local, remote, {
387
+ tempDir: join(this.dataDir, "tmp", "merge"),
388
+ filePath,
389
+ llmOptions: {
390
+ baseURL: process.env.OPENAI_BASE_URL,
391
+ model: process.env.OPENAI_MODEL,
392
+ apiKey: process.env.OPENAI_API_KEY,
393
+ },
394
+ });
395
+
396
+ if (merged !== null) {
397
+ result.set("body", merged);
398
+ }
399
+
400
+ return result;
401
+ }
402
+
403
+ function applySemanticMerges(
404
+ changes: Change[],
405
+ merged: Map<string, string>,
406
+ ): Change[] {
407
+ if (merged.size === 0) return changes;
408
+
409
+ const next = changes.map((change) => {
410
+ if (!merged.has(change.field)) return change;
411
+ return { ...change, newValue: merged.get(change.field) };
412
+ });
413
+
414
+ for (const [field, value] of merged) {
415
+ if (!next.find((change) => change.field === field)) {
416
+ next.push({ field, oldValue: undefined, newValue: value });
417
+ }
418
+ }
419
+
420
+ return next;
421
+ }
422
+
423
+ function parseConflictBlocks(
424
+ content: string,
425
+ ): Map<string, { local: string; base: string; remote: string }> {
426
+ const blocks = new Map<
427
+ string,
428
+ { local: string; base: string; remote: string }
429
+ >();
430
+ const regex = /^\s*##\s+(.+?)\r?\n([\s\S]*?)(?=^\s*##\s+|\s*$)/gm;
431
+ for (const match of content.matchAll(regex)) {
432
+ const field = match[1]?.trim();
433
+ const block = match[2] ?? "";
434
+ if (!field) continue;
435
+ const parsed = parseConflictMarkers(block);
436
+ if (parsed) blocks.set(field, parsed);
437
+ }
438
+
439
+ if (blocks.size === 0) {
440
+ const parsed = parseConflictMarkers(content);
441
+ if (parsed) blocks.set("body", parsed);
442
+ }
443
+ return blocks;
444
+ }
@@ -0,0 +1,37 @@
1
+ import type { Hardcopy } from "./core";
2
+ import type { SyncStats } from "./types";
3
+
4
+ export async function sync(this: Hardcopy): Promise<SyncStats> {
5
+ const config = await this.loadConfig();
6
+ const db = this.getDatabase();
7
+ const providers = this.getProviders();
8
+ const stats: SyncStats = { nodes: 0, edges: 0, errors: [] };
9
+
10
+ for (const source of config.sources) {
11
+ const provider = providers.get(source.name);
12
+ if (!provider) {
13
+ stats.errors.push(`Provider not found: ${source.provider}`);
14
+ continue;
15
+ }
16
+
17
+ try {
18
+ const result = await provider.fetch({ query: {} });
19
+ if (!result.cached) {
20
+ await db.upsertNodes(
21
+ result.nodes.map((n) => ({
22
+ ...n,
23
+ syncedAt: Date.now(),
24
+ versionToken: result.versionToken ?? undefined,
25
+ })),
26
+ );
27
+ await db.upsertEdges(result.edges);
28
+ stats.nodes += result.nodes.length;
29
+ stats.edges += result.edges.length;
30
+ }
31
+ } catch (err) {
32
+ stats.errors.push(`Error syncing ${source.name}: ${err}`);
33
+ }
34
+ }
35
+
36
+ return stats;
37
+ }
@@ -0,0 +1,49 @@
1
+ import type { Change, ConflictInfo } from "../types";
2
+
3
+ export interface HardcopyOptions {
4
+ root: string;
5
+ }
6
+
7
+ export interface SyncStats {
8
+ nodes: number;
9
+ edges: number;
10
+ errors: string[];
11
+ }
12
+
13
+ export interface StatusInfo {
14
+ totalNodes: number;
15
+ totalEdges: number;
16
+ nodesByType: Record<string, number>;
17
+ changedFiles: ChangedFile[];
18
+ conflicts: ConflictInfo[];
19
+ }
20
+
21
+ export interface ChangedFile {
22
+ path: string;
23
+ fullPath: string;
24
+ nodeId: string;
25
+ nodeType: string;
26
+ status: "new" | "modified" | "deleted";
27
+ mtime: number;
28
+ syncedAt: number;
29
+ }
30
+
31
+ export interface RefreshResult {
32
+ rendered: number;
33
+ orphaned: string[];
34
+ cleaned: boolean;
35
+ }
36
+
37
+ export interface DiffResult {
38
+ nodeId: string;
39
+ nodeType: string;
40
+ filePath: string;
41
+ changes: Change[];
42
+ }
43
+
44
+ export interface PushStats {
45
+ pushed: number;
46
+ skipped: number;
47
+ conflicts: number;
48
+ errors: string[];
49
+ }