@vibecompass/vibecompass 0.6.0 → 0.8.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/init.js CHANGED
@@ -6,6 +6,13 @@ import { scaffoldInitFiles } from './scaffold.js';
6
6
  import { applyPlacementDefaults } from './setup.js';
7
7
  import { buildWorkflowMetadata } from './workflow.js';
8
8
  import { parseSimpleYaml } from './simple-yaml.js';
9
+ import {
10
+ assertValidSyncTargetName,
11
+ buildSyncSectionWithTargets,
12
+ mirrorTargetCursorToFlat,
13
+ readSyncTargets,
14
+ reconcileCursorsAfterConnect,
15
+ } from './sync-binding.js';
9
16
  import { PACKAGE_VERSION } from './version.js';
10
17
 
11
18
  const VALID_MODES = new Set(['local-only', 'local-primary', 'hosted-only']);
@@ -68,15 +75,146 @@ export async function connectHostedProjectMemory(options) {
68
75
  throw new Error('connect-hosted requires project.yaml mode to be local-primary or hosted-only.');
69
76
  }
70
77
 
78
+ const targetName = typeof options?.targetName === 'string' && options.targetName.trim() !== ''
79
+ ? assertValidSyncTargetName(options.targetName.trim())
80
+ : null;
81
+ const existingTargets = readSyncTargets(projectConfig);
82
+
83
+ if (!targetName && existingTargets) {
84
+ throw new Error(
85
+ `This project uses named sync targets (${Object.keys(existingTargets.targets).join(', ')}). Pass --target <name> to add or update one, or use "vibecompass sync-target <name>" to switch the default.`,
86
+ );
87
+ }
88
+
89
+ const previousFlatSync = projectConfig.sync && typeof projectConfig.sync === 'object'
90
+ ? projectConfig.sync
91
+ : null;
71
92
  const sync = normalizeSyncOptions(options?.sync, projectConfig.mode);
72
- projectConfig.sync = sync;
93
+
94
+ let syncTarget = null;
95
+ if (targetName) {
96
+ const targets = { ...(existingTargets?.targets ?? {}) };
97
+ targets[targetName] = {
98
+ api_url: sync.api_url,
99
+ project_id: sync.project_id,
100
+ credential_env_var: sync.credential_env_var,
101
+ };
102
+ const defaultTarget = existingTargets?.defaultTarget ?? targetName;
103
+ projectConfig.sync = buildSyncSectionWithTargets(targets, defaultTarget);
104
+ syncTarget = { name: targetName, defaultTarget };
105
+ } else {
106
+ projectConfig.sync = sync;
107
+ }
108
+
73
109
  await writeFile(projectFilePath, serializeProjectConfig(projectConfig), 'utf8');
74
110
 
