@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,136 @@
1
+ import { mkdir, readFile, readdir, rm, writeFile } from "fs/promises";
2
+ import { join } from "path";
3
+ import matter from "gray-matter";
4
+ import type { ConflictInfo, FieldConflict } from "./types";
5
+ import { generateConflictMarkers } from "./conflict";
6
+
7
+ export class ConflictStore {
8
+ private conflictsDir: string;
9
+
10
+ constructor(conflictsDir: string) {
11
+ this.conflictsDir = conflictsDir;
12
+ }
13
+
14
+ async save(info: ConflictInfo): Promise<void> {
15
+ await mkdir(this.conflictsDir, { recursive: true });
16
+ const filePath = this.getPath(info.nodeId);
17
+ const body = formatConflictBody(info.fields);
18
+ const frontmatter = {
19
+ nodeId: info.nodeId,
20
+ nodeType: info.nodeType,
21
+ filePath: info.filePath,
22
+ detectedAt: new Date(info.detectedAt).toISOString(),
23
+ fields: info.fields.map((field) => ({
24
+ field: field.field,
25
+ status: field.status,
26
+ canAutoMerge: field.canAutoMerge,
27
+ })),
28
+ };
29
+
30
+ await writeFile(filePath, matter.stringify(body, frontmatter));
31
+ }
32
+
33
+ async list(): Promise<ConflictInfo[]> {
34
+ const entries = await readdir(this.conflictsDir).catch(() => []);
35
+ const conflicts: ConflictInfo[] = [];
36
+
37
+ for (const entry of entries) {
38
+ if (!entry.endsWith(".md")) continue;
39
+ const fullPath = join(this.conflictsDir, entry);
40
+ try {
41
+ const content = await readFile(fullPath, "utf-8");
42
+ const parsed = matter(content);
43
+ const data = parsed.data as Record<string, unknown>;
44
+ const fields = parseStoredFields(data["fields"]);
45
+ const detectedAt = Date.parse(String(data["detectedAt"] ?? ""));
46
+
47
+ conflicts.push({
48
+ nodeId: String(data["nodeId"] ?? ""),
49
+ nodeType: String(data["nodeType"] ?? ""),
50
+ filePath: String(data["filePath"] ?? ""),
51
+ detectedAt: Number.isNaN(detectedAt) ? 0 : detectedAt,
52
+ fields,
53
+ });
54
+ } catch {
55
+ continue;
56
+ }
57
+ }
58
+
59
+ return conflicts.filter((conflict) => conflict.nodeId.length > 0);
60
+ }
61
+
62
+ async get(nodeId: string): Promise<ConflictInfo | null> {
63
+ const result = await this.read(nodeId);
64
+ return result?.info ?? null;
65
+ }
66
+
67
+ async read(
68
+ nodeId: string,
69
+ ): Promise<{ info: ConflictInfo; body: string } | null> {
70
+ const filePath = this.getPath(nodeId);
71
+ try {
72
+ const content = await readFile(filePath, "utf-8");
73
+ const parsed = matter(content);
74
+ const data = parsed.data as Record<string, unknown>;
75
+ const fields = parseStoredFields(data["fields"]);
76
+ const detectedAt = Date.parse(String(data["detectedAt"] ?? ""));
77
+
78
+ const info = {
79
+ nodeId: String(data["nodeId"] ?? nodeId),
80
+ nodeType: String(data["nodeType"] ?? ""),
81
+ filePath: String(data["filePath"] ?? ""),
82
+ detectedAt: Number.isNaN(detectedAt) ? 0 : detectedAt,
83
+ fields,
84
+ };
85
+
86
+ return { info, body: parsed.content };
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ async remove(nodeId: string): Promise<void> {
93
+ const filePath = this.getPath(nodeId);
94
+ try {
95
+ await rm(filePath, { force: true });
96
+ } catch {
97
+ return;
98
+ }
99
+ }
100
+
101
+ private getPath(nodeId: string): string {
102
+ return join(this.conflictsDir, `${encodeURIComponent(nodeId)}.md`);
103
+ }
104
+
105
+ getArtifactPath(nodeId: string): string {
106
+ return this.getPath(nodeId);
107
+ }
108
+ }
109
+
110
+ function parseStoredFields(value: unknown): FieldConflict[] {
111
+ if (!Array.isArray(value)) return [];
112
+ return value
113
+ .map((item): FieldConflict | null => {
114
+ if (!item || typeof item !== "object") return null;
115
+ const record = item as Record<string, unknown>;
116
+ const field = String(record["field"] ?? "");
117
+ if (!field) return null;
118
+ return {
119
+ field,
120
+ status: String(record["status"] ?? "clean") as FieldConflict["status"],
121
+ canAutoMerge: Boolean(record["canAutoMerge"]),
122
+ base: null,
123
+ local: null,
124
+ remote: null,
125
+ };
126
+ })
127
+ .filter((item): item is FieldConflict => item !== null);
128
+ }
129
+
130
+ function formatConflictBody(fields: FieldConflict[]): string {
131
+ const blocks = fields
132
+ .filter((field) => field.status === "diverged")
133
+ .map((field) => `## ${field.field}\n${generateConflictMarkers(field)}`);
134
+
135
+ return blocks.join("\n\n");
136
+ }
@@ -0,0 +1,147 @@
1
+ import type { ParsedFile } from "./format";
2
+ import type { Node } from "./types";
3
+ import {
4
+ ConflictStatus,
5
+ type FieldConflict,
6
+ type ThreeWayState,
7
+ } from "./types";
8
+
9
+ export function detectFieldConflict(
10
+ field: string,
11
+ state: ThreeWayState,
12
+ ): FieldConflict {
13
+ const localChanged = !valuesEqual(state.local, state.base);
14
+ const remoteChanged = !valuesEqual(state.remote, state.base);
15
+
16
+ let status: ConflictStatus;
17
+ if (!localChanged && !remoteChanged) {
18
+ status = ConflictStatus.CLEAN;
19
+ } else if (localChanged && !remoteChanged) {
20
+ status = ConflictStatus.CLEAN;
21
+ } else if (!localChanged && remoteChanged) {
22
+ status = ConflictStatus.REMOTE_ONLY;
23
+ } else if (valuesEqual(state.local, state.remote)) {
24
+ status = ConflictStatus.CLEAN;
25
+ } else {
26
+ status = ConflictStatus.DIVERGED;
27
+ }
28
+
29
+ return {
30
+ field,
31
+ status,
32
+ base: state.base,
33
+ local: state.local,
34
+ remote: state.remote,
35
+ canAutoMerge: isListField(state),
36
+ };
37
+ }
38
+
39
+ export function detectConflicts(
40
+ baseNode: Node,
41
+ localParsed: ParsedFile,
42
+ remoteNode: Node,
43
+ editableFields: string[],
44
+ ): FieldConflict[] {
45
+ const conflicts: FieldConflict[] = [];
46
+ const baseAttrs = baseNode.attrs as Record<string, unknown>;
47
+ const remoteAttrs = remoteNode.attrs as Record<string, unknown>;
48
+
49
+ for (const field of editableFields) {
50
+ const state: ThreeWayState =
51
+ field === "body"
52
+ ? {
53
+ base: (baseAttrs["body"] as string) ?? "",
54
+ local: localParsed.body ?? "",
55
+ remote: (remoteAttrs["body"] as string) ?? "",
56
+ }
57
+ : {
58
+ base: baseAttrs[field],
59
+ local: localParsed.attrs[field],
60
+ remote: remoteAttrs[field],
61
+ };
62
+
63
+ conflicts.push(detectFieldConflict(field, state));
64
+ }
65
+
66
+ return conflicts;
67
+ }
68
+
69
+ export function hasUnresolvableConflicts(conflicts: FieldConflict[]): boolean {
70
+ return conflicts.some(
71
+ (conflict) =>
72
+ conflict.status === ConflictStatus.DIVERGED && !conflict.canAutoMerge,
73
+ );
74
+ }
75
+
76
+ export function autoMergeField(conflict: FieldConflict): unknown | null {
77
+ if (conflict.status !== ConflictStatus.DIVERGED || !conflict.canAutoMerge) {
78
+ return null;
79
+ }
80
+
81
+ const merged = uniqueList([
82
+ ...(Array.isArray(conflict.base) ? conflict.base : []),
83
+ ...(Array.isArray(conflict.local) ? conflict.local : []),
84
+ ...(Array.isArray(conflict.remote) ? conflict.remote : []),
85
+ ]);
86
+
87
+ return merged;
88
+ }
89
+
90
+ export function generateConflictMarkers(conflict: FieldConflict): string {
91
+ const local = String(conflict.local ?? "");
92
+ const base = String(conflict.base ?? "");
93
+ const remote = String(conflict.remote ?? "");
94
+ return `<<<<<<< LOCAL\n${local}\n||||||| BASE\n${base}\n=======\n${remote}\n>>>>>>> REMOTE`;
95
+ }
96
+
97
+ export function parseConflictMarkers(text: string): {
98
+ local: string;
99
+ base: string;
100
+ remote: string;
101
+ } | null {
102
+ const match = text.match(
103
+ /<<<<<<< LOCAL\r?\n([\s\S]*?)\r?\n\|\|\|\|\|\|\| BASE\r?\n([\s\S]*?)\r?\n=======\r?\n([\s\S]*?)\r?\n>>>>>>> REMOTE/,
104
+ );
105
+ if (!match) return null;
106
+ return {
107
+ local: match[1] ?? "",
108
+ base: match[2] ?? "",
109
+ remote: match[3] ?? "",
110
+ };
111
+ }
112
+
113
+ function valuesEqual(a: unknown, b: unknown): boolean {
114
+ if (a === b) return true;
115
+ if (a == null && b == null) return true;
116
+ if (Array.isArray(a) && Array.isArray(b)) {
117
+ const normalizedA = normalizeArray(a);
118
+ const normalizedB = normalizeArray(b);
119
+ if (normalizedA.length !== normalizedB.length) return false;
120
+ return normalizedA.every((value, index) => value === normalizedB[index]);
121
+ }
122
+ return JSON.stringify(a) === JSON.stringify(b);
123
+ }
124
+
125
+ function normalizeArray(values: unknown[]): string[] {
126
+ return values.map((value) => JSON.stringify(value)).sort();
127
+ }
128
+
129
+ function isListField(state: ThreeWayState): boolean {
130
+ return (
131
+ Array.isArray(state.base) ||
132
+ Array.isArray(state.local) ||
133
+ Array.isArray(state.remote)
134
+ );
135
+ }
136
+
137
+ function uniqueList(values: unknown[]): unknown[] {
138
+ const seen = new Set<string>();
139
+ const result: unknown[] = [];
140
+ for (const value of values) {
141
+ const key = JSON.stringify(value);
142
+ if (seen.has(key)) continue;
143
+ seen.add(key);
144
+ result.push(value);
145
+ }
146
+ return result;
147
+ }
package/src/crdt.ts ADDED
@@ -0,0 +1,100 @@
1
+ import { Loro, LoroDoc, LoroMap } from "loro-crdt";
2
+ import { readFile, writeFile, mkdir, access, rm } from "fs/promises";
3
+ import { dirname, join } from "path";
4
+
5
+ export class CRDTStore {
6
+ private basePath: string;
7
+
8
+ constructor(basePath: string) {
9
+ this.basePath = basePath;
10
+ }
11
+
12
+ private getPath(nodeId: string): string {
13
+ const encoded = encodeURIComponent(nodeId);
14
+ return join(this.basePath, `${encoded}.loro`);
15
+ }
16
+
17
+ async exists(nodeId: string): Promise<boolean> {
18
+ try {
19
+ await access(this.getPath(nodeId));
20
+ return true;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ async load(nodeId: string): Promise<LoroDoc | null> {
27
+ const path = this.getPath(nodeId);
28
+ try {
29
+ const data = await readFile(path);
30
+ const doc = new LoroDoc();
31
+ doc.import(new Uint8Array(data));
32
+ return doc;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ async save(nodeId: string, doc: LoroDoc): Promise<void> {
39
+ const path = this.getPath(nodeId);
40
+ await mkdir(dirname(path), { recursive: true });
41
+ const snapshot = doc.export({ mode: "snapshot" });
42
+ await writeFile(path, Buffer.from(snapshot));
43
+ }
44
+
45
+ async create(nodeId: string): Promise<LoroDoc> {
46
+ const doc = new LoroDoc();
47
+ await this.save(nodeId, doc);
48
+ return doc;
49
+ }
50
+
51
+ async loadOrCreate(nodeId: string): Promise<LoroDoc> {
52
+ const existing = await this.load(nodeId);
53
+ if (existing) return existing;
54
+ return this.create(nodeId);
55
+ }
56
+
57
+ async delete(nodeId: string): Promise<void> {
58
+ try {
59
+ await rm(this.getPath(nodeId));
60
+ } catch {
61
+ // Ignore if not exists
62
+ }
63
+ }
64
+
65
+ async merge(nodeId: string, remote: LoroDoc): Promise<LoroDoc> {
66
+ const local = await this.loadOrCreate(nodeId);
67
+ local.import(remote.export({ mode: "update" }));
68
+ await this.save(nodeId, local);
69
+ return local;
70
+ }
71
+ }
72
+
73
+ export function setDocContent(doc: LoroDoc, content: string): void {
74
+ const text = doc.getText("body");
75
+ const current = text.toString();
76
+ if (current !== content) {
77
+ text.delete(0, current.length);
78
+ text.insert(0, content);
79
+ }
80
+ }
81
+
82
+ export function getDocContent(doc: LoroDoc): string {
83
+ return doc.getText("body").toString();
84
+ }
85
+
86
+ export function setDocAttrs(doc: LoroDoc, attrs: Record<string, unknown>): void {
87
+ const map = doc.getMap("attrs");
88
+ for (const [key, value] of Object.entries(attrs)) {
89
+ map.set(key, value);
90
+ }
91
+ }
92
+
93
+ export function getDocAttrs(doc: LoroDoc): Record<string, unknown> {
94
+ const map = doc.getMap("attrs");
95
+ const result: Record<string, unknown> = {};
96
+ for (const key of map.keys()) {
97
+ result[key] = map.get(key);
98
+ }
99
+ return result;
100
+ }