@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.
- package/.eslintrc.json +22 -0
- package/.github/workflows/publish.yml +41 -0
- package/.prettierignore +17 -0
- package/LICENSE +21 -0
- package/README.md +183 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +2950 -0
- package/dist/index.d.ts +406 -0
- package/dist/index.js +2737 -0
- package/dist/mcp-server.d.ts +7 -0
- package/dist/mcp-server.js +2665 -0
- package/docs/research/crdt.md +777 -0
- package/docs/research/github-issues.md +684 -0
- package/docs/research/gql.md +876 -0
- package/docs/research/index.md +19 -0
- package/docs/specs/conflict-resolution.md +1254 -0
- package/docs/specs/hardcopy.md +742 -0
- package/docs/specs/patchwork-integration.md +227 -0
- package/docs/specs/plugin-architecture.md +747 -0
- package/mcp.json +8 -0
- package/package.json +64 -0
- package/scripts/install-graphqlite.ts +156 -0
- package/src/cli.ts +356 -0
- package/src/config.ts +104 -0
- package/src/conflict-store.ts +136 -0
- package/src/conflict.ts +147 -0
- package/src/crdt.ts +100 -0
- package/src/db.ts +600 -0
- package/src/env.ts +34 -0
- package/src/format.ts +72 -0
- package/src/formats/github-issue.ts +55 -0
- package/src/hardcopy/core.ts +78 -0
- package/src/hardcopy/diff.ts +188 -0
- package/src/hardcopy/index.ts +67 -0
- package/src/hardcopy/init.ts +24 -0
- package/src/hardcopy/push.ts +444 -0
- package/src/hardcopy/sync.ts +37 -0
- package/src/hardcopy/types.ts +49 -0
- package/src/hardcopy/views.ts +199 -0
- package/src/hardcopy.ts +1 -0
- package/src/index.ts +13 -0
- package/src/llm-merge.ts +109 -0
- package/src/mcp-server.ts +388 -0
- package/src/merge.ts +75 -0
- package/src/provider.ts +40 -0
- package/src/providers/a2a/index.ts +166 -0
- package/src/providers/git/index.ts +212 -0
- package/src/providers/github/index.ts +236 -0
- package/src/providers/github/issues.ts +66 -0
- package/src/providers.ts +7 -0
- package/src/types.ts +101 -0
- package/tsconfig.json +21 -0
- 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.
|