111
+ // D-237: reconcile manifest cursor state with the (re)bound target —
112
+ // first-conversion cursor migration, dropping a rebound target's stale
113
+ // cursor, and re-mirroring/clearing the flat cursor when the default
114
+ // target's binding changed (so ≤0.7.0 CLIs can't pair the new flat binding
115
+ // with a previous environment's cursor).
116
+ if (targetName) {
117
+ await updateManifestSyncState(rootDir, (manifestSync) =>
118
+ reconcileCursorsAfterConnect(
119
+ manifestSync,
120
+ targetName,
121
+ { apiUrl: sync.api_url, projectId: sync.project_id },
122
+ {
123
+ isDefault: syncTarget.defaultTarget === targetName,
124
+ isFirstConversion: !existingTargets,
125
+ flatBindingMatches: Boolean(
126
+ previousFlatSync &&
127
+ previousFlatSync.api_url === sync.api_url &&
128
+ previousFlatSync.project_id === sync.project_id,
129
+ ),
130
+ },
131
+ ),
132
+ );
133
+ }
134
+
75
135
  return {
76
136
  rootDir,
77
137
  projectFilePath,
78
138
  mode: projectConfig.mode,
79
139
  syncEnvVar: sync.credential_env_var,
140
+ syncTarget,
141
+ };
142
+ }
143
+
144
+ async function updateManifestSyncState(rootDir, updater) {
145
+ let manifest;
146
+ try {
147
+ manifest = JSON.parse(
148
+ await readFile(path.join(rootDir, 'state', 'manifest.json'), 'utf8'),
149
+ );
150
+ } catch {
151
+ return; // no manifest yet — nothing to migrate
152
+ }
153
+
154
+ const nextSync = updater(
155
+ manifest?.sync && typeof manifest.sync === 'object' ? manifest.sync : {},
156
+ );
157
+ if (!nextSync) {
158
+ return;
159
+ }
160
+
161
+ await writeStateManifest(rootDir, { sync: nextSync });
162
+ }
163
+
164
+ export async function setDefaultSyncTarget(options) {
165
+ const cwd = options?.cwd ? path.resolve(options.cwd) : process.cwd();
166
+ const rootDir = path.resolve(cwd, options?.rootDir ?? '.compass');
167
+ const projectFilePath = path.join(rootDir, 'project.yaml');
168
+ const projectConfig = parseSimpleYaml(await readFile(projectFilePath, 'utf8'), {
169
+ sourceName: projectFilePath,
170
+ });
171
+
172
+ const named = readSyncTargets(projectConfig);
173
+ if (!named) {
174
+ throw new Error(
175
+ 'No named sync targets are defined in project.yaml. Run "vibecompass connect-hosted --target <name>" to add one.',
176
+ );
177
+ }
178
+
179
+ const targetName = typeof options?.targetName === 'string' && options.targetName.trim() !== ''
180
+ ? options.targetName.trim()
181
+ : null;
182
+
183
+ if (!targetName) {
184
+ return {
185
+ rootDir,
186
+ projectFilePath,
187
+ changed: false,
188
+ defaultTarget: named.defaultTarget,
189
+ targets: named.targets,
190
+ };
191
+ }
192
+
193
+ if (!named.targets[targetName]) {
194
+ throw new Error(
195
+ `Unknown sync target "${targetName}". Available targets: ${Object.keys(named.targets).join(', ')}.`,
196
+ );
197
+ }
198
+
199
+ projectConfig.sync = buildSyncSectionWithTargets(named.targets, targetName);
200
+ await writeFile(projectFilePath, serializeProjectConfig(projectConfig), 'utf8');
201
+
202
+ // D-237: the flat manifest cursor mirrors the default target so ≤0.7.0 CLIs
203
+ // stay correct; re-mirror it from the new default (clearing it when the new
204
+ // default has no identity-matching cursor yet).
205
+ await updateManifestSyncState(rootDir, (manifestSync) =>
206
+ mirrorTargetCursorToFlat(manifestSync, targetName, {
207
+ apiUrl: named.targets[targetName].api_url,
208
+ projectId: named.targets[targetName].project_id,
209
+ }),
210
+ );
211
+
212
+ return {
213
+ rootDir,
214
+ projectFilePath,
215
+ changed: named.defaultTarget !== targetName,
216
+ defaultTarget: targetName,
217
+ targets: named.targets,
80
218
  };
81
219
  }
82
220
 
package/src/setup.js CHANGED
@@ -139,6 +139,7 @@ export async function resolveConnectHostedCliOptions(options, environment = {})
139
139
 
140
140
  return {
141
141
  rootDir: resolved.rootDir ? toRelativeIfInsideCwd(resolved.rootDir, cwd) : undefined,
142
+ ...(resolved.targetName ? { targetName: resolved.targetName } : {}),
142
143
  sync: resolved.sync,
143
144
  };
144
145
  }
package/src/status.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { inspectProjectCompatibility } from './compatibility.js';
4
+ import { sha256Text } from './hash.js';
4
5
  import { parseSimpleYaml } from './simple-yaml.js';
5
6
  import { listProjectSessions } from './session.js';
6
7
  import { syncAgentInstructionFiles } from './generators/agent-files/index.js';
