@vibecompass/vibecompass 0.11.1 → 0.12.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/src/status.js CHANGED
@@ -4,6 +4,7 @@ import { inspectProjectCompatibility } from './compatibility.js';
4
4
  import { sha256Text } from './hash.js';
5
5
  import { resolveLaneMarkerContext } from './lane-marker.js';
6
6
  import { parseSimpleYaml } from './simple-yaml.js';
7
+ import { resolveSyncBinding } from './sync-binding.js';
7
8
  import { inferToolingRootForMemoryRoot, listProjectSessions } from './session.js';
8
9
  import { syncAgentInstructionFiles } from './generators/agent-files/index.js';
9
10
  import { scanProjectMemory } from './project-memory.js';
@@ -30,12 +31,18 @@ export async function getProjectStatus(options = {}) {
30
31
  readAgentFileStatus(rootDir, toolingRootDir),
31
32
  readProjectMemoryStatus(rootDir),
32
33
  ]);
34
+ const hostedSync = await readHostedModeCheck(
35
+ compatibility.projectFilePath,
36
+ project.mode ?? null,
37
+ options,
38
+ );
33
39
 
34
40
  return {
35
41
  generatedAt: new Date().toISOString(),
36
42
  rootDir,
37
43
  toolingRootDir,
38
44
  project,
45
+ hostedSync,
39
46
  compatibility: {
40
47
  cliPackageVersion: compatibility.cliPackageVersion,
41
48
  package: compatibility.package,
@@ -111,6 +118,13 @@ export function renderStatusText(status) {
111
118
  `Generated: ${status.generatedAt}`,
112
119
  `Project: ${status.project.name ?? 'Unknown'}`,
113
120
  `Mode: ${status.project.mode ?? 'Unknown'}`,
121
+ ...(status.hostedSync?.status === 'ok' && status.hostedSync.mismatch
122
+ ? [
123
+ `WARNING: mode mismatch — local project.yaml says "${status.hostedSync.localMode}" but the hosted project is "${status.hostedSync.hostedMode}". The two mode records are unreconciled: sync and dashboard actions will refuse until they agree. Do not hand-edit mode; a supported promote/demote flow flips both records together.`,
124
+ ]
125
+ : status.hostedSync?.status === 'unreachable'
126
+ ? [`Hosted mode check: unreachable (${status.hostedSync.detail}).`]
127
+ : []),
114
128
  `Root: ${status.rootDir}`,
115
129
  `Repos: ${status.project.repos.length > 0 ? status.project.repos.map((repo) => repo.id).join(', ') : 'None recorded'}`,
116
130
  `Active lanes: ${formatActiveLanes(status.sessions)}`,
@@ -162,6 +176,7 @@ export function toStatusJson(status) {
162
176
  return {
163
177
  generatedAt: status.generatedAt,
164
178
  rootDir: status.rootDir,
179
+ hostedSync: status.hostedSync ?? null,
165
180
  toolingRootDir: status.toolingRootDir,
166
181
  project: status.project,
167
182
  compatibility: status.compatibility,
@@ -173,6 +188,69 @@ export function toStatusJson(status) {
173
188
  };
174
189
  }
175
190
 
191
+ /**
192
+ * Mode-mismatch diagnostic (plan 3.8): compares the local project.yaml mode
193
+ * against the hosted project's mode via the side-effect-free sync status
194
+ * endpoint. A hand-edited mode previously wedged a project with opaque 409s
195
+ * on both sides and no diagnostic anywhere. Network problems degrade to an
196
+ * "unreachable" note — status stays offline-safe.
197
+ */
198
+ async function readHostedModeCheck(projectFilePath, localMode, options = {}) {
199
+ let binding;
200
+ try {
201
+ const config = parseSimpleYaml(await readFile(projectFilePath, 'utf8'), {
202
+ sourceName: projectFilePath,
203
+ });
204
+ binding = resolveSyncBinding(config, null);
205
+ } catch {
206
+ return { status: 'not-configured' };
207
+ }
208
+ if (!binding) return { status: 'not-configured' };
209
+
210
+ const env = options.env ?? process.env;
211
+ const credential = typeof env[binding.credentialEnvVar] === 'string'
212
+ ? env[binding.credentialEnvVar].trim()
213
+ : '';
214
+ if (!credential) {
215
+ return { status: 'no-credential', credentialEnvVar: binding.credentialEnvVar };
216
+ }
217
+
218
+ const fetchImpl = options.fetch ?? globalThis.fetch;
219
+ if (typeof fetchImpl !== 'function') return { status: 'not-configured' };
220
+
221
+ const baseUrl = binding.apiUrl.endsWith('/') ? binding.apiUrl : `${binding.apiUrl}/`;
222
+ const endpoint = new URL(
223
+ `api/sync/projects/${encodeURIComponent(binding.projectId)}/status`,
224
+ baseUrl,
225
+ );
226
+ const controller = new AbortController();
227
+ const timer = setTimeout(() => controller.abort(), 5000);
228
+ try {
229
+ const response = await fetchImpl(endpoint.href, {
230
+ headers: {
231
+ accept: 'application/json',
232
+ authorization: `Bearer ${credential}`,
233
+ },
234
+ signal: controller.signal,
235
+ });
236
+ if (!response.ok) {
237
+ return { status: 'unreachable', detail: `HTTP ${response.status}` };
238
+ }
239
+ const data = typeof response.json === 'function' ? await response.json() : {};
240
+ const hostedMode = typeof data.mode === 'string' ? data.mode : null;
241
+ return {
242
+ status: 'ok',
243
+ hostedMode,
244
+ localMode,
245
+ mismatch: Boolean(hostedMode && localMode && hostedMode !== localMode),
246
+ };
247
+ } catch (error) {
248
+ return { status: 'unreachable', detail: error?.message ?? 'network error' };
249
+ } finally {
250
+ clearTimeout(timer);
251
+ }
252
+ }
253
+
176
254
  async function readProjectSummary(projectFilePath) {
177
255
  try {
178
256
  const config = parseSimpleYaml(await readFile(projectFilePath, 'utf8'), {
package/src/sync.js CHANGED
@@ -155,21 +155,28 @@ async function applyPullExportLocked(options, rootDir) {
155
155
  throw new Error(`Pull-export bundle at ${outputPath} has no operations.`);
156
156
  }
157
157
 
158
- const applied = [];
158
+ // Pre-validate EVERY operation before writing anything, and classify
159
+ // operations whose target already matches the after-state as
160
+ // already-applied. This makes a retry after a partial failure converge
161
+ // instead of failing forever on the ops that succeeded last time.
162
+ const plan = [];
159
163
  for (const operation of operations) {
160
164
  const relativePath = normalizeCanonicalPath(operation.path);
161
165
  const targetPath = path.join(rootDir, relativePath);
162
166
  const currentHash = await readCurrentHash(targetPath);
163
167
  const expectedBefore = operation.before_content_hash ?? null;
164
- if (currentHash !== expectedBefore) {
165
- throw new Error(
166
- `Cannot apply ${relativePath}: local content hash ${currentHash ?? 'missing'} does not match expected ${expectedBefore ?? 'missing'}. Run pull-preview again or resolve locally.`,
167
- );
168
- }
169
168
 
170
169
  if (operation.op === 'delete') {
171
- await unlink(targetPath);
172
- applied.push({ path: relativePath, status: 'deleted' });
170
+ if (currentHash === null) {
171
+ plan.push({ relativePath, targetPath, action: 'skip', status: 'already-deleted' });
172
+ continue;
173
+ }
174
+ if (currentHash !== expectedBefore) {
175
+ throw new Error(
176
+ `Cannot apply ${relativePath}: local content hash ${currentHash} does not match expected ${expectedBefore ?? 'missing'}. Run pull-preview again or resolve locally.`,
177
+ );
178
+ }
179
+ plan.push({ relativePath, targetPath, action: 'delete' });
173
180
  continue;
174
181
  }
175
182
 
@@ -180,10 +187,32 @@ async function applyPullExportLocked(options, rootDir) {
180
187
  if (afterHash !== operation.after_content_hash) {
181
188
  throw new Error(`Exported content hash does not match raw_text for ${relativePath}.`);
182
189
  }
190
+ if (currentHash === afterHash) {
191
+ plan.push({ relativePath, targetPath, action: 'skip', status: 'already-applied' });
192
+ continue;
193
+ }
194
+ if (currentHash !== expectedBefore) {
195
+ throw new Error(
196
+ `Cannot apply ${relativePath}: local content hash ${currentHash ?? 'missing'} does not match expected ${expectedBefore ?? 'missing'}. Run pull-preview again or resolve locally.`,
197
+ );
198
+ }
199
+ plan.push({ relativePath, targetPath, action: 'write', rawText: operation.raw_text });
200
+ }
183
201
 
184
- await mkdir(path.dirname(targetPath), { recursive: true });
185
- await writeFile(targetPath, ensureTrailingNewline(operation.raw_text), 'utf8');
186
- applied.push({ path: relativePath, status: 'written' });
202
+ const applied = [];
203
+ for (const step of plan) {
204
+ if (step.action === 'skip') {
205
+ applied.push({ path: step.relativePath, status: step.status });
206
+ continue;
207
+ }
208
+ if (step.action === 'delete') {
209
+ await unlink(step.targetPath);
210
+ applied.push({ path: step.relativePath, status: 'deleted' });
211
+ continue;
212
+ }
213
+ await mkdir(path.dirname(step.targetPath), { recursive: true });
214
+ await writeFile(step.targetPath, ensureTrailingNewline(step.rawText), 'utf8');
215
+ applied.push({ path: step.relativePath, status: 'written' });
187
216
  }
188
217
 
189
218
  bundle.applied_at = new Date().toISOString();
@@ -200,6 +229,220 @@ async function applyPullExportLocked(options, rootDir) {
200
229
  };
201
230
  }
202
231
 
232
+ /**
233
+ * Re-baselines this root's sync cursor onto the hosted head (D-287 sync
234
+ * trust): the recovery path for a fresh clone or second device whose
235
+ * gitignored cursor is missing, which previously hit a permanent 409
236
+ * needs_rebase. Preserves D-215's inspect-then-choose workflow: a pull
237
+ * preview always runs first, and pending hosted proposals or conflicts
238
+ * refuse the adopt unless --accept-divergence is passed.
239
+ */
240
+ export async function adoptRemoteHead(options = {}, environment = {}) {
241
+ const context = await loadSyncContext(options, environment);
242
+ const existingManifest = await readJsonIfPresent(context.manifestPath);
243
+ const manifestResult = await writeStateManifest(context.rootDir, {
244
+ sync: existingManifest?.sync,
245
+ });
246
+ const manifest = manifestResult.manifest;
247
+ const cursor = readSyncCursor(manifest.sync, context.binding);
248
+
249
+ const response = await postJson(context, 'pull-preview', {
250
+ state_version: manifest.state_version,
251
+ base_remote_revision_id: normalizeUuid(cursor.last_successful_remote_revision),
252
+ local_root_revision: manifest.canonical.local_root_revision,
253
+ local_manifest_hash: manifest.canonical.manifest_hash,
254
+ local_documents: buildLocalDocumentSummaries(manifest.documents),
255
+ include_pending_proposals: true,
256
+ });
257
+
258
+ const targetRemoteRevisionId = normalizeUuid(response.target_remote_revision_id);
259
+ if (!targetRemoteRevisionId) {
260
+ throw new Error('The hosted project has no baseline revision to adopt yet. Run vibecompass push first.');
261
+ }
262
+
263
+ const proposals = Array.isArray(response.proposals) ? response.proposals : [];
264
+ const conflicts = Array.isArray(response.conflicts) ? response.conflicts : [];
265
+ if ((proposals.length > 0 || conflicts.length > 0) && options.acceptDivergence !== true) {
266
+ throw new Error(
267
+ `Refusing to adopt the hosted head: ${proposals.length} pending proposal${proposals.length === 1 ? '' : 's'} and ${conflicts.length} conflict${conflicts.length === 1 ? '' : 's'} exist for this project (D-215: inspect divergence before choosing a baseline). Review with vibecompass pull-preview, then re-run sync-adopt --accept-divergence to adopt anyway.`,
268
+ );
269
+ }
270
+
271
+ const sync = buildSyncStateWithCursor(manifest.sync, context.binding, {
272
+ ...cursor,
273
+ last_successful_remote_revision: targetRemoteRevisionId,
274
+ last_successful_local_root_revision: manifest.canonical.local_root_revision,
275
+ last_successful_manifest_hash: manifest.canonical.manifest_hash,
276
+ last_sync_direction: 'adopt',
277
+ last_sync_at: new Date().toISOString(),
278
+ pending_previews: [],
279
+ });
280
+ const updatedManifest = await writeStateManifest(context.rootDir, { sync });
281
+
282
+ return {
283
+ status: 'adopted',
284
+ rootDir: context.rootDir,
285
+ manifestPath: updatedManifest.manifestPath,
286
+ remoteRevisionId: targetRemoteRevisionId,
287
+ proposalCount: proposals.length,
288
+ conflictCount: conflicts.length,
289
+ warnings: [
290
+ 'The next push will record this root\'s content as a new revision on top of the adopted head. If local files differ from the hosted head, the hosted content is replaced (visibly, as a new revision).',
291
+ ],
292
+ message: `Adopted hosted head ${targetRemoteRevisionId} as this root's sync baseline.`,
293
+ };
294
+ }
295
+
296
+ /**
297
+ * Materializes a complete local memory root from a hosted bootstrap bundle
298
+ * (D-289): the escape hatch, disaster-recovery, and second-device path. All
299
+ * document hashes are verified before anything is written (fail closed). For
300
+ * local-primary bundles with a resolvable sync binding, the bundle's server
301
+ * head is seeded as the sync cursor so the first push has the correct parent;
302
+ * hosted-only bundles materialize a fully usable offline root (the server
303
+ * rejects hosted-only push until the project is demoted).
304
+ */
305
+ export async function bootstrapFromBundle(options = {}, environment = {}) {
306
+ const cwd = environment.cwd ? path.resolve(environment.cwd) : process.cwd();
307
+ const rootDir = path.resolve(cwd, options.rootDir ?? '.compass');
308
+ if (!options.bundlePath) {
309
+ throw new Error('bootstrap requires --bundle <file> (download it from the hosted Setup page).');
310
+ }
311
+ const bundlePath = path.resolve(cwd, options.bundlePath);
312
+ return withMemoryRootLock(rootDir, 'bootstrap', () =>
313
+ bootstrapFromBundleLocked(options, rootDir, bundlePath),
314
+ );
315
+ }
316
+
317
+ async function bootstrapFromBundleLocked(options, rootDir, bundlePath) {
318
+ const bundle = await readJsonRequired(bundlePath, 'bootstrap bundle');
319
+ if (bundle.bundle_kind !== 'bootstrap_export') {
320
+ throw new Error(
321
+ `File at ${bundlePath} is not a bootstrap bundle (bundle_kind: ${bundle.bundle_kind ?? 'missing'}).`,
322
+ );
323
+ }
324
+ const documents = Array.isArray(bundle.documents) ? bundle.documents : [];
325
+ if (documents.length === 0) {
326
+ throw new Error(`Bootstrap bundle at ${bundlePath} has no documents.`);
327
+ }
328
+
329
+ const projectFilePath = path.join(rootDir, 'project.yaml');
330
+ let existingProjectFile = false;
331
+ try {
332
+ await readFile(projectFilePath, 'utf8');
333
+ existingProjectFile = true;
334
+ } catch {
335
+ existingProjectFile = false;
336
+ }
337
+ if (existingProjectFile) {
338
+ throw new Error(
339
+ `${rootDir} already contains project.yaml. Bootstrap only materializes fresh roots — pick an empty --root or move the existing one aside.`,
340
+ );
341
+ }
342
+
343
+ // Fail closed BEFORE writing anything: verify every document's hash and
344
+ // path, and require the bundle to carry project.yaml.
345
+ let hasProjectFile = false;
346
+ const validated = documents.map((document) => {
347
+ const relativePath = normalizeCanonicalPath(document.path);
348
+ if (document.op !== 'write' || typeof document.raw_text !== 'string') {
349
+ throw new Error(`Unsupported bootstrap operation for ${relativePath}.`);
350
+ }
351
+ const hash = sha256Text(document.raw_text);
352
+ if (hash !== document.after_content_hash) {
353
+ throw new Error(
354
+ `Bootstrap bundle content hash does not match raw_text for ${relativePath}. The bundle is corrupt — download it again.`,
355
+ );
356
+ }
357
+ if (relativePath === 'project.yaml') hasProjectFile = true;
358
+ return { relativePath, rawText: document.raw_text };
359
+ });
360
+ if (!hasProjectFile) {
361
+ throw new Error('Bootstrap bundle does not include project.yaml.');
362
+ }
363
+
364
+ // Content is written verbatim (no newline normalization) so the local root
365
+ // byte-matches the hosted head the bundle's cursor points at.
366
+ const written = [];
367
+ for (const document of validated) {
368
+ const targetPath = path.join(rootDir, document.relativePath);
369
+ await mkdir(path.dirname(targetPath), { recursive: true });
370
+ await writeFile(targetPath, document.rawText, 'utf8');
371
+ written.push(document.relativePath);
372
+ }
373
+
374
+ const warnings = [];
375
+ let manifestPath = null;
376
+ let cursorSeeded = false;
377
+ let projectMode = null;
378
+ try {
379
+ const project = parseSimpleYaml(await readFile(projectFilePath, 'utf8'), {
380
+ sourceName: projectFilePath,
381
+ });
382
+ projectMode = typeof project.mode === 'string' ? project.mode : null;
383
+
384
+ let sync;
385
+ const serverHead = bundle.server_head ?? null;
386
+ if (projectMode === 'local-primary' && serverHead?.remote_revision_id) {
387
+ const binding = resolveSyncBinding(project, options.syncTarget ?? null);
388
+ if (binding) {
389
+ const manifestResult = await writeStateManifest(rootDir);
390
+ // Fail closed on cursor seeding: the cursor claims "this root's
391
+ // content IS the server head" — only true when the materialized
392
+ // manifest hash matches the head hash captured in the bundle. A
393
+ // mismatched bundle still materializes (files are hash-verified
394
+ // individually) but must not claim the head as its baseline.
395
+ const localHash = manifestResult.manifest.canonical.manifest_hash;
396
+ if (serverHead.manifest_hash && localHash !== serverHead.manifest_hash) {
397
+ warnings.push(
398
+ `Sync cursor NOT seeded: the materialized root's manifest hash (${localHash}) does not match the bundle's server head (${serverHead.manifest_hash}). `
399
+ + 'The files are intact, but run vibecompass sync-adopt (after reviewing divergence) before pushing.',
400
+ );
401
+ } else {
402
+ sync = buildSyncStateWithCursor(manifestResult.manifest.sync, binding, {
403
+ last_successful_remote_revision: serverHead.remote_revision_id,
404
+ last_successful_local_root_revision:
405
+ manifestResult.manifest.canonical.local_root_revision,
406
+ last_successful_manifest_hash: localHash,
407
+ last_sync_direction: 'bootstrap',
408
+ last_sync_at: new Date().toISOString(),
409
+ pending_previews: [],
410
+ });
411
+ cursorSeeded = true;
412
+ }
413
+ } else {
414
+ warnings.push(
415
+ 'No sync binding found in the bundled project.yaml; run connect-hosted before pushing.',
416
+ );
417
+ }
418
+ }
419
+ const manifestResult = await writeStateManifest(rootDir, sync ? { sync } : undefined);
420
+ manifestPath = manifestResult.manifestPath;
421
+ } catch (error) {
422
+ warnings.push(
423
+ `Documents were written, but the state manifest could not be generated: ${error.message}`,
424
+ );
425
+ }
426
+
427
+ if (projectMode === 'hosted-only') {
428
+ warnings.push(
429
+ 'This is a hosted-only project: the root is fully usable offline, but hosted sync rejects local push until the project is demoted. '
430
+ + 'To make this root canonical: run vibecompass connect-hosted (with the API URL, project id, and token env var from the hosted Setup page) to add the sync binding, then vibecompass demote-hosted.',
431
+ );
432
+ }
433
+
434
+ return {
435
+ status: 'bootstrapped',
436
+ rootDir,
437
+ manifestPath,
438
+ documentCount: written.length,
439
+ mode: projectMode,
440
+ cursorSeeded,
441
+ warnings,
442
+ message: `Materialized ${written.length} document${written.length === 1 ? '' : 's'} into ${rootDir}${cursorSeeded ? ' and seeded the sync cursor from the bundle head' : ''}.`,
443
+ };
444
+ }
445
+
203
446
  async function loadSyncContext(options, environment) {
204
447
  const cwd = environment.cwd ? path.resolve(environment.cwd) : process.cwd();
205
448
  const rootDir = path.resolve(cwd, options.rootDir ?? '.compass');
@@ -210,7 +453,11 @@ async function loadSyncContext(options, environment) {
210
453
  });
211
454
 
212
455
  if (project.mode !== 'local-primary') {
213
- throw new Error('Hosted sync commands require project.yaml mode: local-primary.');
456
+ throw new Error(
457
+ `Hosted sync commands require project.yaml mode: local-primary (this root says "${project.mode}"). `
458
+ + 'Mode is recorded twice — locally in project.yaml and on the hosted project — and they must agree. '
459
+ + 'Run vibecompass status to compare both records; use promote-hosted/demote-hosted to change modes on both sides together instead of hand-editing.',
460
+ );
214
461
  }
215
462
 
216
463
  const binding = resolveSyncBinding(project, options.syncTarget ?? null);
@@ -220,7 +467,11 @@ async function loadSyncContext(options, environment) {
220
467
 
221
468
  const credential = normalizeOptionalString((environment.env ?? process.env)[binding.credentialEnvVar]);
222
469
  if (!credential) {
223
- throw new Error(`Hosted sync command requires ${binding.credentialEnvVar}.`);
470
+ throw new Error(
471
+ `Hosted sync command requires ${binding.credentialEnvVar}. New terminals do not inherit one-off exports: `
472
+ + `re-export it (export ${binding.credentialEnvVar}="<sync token>") or persist it in your shell profile (~/.zshenv or ~/.bashrc). `
473
+ + 'Lost the token? Rotate it on the hosted dashboard under Setup -> Hosted sync, then export the new value.',
474
+ );
224
475
  }
225
476
 
226
477
  const fetchImpl = environment.runtime?.fetch ?? globalThis.fetch;