@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
package/src/merge.ts ADDED
@@ -0,0 +1,75 @@
1
+ import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { spawnSync } from "node:child_process";
5
+ import { llmMergeText, type LLMMergeOptions } from "./llm-merge";
6
+
7
+ export interface SemanticMergeOptions {
8
+ tempDir?: string;
9
+ filePath: string;
10
+ /** LLM merge options (URL, model, etc.) */
11
+ llmOptions?: LLMMergeOptions;
12
+ }
13
+
14
+ /**
15
+ * Attempts a 3-way merge using diff3, with LLM fallback for conflicts.
16
+ * Returns the merged text if successful, or null if merge fails entirely.
17
+ */
18
+ export async function mergeText(
19
+ base: string,
20
+ local: string,
21
+ remote: string,
22
+ options: SemanticMergeOptions,
23
+ ): Promise<string | null> {
24
+ if (local === remote) return local;
25
+ if (local === base) return remote;
26
+ if (remote === base) return local;
27
+
28
+ // Try diff3 first
29
+ const diff3Result = await tryDiff3Merge(base, local, remote, options.tempDir);
30
+ if (diff3Result !== null) {
31
+ return diff3Result;
32
+ }
33
+
34
+ // Fall back to LLM merge for conflicts
35
+ return llmMergeText(base, local, remote, options.llmOptions);
36
+ }
37
+
38
+ async function tryDiff3Merge(
39
+ base: string,
40
+ local: string,
41
+ remote: string,
42
+ tempDir?: string,
43
+ ): Promise<string | null> {
44
+ const root = tempDir ?? join(tmpdir(), "hardcopy-merge");
45
+ await mkdir(root, { recursive: true });
46
+ const runDir = await mkdtemp(join(root, "merge-"));
47
+
48
+ const basePath = join(runDir, "base");
49
+ const localPath = join(runDir, "local");
50
+ const remotePath = join(runDir, "remote");
51
+
52
+ try {
53
+ await writeFile(basePath, base);
54
+ await writeFile(localPath, local);
55
+ await writeFile(remotePath, remote);
56
+
57
+ // diff3 -m: merge mode
58
+ // Order: local, base, remote (diff3 convention)
59
+ const result = spawnSync("diff3", ["-m", localPath, basePath, remotePath], {
60
+ encoding: "utf-8",
61
+ });
62
+
63
+ // Exit code 0 = clean merge, 1 = conflicts, 2 = error
64
+ if (result.status === 0 && typeof result.stdout === "string") {
65
+ return result.stdout;
66
+ }
67
+
68
+ // Conflicts or error - return null
69
+ return null;
70
+ } catch {
71
+ return null;
72
+ } finally {
73
+ await rm(runDir, { recursive: true, force: true });
74
+ }
75
+ }
@@ -0,0 +1,40 @@
1
+ import type {
2
+ Node,
3
+ Change,
4
+ FetchRequest,
5
+ FetchResult,
6
+ PushResult,
7
+ } from "./types";
8
+
9
+ export interface Tool {
10
+ name: string;
11
+ description: string;
12
+ parameters?: Record<string, unknown>;
13
+ }
14
+
15
+ export interface Provider {
16
+ name: string;
17
+ nodeTypes: string[];
18
+ edgeTypes: string[];
19
+
20
+ fetch(request: FetchRequest): Promise<FetchResult>;
21
+ push(node: Node, changes: Change[]): Promise<PushResult>;
22
+ fetchNode(nodeId: string): Promise<Node | null>;
23
+ getTools(): Tool[];
24
+ }
25
+
26
+ export type ProviderFactory = (config: Record<string, unknown>) => Provider;
27
+
28
+ const providers = new Map<string, ProviderFactory>();
29
+
30
+ export function registerProvider(name: string, factory: ProviderFactory): void {
31
+ providers.set(name, factory);
32
+ }
33
+
34
+ export function getProvider(name: string): ProviderFactory | undefined {
35
+ return providers.get(name);
36
+ }
37
+
38
+ export function listProviders(): string[] {
39
+ return Array.from(providers.keys());
40
+ }
@@ -0,0 +1,166 @@
1
+ import type { Provider, Tool } from "../../provider";
2
+ import type {
3
+ Node,
4
+ Edge,
5
+ Change,
6
+ FetchRequest,
7
+ FetchResult,
8
+ PushResult,
9
+ } from "../../types";
10
+ import { registerProvider } from "../../provider";
11
+
12
+ export interface A2AConfig {
13
+ endpoint?: string;
14
+ links?: {
15
+ edge: string;
16
+ to: string;
17
+ match: string;
18
+ }[];
19
+ }
20
+
21
+ export function createA2AProvider(config: A2AConfig): Provider {
22
+ return {
23
+ name: "a2a",
24
+ nodeTypes: ["a2a.Task", "a2a.Session", "a2a.Agent"],
25
+ edgeTypes: ["a2a.TRACKS", "a2a.CREATED_BY", "a2a.PART_OF"],
26
+
27
+ async fetch(request: FetchRequest): Promise<FetchResult> {
28
+ const nodes: Node[] = [];
29
+ const edges: Edge[] = [];
30
+
31
+ if (!config.endpoint) {
32
+ return { nodes, edges, hasMore: false };
33
+ }
34
+
35
+ try {
36
+ const response = await fetch(`${config.endpoint}/tasks`, {
37
+ headers: { Accept: "application/json" },
38
+ });
39
+
40
+ if (!response.ok) {
41
+ return { nodes, edges, hasMore: false };
42
+ }
43
+
44
+ const tasks = (await response.json()) as A2ATask[];
45
+
46
+ for (const task of tasks) {
47
+ const nodeId = `a2a:${task.id}`;
48
+ nodes.push({
49
+ id: nodeId,
50
+ type: "a2a.Task",
51
+ attrs: {
52
+ name: task.name,
53
+ status: task.status,
54
+ description: task.description,
55
+ created_at: task.created_at,
56
+ updated_at: task.updated_at,
57
+ meta: task.meta,
58
+ },
59
+ });
60
+
61
+ if (
62
+ task.meta?.github?.issue_number &&
63
+ task.meta?.github?.repository
64
+ ) {
65
+ edges.push({
66
+ type: "a2a.TRACKS",
67
+ fromId: nodeId,
68
+ toId: `github:${task.meta.github.repository}#${task.meta.github.issue_number}`,
69
+ });
70
+ }
71
+
72
+ if (task.agent_id) {
73
+ edges.push({
74
+ type: "a2a.CREATED_BY",
75
+ fromId: nodeId,
76
+ toId: `a2a:agent:${task.agent_id}`,
77
+ });
78
+ }
79
+
80
+ if (task.session_id) {
81
+ edges.push({
82
+ type: "a2a.PART_OF",
83
+ fromId: nodeId,
84
+ toId: `a2a:session:${task.session_id}`,
85
+ });
86
+ }
87
+ }
88
+ } catch {
89
+ // A2A endpoint not available
90
+ }
91
+
92
+ return { nodes, edges, hasMore: false };
93
+ },
94
+
95
+ async push(node: Node, changes: Change[]): Promise<PushResult> {
96
+ if (!config.endpoint) {
97
+ return { success: false, error: "No A2A endpoint configured" };
98
+ }
99
+
100
+ const match = node.id.match(/^a2a:(.+)$/);
101
+ if (!match) {
102
+ return { success: false, error: "Invalid A2A node ID" };
103
+ }
104
+
105
+ const taskId = match[1];
106
+ const body: Record<string, unknown> = {};
107
+
108
+ for (const change of changes) {
109
+ body[change.field] = change.newValue;
110
+ }
111
+
112
+ try {
113
+ const response = await fetch(`${config.endpoint}/tasks/${taskId}`, {
114
+ method: "PATCH",
115
+ body: JSON.stringify(body),
116
+ headers: { "Content-Type": "application/json" },
117
+ });
118
+
119
+ if (!response.ok) {
120
+ return { success: false, error: await response.text() };
121
+ }
122
+
123
+ return { success: true };
124
+ } catch (err) {
125
+ return { success: false, error: String(err) };
126
+ }
127
+ },
128
+
129
+ async fetchNode(_nodeId: string): Promise<Node | null> {
130
+ return null;
131
+ },
132
+
133
+ getTools(): Tool[] {
134
+ return [
135
+ { name: "a2a.createTask", description: "Create a new agent task" },
136
+ {
137
+ name: "a2a.updateTask",
138
+ description: "Update task status or metadata",
139
+ },
140
+ { name: "a2a.linkIssue", description: "Link task to GitHub issue" },
141
+ ];
142
+ },
143
+ };
144
+ }
145
+
146
+ interface A2ATask {
147
+ id: string;
148
+ name: string;
149
+ status: string;
150
+ description?: string;
151
+ created_at: string;
152
+ updated_at: string;
153
+ agent_id?: string;
154
+ session_id?: string;
155
+ meta?: {
156
+ github?: {
157
+ issue_number: number;
158
+ repository: string;
159
+ };
160
+ [key: string]: unknown;
161
+ };
162
+ }
163
+
164
+ registerProvider("a2a", (config) => createA2AProvider(config as A2AConfig));
165
+
166
+ export { createA2AProvider as default };
@@ -0,0 +1,212 @@
1
+ import { exec } from "child_process";
2
+ import { promisify } from "util";
3
+ import { readFile, access } from "fs/promises";
4
+ import { join } from "path";
5
+ import type { Provider, Tool } from "../../provider";
6
+ import type {
7
+ Node,
8
+ Edge,
9
+ Change,
10
+ FetchRequest,
11
+ FetchResult,
12
+ PushResult,
13
+ } from "../../types";
14
+ import { registerProvider } from "../../provider";
15
+
16
+ const execAsync = promisify(exec);
17
+
18
+ export interface GitConfig {
19
+ repositories?: { path: string }[];
20
+ links?: {
21
+ edge: string;
22
+ to: string;
23
+ match: string;
24
+ }[];
25
+ }
26
+
27
+ async function execGit(cwd: string, ...args: string[]): Promise<string> {
28
+ const { stdout } = await execAsync(`git ${args.join(" ")}`, { cwd });
29
+ return stdout.trim();
30
+ }
31
+
32
+ async function getWorktrees(repoPath: string): Promise<Worktree[]> {
33
+ const output = await execGit(repoPath, "worktree", "list", "--porcelain");
34
+ const worktrees: Worktree[] = [];
35
+ let current: Partial<Worktree> = {};
36
+
37
+ for (const line of output.split("\n")) {
38
+ if (line.startsWith("worktree ")) {
39
+ if (current.path) worktrees.push(current as Worktree);
40
+ current = { path: line.slice(9) };
41
+ } else if (line.startsWith("HEAD ")) {
42
+ current.head = line.slice(5);
43
+ } else if (line.startsWith("branch ")) {
44
+ current.branch = line.slice(7).replace("refs/heads/", "");
45
+ } else if (line === "bare") {
46
+ current.bare = true;
47
+ } else if (line === "detached") {
48
+ current.detached = true;
49
+ }
50
+ }
51
+
52
+ if (current.path) worktrees.push(current as Worktree);
53
+ return worktrees;
54
+ }
55
+
56
+ async function getBranches(repoPath: string): Promise<string[]> {
57
+ try {
58
+ const output = await execGit(
59
+ repoPath,
60
+ "branch",
61
+ "-a",
62
+ "--format=%(refname:short)",
63
+ );
64
+ return output.split("\n").filter(Boolean);
65
+ } catch {
66
+ return [];
67
+ }
68
+ }
69
+
70
+ async function readWorktreeMeta(
71
+ path: string,
72
+ ): Promise<Record<string, unknown> | null> {
73
+ const metaPath = join(path, ".a2a", "session.json");
74
+ try {
75
+ await access(metaPath);
76
+ const content = await readFile(metaPath, "utf-8");
77
+ return JSON.parse(content);
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ export function createGitProvider(config: GitConfig): Provider {
84
+ return {
85
+ name: "git",
86
+ nodeTypes: ["git.Branch", "git.Worktree", "git.Commit"],
87
+ edgeTypes: ["git.TRACKS", "git.CONTAINS", "git.WORKTREE_OF"],
88
+
89
+ async fetch(request: FetchRequest): Promise<FetchResult> {
90
+ const nodes: Node[] = [];
91
+ const edges: Edge[] = [];
92
+
93
+ for (const repoConfig of config.repositories ?? []) {
94
+ const repoPath = repoConfig.path.replace(
95
+ /^~/,
96
+ process.env["HOME"] ?? "",
97
+ );
98
+
99
+ try {
100
+ const head = await execGit(repoPath, "rev-parse", "HEAD");
101
+ if (request.versionToken === head) {
102
+ return {
103
+ nodes,
104
+ edges,
105
+ hasMore: false,
106
+ cached: true,
107
+ versionToken: head,
108
+ };
109
+ }
110
+
111
+ const worktrees = await getWorktrees(repoPath);
112
+ const branches = await getBranches(repoPath);
113
+
114
+ for (const wt of worktrees) {
115
+ const nodeId = `git:worktree:${wt.path}`;
116
+ const meta = await readWorktreeMeta(wt.path);
117
+
118
+ nodes.push({
119
+ id: nodeId,
120
+ type: "git.Worktree",
121
+ attrs: {
122
+ path: wt.path,
123
+ branch: wt.branch,
124
+ head: wt.head,
125
+ bare: wt.bare,
126
+ detached: wt.detached,
127
+ meta,
128
+ },
129
+ });
130
+
131
+ if (wt.branch) {
132
+ edges.push({
133
+ type: "git.WORKTREE_OF",
134
+ fromId: `git:branch:${repoPath}:${wt.branch}`,
135
+ toId: nodeId,
136
+ });
137
+ }
138
+ }
139
+
140
+ for (const branch of branches) {
141
+ const branchNodeId = `git:branch:${repoPath}:${branch}`;
142
+ const worktree = worktrees.find((wt) => wt.branch === branch);
143
+
144
+ nodes.push({
145
+ id: branchNodeId,
146
+ type: "git.Branch",
147
+ attrs: {
148
+ name: branch,
149
+ repository: repoPath,
150
+ worktreePath: worktree?.path,
151
+ },
152
+ });
153
+
154
+ if (config.links) {
155
+ for (const link of config.links) {
156
+ if (link.edge === "git.TRACKS" && worktree) {
157
+ const meta = await readWorktreeMeta(worktree.path);
158
+ if (meta?.a2a && typeof meta.a2a === "object") {
159
+ const a2aMeta = meta.a2a as Record<string, unknown>;
160
+ if (a2aMeta["task_id"]) {
161
+ edges.push({
162
+ type: "git.TRACKS",
163
+ fromId: branchNodeId,
164
+ toId: `a2a:${a2aMeta["task_id"]}`,
165
+ });
166
+ }
167
+ }
168
+ }
169
+ }
170
+ }
171
+ }
172
+ } catch {
173
+ // Skip invalid repositories
174
+ }
175
+ }
176
+
177
+ return { nodes, edges, hasMore: false };
178
+ },
179
+
180
+ async push(node: Node, changes: Change[]): Promise<PushResult> {
181
+ return { success: false, error: "Git push not implemented via provider" };
182
+ },
183
+
184
+ async fetchNode(_nodeId: string): Promise<Node | null> {
185
+ return null;
186
+ },
187
+
188
+ getTools(): Tool[] {
189
+ return [
190
+ { name: "git.checkout", description: "Checkout branch" },
191
+ { name: "git.push", description: "Push changes" },
192
+ { name: "git.createBranch", description: "Create new branch" },
193
+ {
194
+ name: "git.createWorktree",
195
+ description: "Create worktree for branch",
196
+ },
197
+ ];
198
+ },
199
+ };
200
+ }
201
+
202
+ interface Worktree {
203
+ path: string;
204
+ head?: string;
205
+ branch?: string;
206
+ bare?: boolean;
207
+ detached?: boolean;
208
+ }
209
+
210
+ registerProvider("git", (config) => createGitProvider(config as GitConfig));
211
+
212
+ export { createGitProvider as default };