@vibecompass/vibecompass 0.3.0 → 0.3.3

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/src/sync.js ADDED
@@ -0,0 +1,395 @@
1
+ import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { sha256Text } from './hash.js';
4
+ import { writeStateManifest } from './manifest.js';
5
+ import { parseSimpleYaml } from './simple-yaml.js';
6
+
7
+ const DEFAULT_EXPORT_PATH = 'state/pull-export.json';
8
+
9
+ export async function pushProjectMemory(options = {}, environment = {}) {
10
+ const context = await loadSyncContext(options, environment);
11
+ const existingManifest = await readJsonIfPresent(context.manifestPath);
12
+ const manifestResult = await writeStateManifest(context.rootDir, {
13
+ sync: existingManifest?.sync,
14
+ });
15
+ const manifest = manifestResult.manifest;
16
+ const documents = await readPushDocuments(context.rootDir, manifest.documents);
17
+ const baseRemoteRevisionId = normalizeUuid(manifest.sync?.last_successful_remote_revision);
18
+ const body = {
19
+ ...(baseRemoteRevisionId ? { base_remote_revision_id: baseRemoteRevisionId } : {}),
20
+ local_root_revision: manifest.canonical.local_root_revision,
21
+ manifest_hash: manifest.canonical.manifest_hash,
22
+ format_version: manifest.canonical.format_version,
23
+ documents,
24
+ };
25
+
26
+ const response = await postJson(context, 'push', body);
27
+ const now = new Date().toISOString();
28
+ const sync = {
29
+ ...(manifest.sync ?? {}),
30
+ last_successful_remote_revision: response.remote_revision_id,
31
+ last_successful_local_root_revision: manifest.canonical.local_root_revision,
32
+ last_successful_manifest_hash: manifest.canonical.manifest_hash,
33
+ last_sync_direction: 'push',
34
+ last_sync_at: now,
35
+ pending_previews: [],
36
+ };
37
+ const updatedManifest = await writeStateManifest(context.rootDir, { sync });
38
+
39
+ return {
40
+ status: response.status ?? 'completed',
41
+ rootDir: context.rootDir,
42
+ manifestPath: updatedManifest.manifestPath,
43
+ runId: response.run_id,
44
+ remoteRevisionId: response.remote_revision_id,
45
+ appliedProposalIds: response.applied_proposal_ids ?? [],
46
+ staleProposalIds: response.stale_proposal_ids ?? [],
47
+ message: `Pushed ${documents.length} canonical document${documents.length === 1 ? '' : 's'} to hosted project memory.`,
48
+ };
49
+ }
50
+
51
+ export async function pullPreviewProjectMemory(options = {}, environment = {}) {
52
+ const context = await loadSyncContext(options, environment);
53
+ const existingManifest = await readJsonIfPresent(context.manifestPath);
54
+ const manifestResult = await writeStateManifest(context.rootDir, {
55
+ sync: existingManifest?.sync,
56
+ });
57
+ const manifest = manifestResult.manifest;
58
+
59
+ const response = await postJson(context, 'pull-preview', {
60
+ state_version: manifest.state_version,
61
+ base_remote_revision_id: normalizeUuid(manifest.sync?.last_successful_remote_revision),
62
+ local_root_revision: manifest.canonical.local_root_revision,
63
+ local_manifest_hash: manifest.canonical.manifest_hash,
64
+ local_documents: buildLocalDocumentSummaries(manifest.documents),
65
+ include_pending_proposals: options.includePendingProposals !== false,
66
+ });
67
+
68
+ const previewRecord = {
69
+ preview_token: response.preview_token,
70
+ base_remote_revision: response.base_remote_revision_id,
71
+ target_remote_revision: response.target_remote_revision_id,
72
+ local_root_revision: manifest.canonical.local_root_revision,
73
+ local_manifest_hash: manifest.canonical.manifest_hash,
74
+ state_version: manifest.state_version,
75
+ authoritative_change_set_hash: response.authoritative_change_set_hash,
76
+ include_pending_proposals: options.includePendingProposals !== false,
77
+ available_proposal_ids: Array.isArray(response.proposals)
78
+ ? response.proposals.map((proposal) => proposal.proposal_id)
79
+ : [],
80
+ conflicts: Array.isArray(response.conflicts) ? response.conflicts : [],
81
+ created_at: response.created_at ?? new Date().toISOString(),
82
+ expires_at: response.expires_at,
83
+ };
84
+ const sync = {
85
+ ...(manifest.sync ?? {}),
86
+ pending_previews: [
87
+ ...(Array.isArray(manifest.sync?.pending_previews) ? manifest.sync.pending_previews : []),
88
+ previewRecord,
89
+ ].slice(-5),
90
+ };
91
+ const updatedManifest = await writeStateManifest(context.rootDir, { sync });
92
+
93
+ return {
94
+ status: 'preview-created',
95
+ rootDir: context.rootDir,
96
+ manifestPath: updatedManifest.manifestPath,
97
+ previewToken: response.preview_token,
98
+ proposals: response.proposals ?? [],
99
+ conflicts: response.conflicts ?? [],
100
+ warnings: response.warnings ?? [],
101
+ message: `Created pull preview with ${(response.proposals ?? []).length} pending proposal${(response.proposals ?? []).length === 1 ? '' : 's'}.`,
102
+ };
103
+ }
104
+
105
+ export async function pullExportProjectMemory(options = {}, environment = {}) {
106
+ const context = await loadSyncContext(options, environment);
107
+ const manifest = await readRequiredManifest(context.manifestPath);
108
+ const preview = resolvePreview(manifest, options.previewToken);
109
+ const proposalIds = options.proposalIds?.length
110
+ ? options.proposalIds
111
+ : preview.available_proposal_ids ?? [];
112
+
113
+ if (proposalIds.length === 0) {
114
+ throw new Error('pull-export requires at least one --proposal or a preview with available proposals.');
115
+ }
116
+
117
+ const response = await postJson(context, 'pull-export', {
118
+ preview_token: preview.preview_token,
119
+ proposal_ids: proposalIds,
120
+ });
121
+ const outputPath = path.resolve(context.rootDir, options.outputPath ?? DEFAULT_EXPORT_PATH);
122
+ await mkdir(path.dirname(outputPath), { recursive: true });
123
+ await writeFile(outputPath, `${JSON.stringify(response, null, 2)}\n`, 'utf8');
124
+
125
+ return {
126
+ status: 'exported',
127
+ rootDir: context.rootDir,
128
+ outputPath,
129
+ runId: response.run_id,
130
+ operationCount: Array.isArray(response.operations) ? response.operations.length : 0,
131
+ proposalIds: response.proposal_ids ?? proposalIds,
132
+ message: `Exported ${response.operations?.length ?? 0} proposal operation${(response.operations?.length ?? 0) === 1 ? '' : 's'} to ${outputPath}.`,
133
+ };
134
+ }
135
+
136
+ export async function applyPullExport(options = {}, environment = {}) {
137
+ const cwd = environment.cwd ? path.resolve(environment.cwd) : process.cwd();
138
+ const rootDir = path.resolve(cwd, options.rootDir ?? '.compass');
139
+ const outputPath = path.resolve(rootDir, options.outputPath ?? DEFAULT_EXPORT_PATH);
140
+ const bundle = await readJsonRequired(outputPath, 'pull-export bundle');
141
+ const operations = Array.isArray(bundle.operations) ? bundle.operations : [];
142
+ if (operations.length === 0) {
143
+ throw new Error(`Pull-export bundle at ${outputPath} has no operations.`);
144
+ }
145
+
146
+ const applied = [];
147
+ for (const operation of operations) {
148
+ const relativePath = normalizeCanonicalPath(operation.path);
149
+ const targetPath = path.join(rootDir, relativePath);
150
+ const currentHash = await readCurrentHash(targetPath);
151
+ const expectedBefore = operation.before_content_hash ?? null;
152
+ if (currentHash !== expectedBefore) {
153
+ throw new Error(
154
+ `Cannot apply ${relativePath}: local content hash ${currentHash ?? 'missing'} does not match expected ${expectedBefore ?? 'missing'}. Run pull-preview again or resolve locally.`,
155
+ );
156
+ }
157
+
158
+ if (operation.op === 'delete') {
159
+ await unlink(targetPath);
160
+ applied.push({ path: relativePath, status: 'deleted' });
161
+ continue;
162
+ }
163
+
164
+ if (operation.op !== 'write' || typeof operation.raw_text !== 'string') {
165
+ throw new Error(`Unsupported operation for ${relativePath}.`);
166
+ }
167
+ const afterHash = sha256Text(operation.raw_text);
168
+ if (afterHash !== operation.after_content_hash) {
169
+ throw new Error(`Exported content hash does not match raw_text for ${relativePath}.`);
170
+ }
171
+
172
+ await mkdir(path.dirname(targetPath), { recursive: true });
173
+ await writeFile(targetPath, ensureTrailingNewline(operation.raw_text), 'utf8');
174
+ applied.push({ path: relativePath, status: 'written' });
175
+ }
176
+
177
+ bundle.applied_at = new Date().toISOString();
178
+ bundle.applied = applied;
179
+ await writeFile(outputPath, `${JSON.stringify(bundle, null, 2)}\n`, 'utf8');
180
+
181
+ return {
182
+ status: 'applied',
183
+ rootDir,
184
+ outputPath,
185
+ applied,
186
+ proposalIds: bundle.proposal_ids ?? [],
187
+ message: `Applied ${applied.length} exported proposal operation${applied.length === 1 ? '' : 's'}. Run vibecompass push to confirm applied proposals.`,
188
+ };
189
+ }
190
+
191
+ async function loadSyncContext(options, environment) {
192
+ const cwd = environment.cwd ? path.resolve(environment.cwd) : process.cwd();
193
+ const rootDir = path.resolve(cwd, options.rootDir ?? '.compass');
194
+ const projectFilePath = path.join(rootDir, 'project.yaml');
195
+ const manifestPath = path.join(rootDir, 'state', 'manifest.json');
196
+ const project = parseSimpleYaml(await readFile(projectFilePath, 'utf8'), {
197
+ sourceName: projectFilePath,
198
+ });
199
+
200
+ if (project.mode !== 'local-primary') {
201
+ throw new Error('Hosted sync commands require project.yaml mode: local-primary.');
202
+ }
203
+ if (
204
+ !project.sync ||
205
+ typeof project.sync.api_url !== 'string' ||
206
+ typeof project.sync.project_id !== 'string' ||
207
+ typeof project.sync.credential_env_var !== 'string'
208
+ ) {
209
+ throw new Error('Hosted sync commands require sync.api_url, sync.project_id, and sync.credential_env_var in project.yaml.');
210
+ }
211
+
212
+ const credential = normalizeOptionalString((environment.env ?? process.env)[project.sync.credential_env_var]);
213
+ if (!credential) {
214
+ throw new Error(`Hosted sync command requires ${project.sync.credential_env_var}.`);
215
+ }
216
+
217
+ const fetchImpl = environment.runtime?.fetch ?? globalThis.fetch;
218
+ if (typeof fetchImpl !== 'function') {
219
+ throw new Error('Hosted sync command requires a fetch implementation.');
220
+ }
221
+
222
+ return {
223
+ rootDir,
224
+ project,
225
+ manifestPath,
226
+ fetch: fetchImpl,
227
+ credential,
228
+ apiUrl: project.sync.api_url,
229
+ projectId: project.sync.project_id,
230
+ };
231
+ }
232
+
233
+ async function postJson(context, routeName, body) {
234
+ const endpoint = new URL(
235
+ `api/sync/projects/${encodeURIComponent(context.projectId)}/${routeName}`,
236
+ ensureTrailingSlash(context.apiUrl),
237
+ );
238
+ const response = await context.fetch(endpoint.href, {
239
+ method: 'POST',
240
+ headers: {
241
+ accept: 'application/json',
242
+ authorization: `Bearer ${context.credential}`,
243
+ 'content-type': 'application/json',
244
+ },
245
+ body: JSON.stringify(body),
246
+ });
247
+
248
+ if (!response.ok) {
249
+ const responseText = typeof response.text === 'function' ? await response.text() : '';
250
+ throw new Error(`Hosted ${routeName} failed with ${response.status}${responseText ? `: ${responseText}` : ''}`);
251
+ }
252
+
253
+ return typeof response.json === 'function' ? response.json() : {};
254
+ }
255
+
256
+ async function readPushDocuments(rootDir, documents) {
257
+ const result = [];
258
+ for (const [relativePath, metadata] of Object.entries(documents)) {
259
+ const normalizedPath = normalizeCanonicalPath(relativePath);
260
+ const rawText = await readFile(path.join(rootDir, normalizedPath), 'utf8');
261
+ result.push({
262
+ path: normalizedPath,
263
+ kind: metadata.kind,
264
+ content_hash: metadata.content_hash,
265
+ byte_length: metadata.byte_length,
266
+ raw_text: rawText,
267
+ });
268
+ }
269
+
270
+ result.sort((left, right) => left.path.localeCompare(right.path));
271
+ return result;
272
+ }
273
+
274
+ function buildLocalDocumentSummaries(documents) {
275
+ return Object.entries(documents ?? {}).map(([relativePath, metadata]) => ({
276
+ path: normalizeCanonicalPath(relativePath),
277
+ kind: metadata.kind,
278
+ content_hash: metadata.content_hash,
279
+ extracted: buildLocalDocumentExtractedSummary(metadata.kind, metadata.extracted),
280
+ }));
281
+ }
282
+
283
+ function buildLocalDocumentExtractedSummary(kind, extracted = {}) {
284
+ if (!extracted || typeof extracted !== 'object') return {};
285
+
286
+ if (kind === 'session') {
287
+ return {
288
+ ...(typeof extracted.title === 'string' ? { title: extracted.title } : {}),
289
+ ...(typeof extracted.session_date === 'string' ? { session_date: extracted.session_date } : {}),
290
+ ...(Number.isInteger(extracted.session_number)
291
+ ? { session_number: extracted.session_number }
292
+ : {}),
293
+ };
294
+ }
295
+
296
+ if (kind === 'decision') {
297
+ return {
298
+ ...(Array.isArray(extracted.decision_ids)
299
+ ? {
300
+ decision_ids: extracted.decision_ids.filter(
301
+ (value) => Number.isInteger(value) && value > 0,
302
+ ),
303
+ }
304
+ : {}),
305
+ };
306
+ }
307
+
308
+ return {};
309
+ }
310
+
311
+ function resolvePreview(manifest, previewToken) {
312
+ const previews = Array.isArray(manifest.sync?.pending_previews)
313
+ ? manifest.sync.pending_previews
314
+ : [];
315
+ if (previewToken) {
316
+ const match = previews.find((preview) => preview.preview_token === previewToken);
317
+ if (!match) {
318
+ throw new Error(`Preview token ${previewToken} was not found in state/manifest.json.`);
319
+ }
320
+ return match;
321
+ }
322
+
323
+ const latest = previews[previews.length - 1];
324
+ if (!latest) {
325
+ throw new Error('pull-export requires a pending preview. Run vibecompass pull-preview first.');
326
+ }
327
+ return latest;
328
+ }
329
+
330
+ async function readRequiredManifest(manifestPath) {
331
+ return readJsonRequired(manifestPath, 'state manifest');
332
+ }
333
+
334
+ async function readJsonRequired(filePath, label) {
335
+ try {
336
+ return JSON.parse(await readFile(filePath, 'utf8'));
337
+ } catch (error) {
338
+ throw new Error(`Could not read ${label} at ${filePath}: ${error.message}`);
339
+ }
340
+ }
341
+
342
+ async function readJsonIfPresent(filePath) {
343
+ try {
344
+ return JSON.parse(await readFile(filePath, 'utf8'));
345
+ } catch (error) {
346
+ if (error?.code === 'ENOENT') return null;
347
+ throw error;
348
+ }
349
+ }
350
+
351
+ async function readCurrentHash(filePath) {
352
+ try {
353
+ return sha256Text(await readFile(filePath, 'utf8'));
354
+ } catch (error) {
355
+ if (error?.code === 'ENOENT') return null;
356
+ throw error;
357
+ }
358
+ }
359
+
360
+ function normalizeCanonicalPath(value) {
361
+ if (
362
+ typeof value !== 'string' ||
363
+ value.startsWith('/') ||
364
+ value.includes('\\') ||
365
+ value.split('/').some((part) => part === '..' || part === '') ||
366
+ value.startsWith('state/') ||
367
+ value.startsWith('sessions/active/')
368
+ ) {
369
+ throw new Error(`Invalid canonical path: ${value}`);
370
+ }
371
+
372
+ return value;
373
+ }
374
+
375
+ function ensureTrailingNewline(value) {
376
+ return value.endsWith('\n') ? value : `${value}\n`;
377
+ }
378
+
379
+ function normalizeOptionalString(value) {
380
+ if (typeof value !== 'string') return null;
381
+ const trimmed = value.trim();
382
+ return trimmed ? trimmed : null;
383
+ }
384
+
385
+ function normalizeUuid(value) {
386
+ const normalized = normalizeOptionalString(value);
387
+ if (!normalized) return null;
388
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(normalized)
389
+ ? normalized
390
+ : null;
391
+ }
392
+
393
+ function ensureTrailingSlash(value) {
394
+ return value.endsWith('/') ? value : `${value}/`;
395
+ }