@@ -94,6 +95,9 @@ export function renderStatusText(status) {
94
95
  '',
95
96
  'Docs review:',
96
97
  `- ${formatDocsReviewStatus(status.docsReview)}`,
98
+ ...(status.docsReview.outputDrift?.hasDrift
99
+ ? [`- Output drift: ${status.docsReview.outputDrift.reasons.join('; ')}`]
100
+ : []),
97
101
  '',
98
102
  'Agent files:',
99
103
  ...formatAgentFileStatus(status.agentFiles),
@@ -151,6 +155,20 @@ async function readProjectSummary(projectFilePath) {
151
155
  projectId: typeof config.sync.project_id === 'string' ? config.sync.project_id : null,
152
156
  credentialSource: typeof config.sync.credential_source === 'string' ? config.sync.credential_source : null,
153
157
  credentialEnvVar: typeof config.sync.credential_env_var === 'string' ? config.sync.credential_env_var : null,
158
+ defaultTarget:
159
+ typeof config.sync.default_target === 'string' ? config.sync.default_target : null,
160
+ targets:
161
+ config.sync.targets && typeof config.sync.targets === 'object'
162
+ ? Object.entries(config.sync.targets)
163
+ .filter(([, target]) => target && typeof target === 'object')
164
+ .map(([name, target]) => ({
165
+ name,
166
+ apiUrl: typeof target.api_url === 'string' ? target.api_url : null,
167
+ projectId: typeof target.project_id === 'string' ? target.project_id : null,
168
+ credentialEnvVar:
169
+ typeof target.credential_env_var === 'string' ? target.credential_env_var : null,
170
+ }))
171
+ : [],
154
172
  }
155
173
  : null,
156
174
  };
