@vibecompass/vibecompass 0.7.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/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0 - 2026-06-12
4
+
5
+ - Add named sync targets (D-236): `connect-hosted --target <name>` binds one project root to multiple hosted environments (for example `dev` on localhost and `prod` on vibecompass.dev).
6
+ - Add a `sync-target` command to list named targets and switch the default; flat `sync` fields in project.yaml always mirror the default target so older CLIs keep working.
7
+ - Add `--sync-target <name>` to `push`, `pull-preview`, `pull-export`, and `docs-review` for per-command environment selection; unknown target names error instead of falling back.
8
+ - Partition local sync cursor state per target (D-237): each target keeps its own last-pushed revision and pull previews, identity-checked against its URL and project id, so one environment's history can never become another environment's baseline.
9
+ - Carry the existing sync history over when a flat binding is converted into its first named target with the same URL and project id.
10
+ - Record the submitted sync target in the docs-review marker so `--poll-hosted` stays pinned to the environment the run was submitted to.
11
+
3
12
  ## 0.7.0 - 2026-05-30
4
13
 
5
14
  - Add `docs-review --rebuild` with dry-run preview, scoped architecture paths, and archive-based stale-doc preparation.
package/README.md CHANGED
@@ -125,7 +125,7 @@ After setup, use these phrases inside your AI coding tool:
125
125
  - `planning mode`
126
126
  - `review handoff`
127
127
  - `address review`
128
- - `close session`
128
+ - `close session` / `end session`
129
129
  - `docs review`
130
130
 
131
131
  The npm package owns the file mechanics. Your AI session owns reading the
@@ -146,6 +146,28 @@ vibecompass connect-hosted \
146
146
  vibecompass push --root .compass
