@vibecompass/vibecompass 0.7.0 → 0.9.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
 
@@ -119,13 +257,42 @@ function normalizeInitOptions(options) {
119
257
  }
120
258
  seenRepoIds.add(repo.id);
121
259
 
122
- if (typeof repo.remote !== 'string' || repo.remote.trim() === '') {
123
- throw new Error(`repo "${repo.id}" requires a non-empty remote.`);
260
+ const source = typeof repo.source === 'string' && repo.source.trim() !== ''
261
+ ? repo.source.trim()
262
+ : null;
263
+ const pathValue = typeof repo.path === 'string' && repo.path.trim() !== ''
264
+ ? repo.path.trim()
265
+ : null;
266
+ const remote = typeof repo.remote === 'string' ? repo.remote.trim() : '';
267
+
268
+ if (source && !['git', 'local'].includes(source)) {
269
+ throw new Error(`repo "${repo.id}" source must be git or local.`);
270
+ }
271
+
272
+ if (source === 'local' || (!source && !remote && pathValue)) {
273
+ if (!['local-only', 'local-primary'].includes(preparedOptions.mode)) {
274
+ throw new Error(`repo "${repo.id}" local sources are supported only in local-only or local-primary mode.`);
275
+ }
276
+
277
+ if (!pathValue) {
278
+ throw new Error(`repo "${repo.id}" source local requires a non-empty path.`);
279
+ }
280
+
281
+ return {
282
+ id: repo.id,
283
+ source: 'local',
284
+ path: pathValue,
285
+ };
286
+ }
287
+
288
+ if (!remote) {
289
+ throw new Error(`repo "${repo.id}" requires a non-empty remote, or use a local repo source with source: local and path.`);
124
290
  }
125
291
 
126
292
  return {
127
293
  id: repo.id,
128
- remote: repo.remote,
294
+ ...(source === 'git' ? { source: 'git' } : {}),
295
+ remote,
129
296
  ...(repo.defaultBranch ? { default_branch: repo.defaultBranch } : {}),
130
297
  };
131
298
  });
@@ -34,7 +34,7 @@ const KNOWN_PROJECT_FIELDS = new Set([
34
34
  'sync',
35
35
  'metadata',
36
36
  ]);