@@ -192,12 +210,15 @@ async function readDocsReviewStatus(rootDir) {
192
210
  const markerPath = path.join(rootDir, 'state', 'docs-review.json');
193
211
  try {
194
212
  const marker = JSON.parse(await readFile(markerPath, 'utf8'));
213
+ const outputDrift = await readDocsReviewOutputDrift(rootDir, marker);
195
214
  return {
196
215
  status: typeof marker.status === 'string' ? marker.status : 'unknown',
197
216
  markerPath,
198
217
  completedAt: typeof marker.completed_at === 'string' ? marker.completed_at : null,
199
218
  provider: typeof marker.provider === 'string' ? marker.provider : null,
200
219
  model: typeof marker.model === 'string' ? marker.model : null,
220
+ applied: marker.applied ?? null,
221
+ outputDrift,
201
222
  };
202
223
  } catch (error) {
203
224
  if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
@@ -215,6 +236,36 @@ async function readDocsReviewStatus(rootDir) {
215
236
  }
216
237
  }
217
238
 
239
+ async function readDocsReviewOutputDrift(rootDir, marker) {
240
+ const applied = marker?.applied;
241
+ if (!applied || typeof applied !== 'object') {
242
+ return { status: 'not-tracked', hasDrift: false, reasons: [] };
243
+ }
244
+
245
+ const reasons = [];
246
+ if (typeof applied.output_path === 'string' && typeof applied.output_hash === 'string') {
247
+ try {
248
+ const outputContent = await readFile(applied.output_path, 'utf8');
249
+ const currentOutputHash = sha256Text(outputContent);
250
+ if (currentOutputHash !== applied.output_hash) {
251
+ reasons.push('state/docs-review-output.md changed since apply');
252
+ }
253
+ } catch (error) {
254
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
255
+ reasons.push('state/docs-review-output.md is missing since apply');
256
+ } else {
257
+ reasons.push('state/docs-review-output.md could not be read for drift check');
258
+ }
259
+ }
260
+ }
261
+
262
+ return {
263
+ status: reasons.length > 0 ? 'drift' : 'current',
264
+ hasDrift: reasons.length > 0,
265
+ reasons,
266
+ };
267
+ }
268
+
218
269
  async function readAgentFileStatus(rootDir, toolingRootDir) {
219
270
  try {
220
271
  const result = await syncAgentInstructionFiles({
@@ -257,8 +308,10 @@ function buildRecommendations({ project, compatibility, sessions, docsReview, ag
257
308
  recommendations.push('vibecompass refresh-workflow --dry-run');
258
309
  }
259
310
 
260
- if (docsReview.status === 'missing') {
311
+ if (docsReview.status === 'missing' || docsReview.status === 'rebuild-ready') {
261
312
  recommendations.push('vibecompass docs-review --guided');
313
+ } else if (docsReview.outputDrift?.hasDrift) {
314
+ recommendations.push('vibecompass docs-review --apply-output');
262
315
  }
263
316
 
264
317
  if (agentFiles.status === 'ok' && agentFiles.results.some((item) => item.changed || item.warning)) {
@@ -278,6 +331,7 @@ function hasStatusDrift(status) {
278
331
  return (
279
332
  status.compatibility.warnings.length > 0 ||
280
333
  status.docsReview.status !== 'completed' ||
334
+ Boolean(status.docsReview.outputDrift?.hasDrift) ||
281
335
  status.agentFiles.status !== 'ok' ||
282
336
  status.agentFiles.results.some((item) => item.changed || item.warning)
283
337
  );
@@ -0,0 +1,307 @@
1
+ const TARGET_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
2
+
3
+ function normalizeBindingFields(source) {
4
+ const apiUrl = typeof source?.api_url === 'string' && source.api_url.trim() !== ''
5
+ ? source.api_url
6
+ : null;
7
+ const projectId = typeof source?.project_id === 'string' && source.project_id.trim() !== ''
8
+ ? source.project_id
9
+ : null;
10
+ const credentialEnvVar = typeof source?.credential_env_var === 'string' && source.credential_env_var.trim() !== ''
11
+ ? source.credential_env_var
12
+ : null;
13
+
14
+ if (!apiUrl || !projectId || !credentialEnvVar) {
15
+ return null;
16
+ }
17
+
18
+ return { apiUrl, projectId, credentialEnvVar };
19
+ }
20
+
21
+ export function assertValidSyncTargetName(name) {
22
+ if (typeof name !== 'string' || !TARGET_NAME_PATTERN.test(name)) {
23
+ throw new Error(
24
+ `Sync target name "${name}" is invalid. Use lowercase letters, digits, hyphens, or underscores (e.g. dev, prod).`,
25
+ );
26
+ }
27
+ return name;
28
+ }
29
+
30
+ export function readSyncTargets(projectConfig) {
31
+ const sync = projectConfig?.sync;
32
+ if (!sync || typeof sync !== 'object') {
33
+ return null;
34
+ }
35
+
36
+ const targets = sync.targets && typeof sync.targets === 'object' ? sync.targets : null;
37
+ if (!targets || Object.keys(targets).length === 0) {
38
+ return null;
39
+ }
40
+
41
+ const defaultTarget = typeof sync.default_target === 'string' && targets[sync.default_target]
42
+ ? sync.default_target
43
+ : null;
44
+
45
+ return { defaultTarget, targets };
46
+ }
47
+
48
+ /**
49
+ * Resolves the effective hosted sync binding for a command (D-236).
50
+ *
51
+ * Resolution order: explicit target name -> sync.default_target -> legacy
52
+ * flat sync fields. Returns null when no complete binding exists. Throws on
53
+ * an explicitly requested target that is unknown or incomplete, because a
54
+ * typo must never silently fall back to a different environment.
55
+ */
56
+ export function resolveSyncBinding(projectConfig, targetName) {
57
+ const sync = projectConfig?.sync;
58
+ if (!sync || typeof sync !== 'object') {
59
+ if (targetName) {
60
+ throw new Error(
61
+ `--sync-target ${targetName} requires a sync section in project.yaml. Run "vibecompass connect-hosted --target ${targetName}" first.`,
62
+ );
63
+ }
64
+ return null;
65
+ }
66
+
67
+ const named = readSyncTargets(projectConfig);
68
+
69
+ if (targetName) {
70
+ const target = named?.targets?.[targetName];
71
+ if (!target || typeof target !== 'object') {
72
+ const available = named ? Object.keys(named.targets) : [];
73
+ throw new Error(
74
+ `Unknown sync target "${targetName}". ${
75
+ available.length > 0
76
+ ? `Available targets: ${available.join(', ')}.`
77
+ : 'No named sync targets are defined in project.yaml; run "vibecompass connect-hosted --target <name>" to add one.'
78
+ }`,
79
+ );
80
+ }
81
+
82
+ const fields = normalizeBindingFields(target);
83
+ if (!fields) {
84
+ throw new Error(
85
+ `Sync target "${targetName}" is incomplete. It must define api_url, project_id, and credential_env_var.`,
86
+ );
87
+ }
88
+
89
+ return { target: targetName, isDefault: targetName === named.defaultTarget, ...fields };
90
+ }
91
+
92
+ if (named?.defaultTarget) {
93
+ const fields = normalizeBindingFields(named.targets[named.defaultTarget]);
94
+ if (fields) {
95
+ return { target: named.defaultTarget, isDefault: true, ...fields };
96
+ }
97
+ }
98
+
99
+ const flat = normalizeBindingFields(sync);
100
+ return flat ? { target: null, isDefault: true, ...flat } : null;
101
+ }
102
+
103
+ const CURSOR_FIELDS = [
104
+ 'last_successful_remote_revision',
105
+ 'last_successful_local_root_revision',
106
+ 'last_successful_manifest_hash',
107
+ 'last_sync_direction',
108
+ 'last_sync_at',
109
+ 'pending_previews',
110
+ ];
111
+
112
+ function pickCursorFields(source) {
113
+ const cursor = {};
114
+ for (const field of CURSOR_FIELDS) {
115
+ if (source?.[field] !== undefined) {
116
+ cursor[field] = source[field];
117
+ }
118
+ }
119
+ return cursor;
120
+ }
121
+
122
+ /**
123
+ * Reads the sync cursor for the resolved binding (D-237). Legacy flat
124
+ * bindings keep using the flat manifest.sync fields. Named targets read only
125
+ * their own cursor, and only when its recorded api_url/project_id identity
126
+ * matches the current binding — otherwise the cursor is treated as absent so
127
+ * a stale or cross-environment cursor can never become another environment's
128
+ * baseline.
129
+ */
130
+ export function readSyncCursor(manifestSync, binding) {
131
+ if (!manifestSync || typeof manifestSync !== 'object') {
132
+ return {};
133
+ }
134
+
135
+ if (!binding?.target) {
136
+ return pickCursorFields(manifestSync);
137
+ }
138
+
139
+ const cursor = manifestSync.targets?.[binding.target];
140
+ if (
141
+ !cursor ||
142
+ typeof cursor !== 'object' ||
143
+ cursor.api_url !== binding.apiUrl ||
144
+ cursor.project_id !== binding.projectId
145
+ ) {
146
+ return {};
147
+ }
148
+
149
+ return pickCursorFields(cursor);
150
+ }
151
+
152
+ /**
153
+ * Builds the next manifest.sync value after a sync operation for the resolved
154
+ * binding (D-237). Named targets write into manifest.sync.targets.<name>
155
+ * (stamped with their api_url/project_id identity); when the binding is the
156
+ * default target, the flat fields mirror the same cursor so ≤0.7.0 CLIs stay
157
+ * correct on the default environment. Legacy flat bindings keep writing the
158
+ * flat fields only.
159
+ */
160
+ export function buildSyncStateWithCursor(manifestSync, binding, cursor) {
161
+ const existing = manifestSync && typeof manifestSync === 'object' ? manifestSync : {};
162
+ const cursorFields = pickCursorFields(cursor);
163
+
164
+ if (!binding?.target) {
165
+ return { ...existing, ...cursorFields };
166
+ }
167
+
168
+ const targets = {
169
+ ...(existing.targets && typeof existing.targets === 'object' ? existing.targets : {}),
170
+ [binding.target]: {
171
+ api_url: binding.apiUrl,
172
+ project_id: binding.projectId,
173
+ ...cursorFields,
174
+ },
175
+ };
176
+
177
+ return {
178
+ ...existing,
179
+ ...(binding.isDefault ? cursorFields : {}),
180
+ targets,
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Migrates a legacy flat manifest cursor into a named target's cursor slot
186
+ * (D-237). Only applies when the new target's binding values match the flat
187
+ * binding the cursor was written against, the target has no cursor yet, and
188
+ * a flat cursor actually exists. Returns the next manifest.sync value, or
189
+ * null when nothing should change.
190
+ */
191
+ export function migrateFlatCursorToTarget(manifestSync, targetName, bindingFields) {
192
+ const existing = manifestSync && typeof manifestSync === 'object' ? manifestSync : {};
193
+ const targets = existing.targets && typeof existing.targets === 'object' ? existing.targets : {};
194
+ const flatCursor = pickCursorFields(existing);
195
+
196
+ if (targets[targetName] || Object.keys(flatCursor).length === 0) {
197
+ return null;
198
+ }
199
+
200
+ return {
201
+ ...existing,
202
+ targets: {
203
+ ...targets,
204
+ [targetName]: {
205
+ api_url: bindingFields.apiUrl,
206
+ project_id: bindingFields.projectId,
207
+ ...flatCursor,
208
+ },
209
+ },
210
+ };
211
+ }
212
+
213
+ /**
214
+ * Reconciles manifest cursor state after connect-hosted (re)binds a named
215
+ * target (D-237). Handles, in order:
216
+ * 1. first flat→named conversion with matching values: migrate the flat
217
+ * cursor into the target's slot;
218
+ * 2. rebinding an existing target to a different api_url/project_id: drop its
219
+ * now-wrong cursor;
220
+ * 3. when the (re)bound target is the default: re-mirror the flat cursor from
221
+ * the target's identity-matching cursor, or clear it — so an older CLI can
222
+ * never combine the new flat binding with a previous environment's cursor.
223
+ * Returns the next manifest.sync value, or null when nothing changes.
224
+ */
225
+ export function reconcileCursorsAfterConnect(manifestSync, targetName, bindingFields, options = {}) {
226
+ const existing = manifestSync && typeof manifestSync === 'object' ? manifestSync : {};
227
+ let next = existing;
228
+ let changed = false;
229
+
230
+ if (options.isFirstConversion && options.flatBindingMatches) {
231
+ const migrated = migrateFlatCursorToTarget(next, targetName, bindingFields);
232
+ if (migrated) {
233
+ next = migrated;
234
+ changed = true;
235
+ }
236
+ }
237
+
238
+ const targetCursor = next.targets?.[targetName];
239
+ if (
240
+ targetCursor &&
241
+ typeof targetCursor === 'object' &&
242
+ (targetCursor.api_url !== bindingFields.apiUrl ||
243
+ targetCursor.project_id !== bindingFields.projectId)
244
+ ) {
245
+ const targets = { ...next.targets };
246
+ delete targets[targetName];
247
+ next = { ...next, targets };
248
+ changed = true;
249
+ }
250
+
251
+ if (options.isDefault) {
252
+ const mirrored = mirrorTargetCursorToFlat(next, targetName, bindingFields);
253
+ if (JSON.stringify(mirrored) !== JSON.stringify(next)) {
254
+ next = mirrored;
255
+ changed = true;
256
+ }
257
+ }
258
+
259
+ return changed ? next : null;
260
+ }
261
+
262
+ /**
263
+ * Re-mirrors the flat manifest cursor from a (new default) target's cursor
264
+ * (D-237). When the target has no identity-matching cursor, the flat cursor
265
+ * fields are cleared so an older CLI cannot reuse the previous default's
266
+ * cursor against the new default environment.
267
+ */
268
+ export function mirrorTargetCursorToFlat(manifestSync, targetName, bindingFields) {
269
+ const existing = manifestSync && typeof manifestSync === 'object' ? manifestSync : {};
270
+ const base = {};
271
+ for (const [key, value] of Object.entries(existing)) {
272
+ if (!CURSOR_FIELDS.includes(key)) {
273
+ base[key] = value;
274
+ }
275
+ }
276
+
277
+ const target = existing.targets?.[targetName];
278
+ const identityMatches =
279
+ target &&
280
+ typeof target === 'object' &&
281
+ target.api_url === bindingFields.apiUrl &&
282
+ target.project_id === bindingFields.projectId;
283
+
284
+ return identityMatches ? { ...base, ...pickCursorFields(target) } : base;
285
+ }
286
+
287
+ /**
288
+ * Builds the project.yaml sync section for a set of named targets. The flat
289
+ * fields always mirror the default target so older CLIs that only read the
290
+ * flat binding keep working against the default environment.
291
+ */
292
+ export function buildSyncSectionWithTargets(targets, defaultTarget) {
293
+ const defaultFields = normalizeBindingFields(targets[defaultTarget]);
294
+ if (!defaultFields) {
295
+ throw new Error(`Default sync target "${defaultTarget}" is incomplete.`);
296
+ }
297
+
298
+ return {
299
+ provider: 'vibecompass',
300
+ api_url: defaultFields.apiUrl,
301
+ project_id: defaultFields.projectId,
302
+ credential_source: 'env',
303
+ credential_env_var: defaultFields.credentialEnvVar,
304
+ default_target: defaultTarget,
305
+ targets,
306
+ };
307
+ }