147
147
  ```
148
148
 
149
+ ### Multiple environments
150
+
151
+ One project can have named sync targets (for example a local dev server and
152
+ production) so you never have to rebind:
153
+
154
+ ```bash
155
+ # add targets once — the first one becomes the default
156
+ vibecompass connect-hosted --root .compass --target dev \
157
+ --sync-api-url http://localhost:3000 --sync-project-id vc_proj_dev \
158
+ --sync-credential-env-var VIBECOMPASS_SYNC_TOKEN
159
+ vibecompass connect-hosted --root .compass --target prod \
160
+ --sync-api-url https://vibecompass.dev --sync-project-id vc_proj_prod \
161
+ --sync-credential-env-var VIBECOMPASS_SYNC_TOKEN_PROD
162
+
163
+ # then pick per command, or switch the default
164
+ vibecompass push --root .compass --sync-target prod
165
+ vibecompass sync-target prod --root .compass
166
+ ```
167
+
168
+ Tip: if you already have a single (flat) binding, add it as your first named
169
+ target using the same URL and project id — its sync history carries over.
170
+
149
171
  See https://vibecompass.dev/developers for the full local-primary workflow.
150
172
 
151
173
  ## JavaScript API
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibecompass/vibecompass",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Local-first project-memory core for VibeCompass",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { realpathSync } from 'node:fs';
4
4
  import { pathToFileURL } from 'node:url';
5
- import { connectHostedProjectMemory, initializeProjectMemory } from './init.js';
5
+ import { connectHostedProjectMemory, initializeProjectMemory, setDefaultSyncTarget } from './init.js';
6
6
  import { preflightDocsReview } from './docs-review.js';
7
7
  import { resolveConnectHostedCliOptions, resolveInitCliOptions } from './setup.js';
8
8
  import { closeProjectSession, listProjectSessions, startProjectSession, switchProjectSession } from './session.js';
@@ -140,6 +140,10 @@ export async function runCli(argv, io = createDefaultIo(), runtime = {}) {
140
140
  });
141
141
  io.stdout.write(`Connected hosted VibeCompass for ${result.mode}\n`);
142
142
  io.stdout.write(`Updated ${result.projectFilePath}\n`);
143
+ if (result.syncTarget) {
144
+ io.stdout.write(`Sync target: ${result.syncTarget.name} (default: ${result.syncTarget.defaultTarget})\n`);
145
+ io.stdout.write(`Select per command with --sync-target ${result.syncTarget.name}, or switch the default with: vibecompass sync-target ${result.syncTarget.name}\n`);
146
+ }
143
147
  io.stdout.write(`Next step: set ${result.syncEnvVar} locally before your first hosted command.\n`);
144
148
  if (result.mode === 'local-primary') {
145
149
  io.stdout.write('Then run: vibecompass push\n');
@@ -150,6 +154,28 @@ export async function runCli(argv, io = createDefaultIo(), runtime = {}) {
150
154
  return 0;
151
155
  }
152
156
 
157
+ if (parsed.command === 'sync-target') {
158
+ const result = await setDefaultSyncTarget({
159
+ ...parsed.options,
160
+ ...(runtime.cwd ? { cwd: runtime.cwd } : {}),
161
+ });
162
+ if (parsed.options.targetName) {
163
+ io.stdout.write(
164
+ result.changed
165
+ ? `Default sync target set to ${result.defaultTarget}\n`
166
+ : `Default sync target already ${result.defaultTarget}\n`,
167
+ );
168
+ io.stdout.write(`Updated ${result.projectFilePath}\n`);
169
+ } else {
170
+ io.stdout.write(`Default sync target: ${result.defaultTarget ?? '(none)'}\n`);
171
+ }
172
+ for (const [name, target] of Object.entries(result.targets)) {
173
+ const marker = name === result.defaultTarget ? '*' : ' ';
174
+ io.stdout.write(`${marker} ${name}: ${target.api_url} (project ${target.project_id}, env ${target.credential_env_var})\n`);
175
+ }
176
+ return 0;
177
+ }
178
+
153
179
  if (parsed.command === 'status') {
154
180
  const result = await getProjectStatus({
155
181
  ...parsed.options,
@@ -401,6 +427,10 @@ export function parseCliArgs(argv) {
401
427
  return parseConnectHostedArgs(rest);
402
428
  }
403
429
 
430
+ if (command === 'sync-target') {
431
+ return parseSyncTargetArgs(rest);
432
+ }
433
+
404
434
  if (command === 'status') {
405
435
  return parseStatusArgs(rest);
406
436
  }
@@ -702,6 +732,9 @@ function parseConnectHostedArgs(argv) {
702
732
  case '--root':
703
733
  parsed.rootDir = value;
704
734
  break;
735
+ case '--target':
736
+ parsed.targetName = value;
737
+ break;
705
738
  case '--sync-api-url':
706
739
  parsed.syncApiUrl = value;
707
740
  break;
@@ -726,6 +759,7 @@ function parseConnectHostedArgs(argv) {
726
759
  command: 'connect-hosted',
727
760
  options: {
728
761
  rootDir: parsed.rootDir,
762
+ ...(parsed.targetName ? { targetName: parsed.targetName } : {}),
729
763
  ...(syncValues.length > 0
730
764
  ? {
731
765
  sync: {
@@ -1128,6 +1162,9 @@ function parseDocsReviewArgs(argv) {
1128
1162
  case '--artifact':
1129
1163
  parsed.artifactId = value;
1130
1164
  break;
1165
+ case '--sync-target':
1166
+ parsed.syncTarget = value;
1167
+ break;
1131
1168
  case '--max-tokens':
1132
1169
  parsed.maxTokens = value;
1133
1170
  break;
@@ -1145,7 +1182,72 @@ function parseDocsReviewArgs(argv) {
1145
1182
  function parsePushArgs(argv) {
1146
1183
  return {
1147
1184
  command: 'push',
1148
- options: parseRootOnlyArgs(argv, 'push'),
1185
+ options: parseRootAndSyncTargetArgs(argv, 'push'),
1186
+ };
1187
+ }
1188
+
1189
+ function parseRootAndSyncTargetArgs(argv, commandName) {
1190
+ const parsed = {};
1191
+
1192
+ for (let index = 0; index < argv.length; index += 1) {
1193
+ const token = argv[index];
1194
+ if (!token.startsWith('--')) {
1195
+ throw new Error(`Unexpected argument "${token}".`);
1196
+ }
1197
+
1198
+ const value = argv[index + 1];
1199
+ if (value === undefined) {
1200
+ throw new Error(`Flag "${token}" requires a value.`);
1201
+ }
1202
+ index += 1;
1203
+
1204
+ switch (token) {
1205
+ case '--root':
1206
+ parsed.rootDir = value;
1207
+ break;
1208
+ case '--sync-target':
1209
+ parsed.syncTarget = value;
1210
+ break;
1211
+ default:
1212
+ throw new Error(`Unknown flag "${token}" for ${commandName}.`);
1213
+ }
1214
+ }
1215
+
1216
+ return parsed;
1217
+ }
1218
+
1219
+ function parseSyncTargetArgs(argv) {
1220
+ const parsed = {};
1221
+
1222
+ for (let index = 0; index < argv.length; index += 1) {
1223
+ const token = argv[index];
1224
+
1225
+ if (!token.startsWith('--')) {
1226
+ if (parsed.targetName !== undefined) {
1227
+ throw new Error(`Unexpected argument "${token}".`);
1228
+ }
1229
+ parsed.targetName = token;
1230
+ continue;
1231
+ }
1232
+
1233
+ const value = argv[index + 1];
1234
+ if (value === undefined) {
1235
+ throw new Error(`Flag "${token}" requires a value.`);
1236
+ }
1237
+ index += 1;
1238
+
1239
+ switch (token) {
1240
+ case '--root':
1241
+ parsed.rootDir = value;
1242
+ break;
1243
+ default:
1244
+ throw new Error(`Unknown flag "${token}" for sync-target.`);
1245
+ }
1246
+ }
1247
+
1248
+ return {
1249
+ command: 'sync-target',
1250
+ options: parsed,
1149
1251
  };
1150
1252
  }
1151
1253
 
@@ -1173,6 +1275,9 @@ function parsePullPreviewArgs(argv) {
1173
1275
  case '--root':
1174
1276
  parsed.rootDir = value;
1175
1277
  break;
1278
+ case '--sync-target':
1279
+ parsed.syncTarget = value;
1280
+ break;
1176
1281
  default:
1177
1282
  throw new Error(`Unknown flag "${token}".`);
1178
1283
  }
@@ -1206,6 +1311,9 @@ function parsePullExportArgs(argv) {
1206
1311
  case '--root':
1207
1312
  parsed.rootDir = value;
1208
1313
  break;
1314
+ case '--sync-target':
1315
+ parsed.syncTarget = value;
1316
+ break;
1209
1317
  case '--preview-token':
1210
1318
  parsed.previewToken = value;
1211
1319
  break;
@@ -1364,6 +1472,7 @@ function usageText() {
1364
1472
  'Usage:',
1365
1473
  ' vibecompass init --name <project-name> --mode <local-only|local-primary|hosted-only> --repo <id=remote> [options]',
1366
1474
  ' vibecompass connect-hosted [options]',
1475
+ ' vibecompass sync-target [<name>] [options]',
1367
1476
  ' vibecompass status [options]',
1368
1477
  ' vibecompass refresh-workflow [--dry-run|--apply] [options]',
1369
1478
  ' vibecompass start-session --id <lane-id> --working-on <text> [options]',
@@ -1405,10 +1514,17 @@ function usageText() {
1405
1514
  '',
1406
1515
  'Connect-hosted options:',
1407
1516
  ' --root <path> Project-memory root. Defaults to .compass',
1517
+ ' --target <name> Add or update a named sync target (e.g. dev, prod)',
1518
+ ' First named target becomes the default; flat sync fields mirror the default target',
1408
1519
  ' --sync-api-url <url> Hosted sync api_url',
1409
1520
  ' --sync-project-id <id> Hosted sync project_id',
1410
1521
  ' --sync-credential-env-var <name> Hosted sync env var reference',
1411
- ' Replaces any existing project.yaml sync binding',
1522
+ ' Without --target, replaces any existing flat project.yaml sync binding',
1523
+ '',
1524
+ 'Sync-target options:',
1525
+ ' vibecompass sync-target List named sync targets and the current default',
1526
+ ' vibecompass sync-target <name> Switch the default sync target (re-mirrors flat sync fields)',
1527
+ ' --root <path> Project-memory root. Defaults to .compass',
1412
1528
  '',
1413
1529
  'Status options:',
1414
1530
  ' --root <path> Project-memory root. Defaults to .compass',
@@ -1463,6 +1579,7 @@ function usageText() {
1463
1579
  '',
1464
1580
  'Sync transport options:',
1465
1581
  ' push --root <path> Push canonical local project memory to hosted',
1582
+ ' --sync-target <name> Use a named sync target for this command (push, pull-preview, pull-export, docs-review)',
1466
1583
  ' pull-preview --root <path> Preview hosted proposals and remote state',
1467
1584
  ' pull-preview --no-pending-proposals Exclude pending proposals from preview',
1468
1585
  ' pull-export --proposal <id> Export a selected proposal; repeatable',
@@ -5,6 +5,7 @@ import { parseFrontmatter } from './frontmatter.js';
5
5
  import { sha256Text } from './hash.js';
6
6
  import { writeStateManifest } from './manifest.js';
7
7
  import { parseSimpleYaml } from './simple-yaml.js';
8
+ import { readSyncCursor, resolveSyncBinding } from './sync-binding.js';
8
9
 
9
10
  const DOCS_REVIEW_PROMPT_VERSION = 'VibeCompass Docs Review Prompt v2';
10
11
  const ARCHITECTURE_DOC_SOFT_SIZE_LIMIT_BYTES = 12000;
@@ -83,6 +84,7 @@ export async function preflightDocsReview(options = {}, environment = {}) {
83
84
  localProvider,
84
85
  runLocal: shouldRunLocal(options),
85
86
  anthropicEnvVar: options.anthropicEnvVar ?? 'ANTHROPIC_API_KEY',
87
+ syncTarget: options.syncTarget ?? null,
86
88
  });
87
89
  const reviewPrompt = renderReviewPrompt({
88
90
  project,
@@ -617,11 +619,10 @@ function resolveDocsReviewRuntime(project, options) {
617
619
  localProvider &&
618
620
  typeof options.env?.[localProvider.credentialEnvVar] === 'string' &&
619
621
  options.env[localProvider.credentialEnvVar].trim() !== '';
620
- const hasHostedBinding =
621
- project?.sync &&
622
- typeof project.sync === 'object' &&
623
- typeof project.sync.api_url === 'string' &&
624
- typeof project.sync.project_id === 'string';
622
+ // Throws on an explicitly requested unknown/incomplete --sync-target so a
623
+ // typo can never fall back to a different environment (D-236).
624
+ const hostedBinding = resolveSyncBinding(project, options.syncTarget ?? null);
625
+ const hasHostedBinding = Boolean(hostedBinding);
625
626
 
626
627
  if (mode === 'local-only') {
627
628
  if (options.runLocal && !localProvider) {
@@ -663,11 +664,10 @@ function resolveDocsReviewRuntime(project, options) {
663
664
  if (hasHostedBinding) {
664
665
  return {
665
666
  provider: 'hosted',
666
- api_url: project.sync.api_url,
667
- project_id: project.sync.project_id,
668
- ...(typeof project.sync.credential_env_var === 'string'
669
- ? { credential_env_var: project.sync.credential_env_var }
670
- : {}),
667
+ api_url: hostedBinding.apiUrl,
668
+ project_id: hostedBinding.projectId,
669
+ credential_env_var: hostedBinding.credentialEnvVar,
670
+ ...(hostedBinding.target ? { sync_target: hostedBinding.target } : {}),
671
671
  };
672
672
  }
673
673
 
@@ -699,11 +699,10 @@ function resolveDocsReviewRuntime(project, options) {
699
699
 
700
700
  return {
701
701
  provider: 'hosted',
702
- api_url: project.sync.api_url,
703
- project_id: project.sync.project_id,
704
- ...(typeof project.sync.credential_env_var === 'string'
705
- ? { credential_env_var: project.sync.credential_env_var }
706
- : {}),
702
+ api_url: hostedBinding.apiUrl,
703
+ project_id: hostedBinding.projectId,
704
+ credential_env_var: hostedBinding.credentialEnvVar,
705
+ ...(hostedBinding.target ? { sync_target: hostedBinding.target } : {}),
707
706
  };
708
707
  }
709
708
 
@@ -1107,8 +1106,15 @@ async function submitHostedDocsReview(options) {
1107
1106
  `api/sync/projects/${encodeURIComponent(options.runtime.project_id)}/docs-review`,
1108
1107
  ensureTrailingSlash(options.runtime.api_url),
1109
1108
  );
1109
+ // D-237: the baseline must come from the submitted target's own cursor so a
1110
+ // dev revision can never be sent as another environment's baseline.
1111
+ const submitCursor = readSyncCursor(options.manifest?.manifest?.sync, {
1112
+ target: options.runtime.sync_target ?? null,
1113
+ apiUrl: options.runtime.api_url,
1114
+ projectId: options.runtime.project_id,
1115
+ });
1110
1116
  const baselineRemoteRevisionId = normalizeUuid(
1111
- options.manifest?.manifest?.sync?.last_successful_remote_revision,
1117
+ submitCursor.last_successful_remote_revision,
1112
1118
  );
1113
1119
  const response = await options.fetch(endpoint.href, {
1114
1120
  method: 'POST',
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
@@ -155,6 +155,20 @@ async function readProjectSummary(projectFilePath) {
155
155
  projectId: typeof config.sync.project_id === 'string' ? config.sync.project_id : null,
156
156
  credentialSource: typeof config.sync.credential_source === 'string' ? config.sync.credential_source : null,
157
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
+ : [],
158
172
  }
159
173
  : null,
160
174
  };
@@ -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
+ }
package/src/sync.js CHANGED
@@ -3,6 +3,11 @@ import path from 'node:path';
3
3
  import { sha256Text } from './hash.js';
4
4
  import { writeStateManifest } from './manifest.js';
5
5
  import { parseSimpleYaml } from './simple-yaml.js';
6
+ import {
7
+ buildSyncStateWithCursor,
8
+ readSyncCursor,
9
+ resolveSyncBinding,
10
+ } from './sync-binding.js';
6
11
 
7
12
  const DEFAULT_EXPORT_PATH = 'state/pull-export.json';
8
13
 
@@ -14,7 +19,8 @@ export async function pushProjectMemory(options = {}, environment = {}) {
14
19
  });
15
20
  const manifest = manifestResult.manifest;
16
21
  const documents = await readPushDocuments(context.rootDir, manifest.documents);
17
- const baseRemoteRevisionId = normalizeUuid(manifest.sync?.last_successful_remote_revision);
22
+ const cursor = readSyncCursor(manifest.sync, context.binding);
23
+ const baseRemoteRevisionId = normalizeUuid(cursor.last_successful_remote_revision);
18
24
  const body = {
19
25
  ...(baseRemoteRevisionId ? { base_remote_revision_id: baseRemoteRevisionId } : {}),
20
26
  local_root_revision: manifest.canonical.local_root_revision,
@@ -25,15 +31,15 @@ export async function pushProjectMemory(options = {}, environment = {}) {
25
31
 
26
32
  const response = await postJson(context, 'push', body);
27
33
  const now = new Date().toISOString();
28
- const sync = {
29
- ...(manifest.sync ?? {}),
34
+ const sync = buildSyncStateWithCursor(manifest.sync, context.binding, {
35
+ ...cursor,
30
36
  last_successful_remote_revision: response.remote_revision_id,
31
37
  last_successful_local_root_revision: manifest.canonical.local_root_revision,
32
38
  last_successful_manifest_hash: manifest.canonical.manifest_hash,
33
39
  last_sync_direction: 'push',
34
40
  last_sync_at: now,
35
41
  pending_previews: [],
36
- };
42
+ });
37
43
  const updatedManifest = await writeStateManifest(context.rootDir, { sync });
38
44
 
39
45
  return {
@@ -55,10 +61,11 @@ export async function pullPreviewProjectMemory(options = {}, environment = {}) {
55
61
  sync: existingManifest?.sync,
56
62
  });
57
63
  const manifest = manifestResult.manifest;
64
+ const cursor = readSyncCursor(manifest.sync, context.binding);
58
65
 
59
66
  const response = await postJson(context, 'pull-preview', {
60
67
  state_version: manifest.state_version,
61
- base_remote_revision_id: normalizeUuid(manifest.sync?.last_successful_remote_revision),
68
+ base_remote_revision_id: normalizeUuid(cursor.last_successful_remote_revision),
62
69
  local_root_revision: manifest.canonical.local_root_revision,
63
70
  local_manifest_hash: manifest.canonical.manifest_hash,
64
71
  local_documents: buildLocalDocumentSummaries(manifest.documents),
@@ -81,13 +88,13 @@ export async function pullPreviewProjectMemory(options = {}, environment = {}) {
81
88
  created_at: response.created_at ?? new Date().toISOString(),
82
89
  expires_at: response.expires_at,
83
90
  };
84
- const sync = {
85
- ...(manifest.sync ?? {}),
91
+ const sync = buildSyncStateWithCursor(manifest.sync, context.binding, {
92
+ ...cursor,
86
93
  pending_previews: [
87
- ...(Array.isArray(manifest.sync?.pending_previews) ? manifest.sync.pending_previews : []),
94
+ ...(Array.isArray(cursor.pending_previews) ? cursor.pending_previews : []),
88
95
  previewRecord,
89
96
  ].slice(-5),
90
- };
97
+ });
91
98
  const updatedManifest = await writeStateManifest(context.rootDir, { sync });
92
99
 
93
100
  return {
@@ -105,7 +112,7 @@ export async function pullPreviewProjectMemory(options = {}, environment = {}) {
105
112
  export async function pullExportProjectMemory(options = {}, environment = {}) {
106
113
  const context = await loadSyncContext(options, environment);
107
114
  const manifest = await readRequiredManifest(context.manifestPath);
108
- const preview = resolvePreview(manifest, options.previewToken);
115
+ const preview = resolvePreview(manifest, options.previewToken, context.binding);
109
116
  const proposalIds = options.proposalIds?.length
110
117
  ? options.proposalIds
111
118
  : preview.available_proposal_ids ?? [];
@@ -200,18 +207,15 @@ async function loadSyncContext(options, environment) {
200
207
  if (project.mode !== 'local-primary') {
201
208
  throw new Error('Hosted sync commands require project.yaml mode: local-primary.');
202
209
  }
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
+ const binding = resolveSyncBinding(project, options.syncTarget ?? null);
212
+ if (!binding) {
213
+ throw new Error('Hosted sync commands require sync.api_url, sync.project_id, and sync.credential_env_var in project.yaml (or a named target under sync.targets).');
210
214
  }
211
215
 
212
- const credential = normalizeOptionalString((environment.env ?? process.env)[project.sync.credential_env_var]);
216
+ const credential = normalizeOptionalString((environment.env ?? process.env)[binding.credentialEnvVar]);
213
217
  if (!credential) {
214
- throw new Error(`Hosted sync command requires ${project.sync.credential_env_var}.`);
218
+ throw new Error(`Hosted sync command requires ${binding.credentialEnvVar}.`);
215
219
  }
216
220
 
217
221
  const fetchImpl = environment.runtime?.fetch ?? globalThis.fetch;
@@ -225,8 +229,10 @@ async function loadSyncContext(options, environment) {
225
229
  manifestPath,
226
230
  fetch: fetchImpl,
227
231
  credential,
228
- apiUrl: project.sync.api_url,
229
- projectId: project.sync.project_id,
232
+ apiUrl: binding.apiUrl,
233
+ projectId: binding.projectId,
234
+ syncTarget: binding.target,
235
+ binding,
230
236
  };
231
237
  }
232
238
 
@@ -308,9 +314,10 @@ function buildLocalDocumentExtractedSummary(kind, extracted = {}) {
308
314
  return {};
309
315
  }
310
316
 
311
- function resolvePreview(manifest, previewToken) {
312
- const previews = Array.isArray(manifest.sync?.pending_previews)
313
- ? manifest.sync.pending_previews
317
+ function resolvePreview(manifest, previewToken, binding) {
318
+ const cursor = readSyncCursor(manifest.sync, binding);
319
+ const previews = Array.isArray(cursor.pending_previews)
320
+ ? cursor.pending_previews
314
321
  : [];
315
322
  if (previewToken) {
316
323
  const match = previews.find((preview) => preview.preview_token === previewToken);