37
- const KNOWN_REPO_FIELDS = new Set(['id', 'remote', 'default_branch']);
37
+ const KNOWN_REPO_FIELDS = new Set(['id', 'source', 'remote', 'path', 'default_branch']);
38
38
  const KNOWN_SYNC_FIELDS = new Set([
39
39
  'provider',
40
40
  'api_url',
@@ -278,8 +278,25 @@ function validateProjectData(data, relativePath) {
278
278
  repoIds.push(repo.id);
279
279
  }
280
280
 
281
- if (typeof repo.remote !== 'string' || repo.remote.trim() === '') {
282
- errors.push(createError(relativePath, 'project-invalid-repo-remote', `${prefix}.remote must be a non-empty string.`));
281
+ const source = typeof repo.source === 'string' && repo.source.trim() !== ''
282
+ ? repo.source.trim()
283
+ : null;
284
+ const remote = typeof repo.remote === 'string' ? repo.remote.trim() : '';
285
+ const sourcePath = typeof repo.path === 'string' ? repo.path.trim() : '';
286
+
287
+ if (source && !['git', 'local'].includes(source)) {
288
+ errors.push(createError(relativePath, 'project-invalid-repo-source', `${prefix}.source must be git or local.`));
289
+ }
290
+
291
+ if (source === 'local' || (!source && !remote && sourcePath)) {
292
+ if (!['local-only', 'local-primary'].includes(data.mode)) {
293
+ errors.push(createError(relativePath, 'project-invalid-repo-source', `${prefix}.source local is supported only when mode is local-only or local-primary.`));
294
+ }
295
+ if (!sourcePath) {
296
+ errors.push(createError(relativePath, 'project-invalid-repo-path', `${prefix}.path must be a non-empty string for local sources.`));
297
+ }
298
+ } else if (!remote) {
299
+ errors.push(createError(relativePath, 'project-invalid-repo-remote', `${prefix}.remote must be a non-empty string for Git-backed sources.`));
283
300
  }
284
301
  }
285
302
  }
@@ -336,10 +353,12 @@ function validateProjectData(data, relativePath) {
336
353
  repo_ids: repoIds,
337
354
  repos: Array.isArray(data.repos)
338
355
  ? data.repos
339
- .filter((repo) => isPlainObject(repo) && typeof repo.id === 'string' && typeof repo.remote === 'string')
356
+ .filter((repo) => isPlainObject(repo) && typeof repo.id === 'string')
340
357
  .map((repo) => ({
341
358
  id: repo.id,
342
- remote: repo.remote,
359
+ source: typeof repo.source === 'string' ? repo.source : (typeof repo.remote === 'string' ? 'git' : 'local'),
360
+ remote: typeof repo.remote === 'string' ? repo.remote : null,
361
+ path: typeof repo.path === 'string' ? repo.path : null,
343
362
  default_branch: typeof repo.default_branch === 'string' ? repo.default_branch : null,
344
363
  }))
345
364
  : [],
package/src/read-model.js CHANGED
@@ -105,7 +105,9 @@ function buildProjectSummary(scanResult, currentManifest, freshness, existingMan
105
105
  const extracted = scanResult.project.extracted ?? {};
106
106
  const repos = (extracted.repos ?? []).map((repo) => ({
107
107
  id: repo.id,
108
+ source: repo.source,
108
109
  remote: repo.remote,
110
+ path: repo.path,
109
111
  default_branch: repo.default_branch,
110
112
  }));
111
113
 
@@ -451,10 +453,12 @@ function buildRepoAliasMap(repos) {
451
453
  aliases.set(repo.id, repo.id);
452
454
  aliases.set(repo.id.toLowerCase(), repo.id);
453
455
 
454
- const remoteName = repo.remote
455
- .replace(/\.git$/, '')
456
- .split('/')
457
- .pop();
456
+ const remoteName = typeof repo.remote === 'string'
457
+ ? repo.remote
458
+ .replace(/\.git$/, '')
459
+ .split('/')
460
+ .pop()
461
+ : null;
458
462
 
459
463
  if (remoteName) {
460
464
  aliases.set(remoteName, repo.id);
package/src/scaffold.js CHANGED
@@ -206,7 +206,7 @@ function generateContextMarkdown(options) {
206
206
  const workflow = resolveWorkflowSettings(options.projectConfig);
207
207
  const promptCommands = renderPromptCommandLines({ rootRelativePath }).join('\n');
208
208
  const repos = options.projectConfig.repos
209
- .map((repo) => `- \`${repo.id}\` → ${repo.remote}`)
209
+ .map((repo) => `- \`${repo.id}\` → ${formatRepoDescriptor(repo)}`)
210
210
  .join('\n');
211
211
 
212
212
  return `# Project Context — ${projectName}
@@ -348,7 +348,7 @@ automatically by package commands.
348
348
  function generateStarterArchitectureDoc(projectConfig) {
349
349
  const repos = projectConfig.repos.map((repo) => repo.id);
350
350
  const repoLines = projectConfig.repos
351
- .map((repo) => `- \`${repo.id}\` — ${repo.remote}${repo.default_branch ? ` (${repo.default_branch})` : ''}`)
351
+ .map((repo) => `- \`${repo.id}\` — ${formatRepoDescriptor(repo)}${repo.default_branch ? ` (${repo.default_branch})` : ''}`)
352
352
  .join('\n');
353
353
  const repoEvidence = projectConfig.repos
354
354
  .map((repo) => `- \`${repo.id}:/\` — declared repository root`)
@@ -379,7 +379,7 @@ ${repoLines}
379
379
  ## Coverage
380
380
  Confirmed:
381
381
  - Project identity and mode from \`project.yaml\`
382
- - Declared repository IDs and remotes from \`project.yaml\`
382
+ - Declared repository IDs and source descriptors from \`project.yaml\`
383
383
  - Project-memory root layout created by \`vibecompass init\`
384
384
 
385
385
  Not yet documented:
@@ -425,7 +425,7 @@ Do not copy this example as-is. Allocate real decision IDs from the current cano
425
425
  function generateStarterSessionNote(options) {
426
426
  const sessionDate = sessionDateFromGeneratedAt(options.generatedAt);
427
427
  const projectConfig = options.projectConfig;
428
- const repoLines = projectConfig.repos.map((repo) => `- \`${repo.id}\` — ${repo.remote}`).join('\n');
428
+ const repoLines = projectConfig.repos.map((repo) => `- \`${repo.id}\` — ${formatRepoDescriptor(repo)}`).join('\n');
429
429
 
430
430
  return `# Session — ${sessionDate}-1 — Project Memory Initialized
431
431
 
@@ -458,6 +458,14 @@ ${repoLines}
458
458
  `;
459
459
  }
460
460
 
461
+ function formatRepoDescriptor(repo) {
462
+ if (repo.source === 'local') {
463
+ return `local folder ${repo.path}`;
464
+ }
465
+
466
+ return repo.remote;
467
+ }
468
+
461
469
  function generateArchitectureGuide(projectConfig) {
462
470
  return `# Architecture
463
471
 
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
  }
@@ -881,12 +882,13 @@ async function promptForRepos(prompter, cwd) {
881
882
 
882
883
  for (let index = 0; index < repoCount; index += 1) {
883
884
  const ordinal = index + 1;
884
- const repo = {
885
- id: await askInput(prompter, `Repo ${ordinal} id`, {
886
- defaultValue: repoCount === 1 ? repoIdDefault : `repo-${ordinal}`,
887
- }),
888
- remote: await askInput(prompter, `Repo ${ordinal} remote`),
889
- };
885
+ const repoId = await askInput(prompter, `Repo ${ordinal} id`, {
886
+ defaultValue: repoCount === 1 ? repoIdDefault : `repo-${ordinal}`,
887
+ });
888
+ const remote = await askOptionalInput(prompter, `Repo ${ordinal} remote (blank for local folder)`);
889
+ const repo = remote
890
+ ? { id: repoId, remote }
891
+ : { id: repoId, source: 'local', path: '.' };
890
892
 
891
893
  const defaultBranch = await askOptionalInput(prompter, `Repo ${ordinal} default branch`);
892
894
  if (defaultBranch) {
package/src/status.js CHANGED
@@ -143,7 +143,9 @@ async function readProjectSummary(projectFilePath) {
143
143
  .filter((repo) => repo && typeof repo === 'object')
144
144
  .map((repo) => ({
145
145
  id: typeof repo.id === 'string' ? repo.id : null,
146
+ source: typeof repo.source === 'string' ? repo.source : (typeof repo.remote === 'string' ? 'git' : 'local'),
146
147
  remote: typeof repo.remote === 'string' ? repo.remote : null,
148
+ path: typeof repo.path === 'string' ? repo.path : null,
147
149
  defaultBranch: typeof repo.default_branch === 'string' ? repo.default_branch : null,
148
150
  }))
149
151
  .filter((repo) => repo.id)
@@ -155,6 +157,20 @@ async function readProjectSummary(projectFilePath) {
155
157
  projectId: typeof config.sync.project_id === 'string' ? config.sync.project_id : null,
156
158
  credentialSource: typeof config.sync.credential_source === 'string' ? config.sync.credential_source : null,
157
159
  credentialEnvVar: typeof config.sync.credential_env_var === 'string' ? config.sync.credential_env_var : null,
160
+ defaultTarget:
161
+ typeof config.sync.default_target === 'string' ? config.sync.default_target : null,
162
+ targets:
163
+ config.sync.targets && typeof config.sync.targets === 'object'
164
+ ? Object.entries(config.sync.targets)
165
+ .filter(([, target]) => target && typeof target === 'object')
166
+ .map(([name, target]) => ({
167
+ name,
168
+ apiUrl: typeof target.api_url === 'string' ? target.api_url : null,
169
+ projectId: typeof target.project_id === 'string' ? target.project_id : null,
170
+ credentialEnvVar:
171
+ typeof target.credential_env_var === 'string' ? target.credential_env_var : null,
172
+ }))
173
+ : [],
158
174
  }
159
175
  : null,
160
176
  };