@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/CHANGELOG.md CHANGED
@@ -1,5 +1,52 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.12.0 - 2026-07-10
6
+
7
+ - New `vibecompass bootstrap --bundle <file>` materializes a complete local
8
+ root from a hosted bootstrap-export bundle (fail-closed hash validation,
9
+ verbatim writes, sync-cursor seeding for local-primary bundles).
10
+ - New `vibecompass sync-adopt [--accept-divergence]` re-baselines a root's
11
+ sync cursor onto the hosted head after a divergence preview (D-215) — the
12
+ recovery path for fresh clones and second devices.
13
+ - New `vibecompass promote-hosted [--resume|--abort]` and
14
+ `vibecompass demote-hosted [--accept-divergence]` run the D-288 two-phase
15
+ hosting-mode cutover: fresh verified baseline, server-side transition
16
+ intent with a completeness report, local flip + promoted-root marker, and
17
+ confirm — resumable after any crash and fully reversible.
18
+ - Promoted roots hard-refuse canonical write commands (override with
19
+ VIBECOMPASS_ALLOW_PROMOTED_ROOT_WRITES=1); `vibecompass status` now checks
20
+ the hosted mode and warns on local/hosted mode mismatches.
21
+ - `apply-export` pre-validates every operation before writing and treats
22
+ already-applied operations as converged, so retries after partial failure
23
+ succeed. `docs-review --apply-decision-artifact` is idempotent and
24
+ crash-safe (provenance Source line, marker repair, post-write verification,
25
+ code-enforced append-only) and warns on hosted-only roots.
26
+
27
+ - The missing-sync-token error now explains how to recover: re-export the bound
28
+ env var, persist it in a shell profile, or rotate the token from the hosted
29
+ dashboard (previously a bare "requires VIBECOMPASS_SYNC_TOKEN" with no
30
+ guidance). README documents the persistence recipe.
31
+ - AGENTS.md gains a release checklist, including verifying that
32
+ `vibecompass-mcp`'s dependency range still spans the new version so core
33
+ releases stop stranding the MCP package (its `^0.1.0` pin had drifted 27
34
+ releases behind).
35
+
36
+ ## 0.11.2 - 2026-07-06
37
+
38
+ - Complete the remaining Phase 8 start-session overlap warnings: lanes can now
39
+ declare expected architecture doc paths with `--architecture-doc` and
40
+ decision domain files with `--decision-domain-file` / `--decision-domain`;
41
+ active-lane overlap warnings compare normalized path spellings for those
42
+ fields, and same-repo ownership ambiguity now covers one-sided or
43
+ unparseable path claims while treating single-repo unprefixed claims and
44
+ disjoint feature slugs as real non-overlapping ownership.
45
+ - Harden S2 lane-marker follow-ups: mutating session commands revalidate a
46
+ pre-lock marker snapshot after acquiring the memory-root lock, close-session
47
+ reports unrecorded cwd markers that name the closing lane, and marker parsing
48
+ round-trips Windows absolute `memory_root` values with backslashes.
49
+
3
50
  ## 0.11.1 - 2026-07-06
4
51
 
5
52
  - Harden the per-root serialization lock for symlink-aliased memory roots: root keys now canonicalize through existing parent directories when the memory root itself does not yet exist, and nested lock holders compare held/requested roots by `realpath` before reacquiring the disk lock. This preserves reentrancy when an outer lock creates the root and an inner call reaches it through another spelling.
package/README.md CHANGED
@@ -145,7 +145,15 @@ npx -y @vibecompass/vibecompass@latest close-session --root .compass --session a
145
145
  # close-session and docs-update also print a pre-close staleness set: new
146
146
  # decisions since lane start, stale base revisions, newer finalized notes
147
147
  # that mention the lane's scope, and claim overlap with other active lanes.
148
- npx -y @vibecompass/vibecompass@latest start-session --id auth-flow --working-on "Auth flow" --repo app --branch feature/auth --worktree
148
+ npx -y @vibecompass/vibecompass@latest start-session \
149
+ --id auth-flow \
150
+ --working-on "Auth flow" \
151
+ --repo app \
152
+ --claim src/app/auth \
153
+ --architecture-doc architecture/product/auth/login.md \
154
+ --decision-domain-file cross-cutting.md \
155
+ --branch feature/auth \
156
+ --worktree
149
157
 
150
158
  # Per-lane runtime isolation (D-282): every lane gets its own port and temp
151
159
  # dir at start (recorded in the lane's session.yaml; defaults configurable
@@ -242,6 +250,17 @@ changed canonical project-memory files and the hosted dashboard should catch
242
250
  up. Hosted-only projects do not use a local authoritative push; update or
243
251
  refresh them through the hosted dashboard/proposal flow.
244
252
 
253
+ The sync token is read from the environment variable you bind (for example
254
+ `VIBECOMPASS_SYNC_TOKEN`). One-off `export`s do not survive new terminals —
255
+ persist the token in your shell profile (`~/.zshenv` or `~/.bashrc`):
256
+
257
+ ```bash
258
+ echo 'export VIBECOMPASS_SYNC_TOKEN="<your sync token>"' >> ~/.zshenv
259
+ ```
260
+
261
+ If the token is lost, rotate it on the hosted dashboard under Setup -> Hosted
262
+ sync and export the new value.
263
+
245
264
  ### Multiple environments
246
265
 
247
266
  One project can have named sync targets (for example a local dev server and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibecompass/vibecompass",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "description": "Local-first project-memory core for VibeCompass",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -17,8 +17,11 @@ import { getProjectStatus, renderStatusText, toStatusJson } from './status.js';
17
17
  import { refreshWorkflow } from './refresh-workflow.js';
18
18
  import { inspectProjectCompatibility, formatCompatibilityWarnings } from './compatibility.js';
19
19
  import { PACKAGE_VERSION } from './version.js';
20
+ import { demoteHosted, promoteHosted } from './mode-transition.js';
20
21
  import {
22
+ adoptRemoteHead,
21
23
  applyPullExport,
24
+ bootstrapFromBundle,
22
25
  pullExportProjectMemory,
23
26
  pullPreviewProjectMemory,
24
27
  pushProjectMemory,
@@ -437,6 +440,71 @@ export async function runCli(argv, io = createDefaultIo(), runtime = {}) {
437
440
  return 0;
438
441
  }
439
442
 
443
+ if (parsed.command === 'promote-hosted' || parsed.command === 'demote-hosted') {
444
+ await writeCompatibilityPreflightWarnings(io, parsed.options, runtime);
445
+ const run = parsed.command === 'promote-hosted' ? promoteHosted : demoteHosted;
446
+ const result = await run(parsed.options, {
447
+ cwd: runtime.cwd,
448
+ env: runtime.env,
449
+ runtime,
450
+ });
451
+ io.stdout.write(`Mode transition: ${result.status}\n`);
452
+ if (result.completeness) {
453
+ io.stdout.write(`Carries over: ${result.completeness.carries_over}\n`);
454
+ for (const [kind, total] of Object.entries(result.completeness.documents_by_kind ?? {})) {
455
+ io.stdout.write(` ${kind}: ${total}\n`);
456
+ }
457
+ io.stdout.write('Does not carry over:\n');
458
+ for (const item of result.completeness.does_not_carry_over ?? []) {
459
+ io.stdout.write(` - ${item}\n`);
460
+ }
461
+ }
462
+ for (const warning of result.warnings) {
463
+ io.stdout.write(`Note: ${warning}\n`);
464
+ }
465
+ io.stdout.write(`${result.message}\n`);
466
+ return 0;
467
+ }
468
+
469
+ if (parsed.command === 'sync-adopt') {
470
+ await writeCompatibilityPreflightWarnings(io, parsed.options, runtime);
471
+ const result = await adoptRemoteHead(parsed.options, {
472
+ cwd: runtime.cwd,
473
+ env: runtime.env,
474
+ runtime,
475
+ });
476
+ io.stdout.write(`Sync adopt: ${result.status}\n`);
477
+ io.stdout.write(`Adopted head: ${result.remoteRevisionId}\n`);
478
+ io.stdout.write(`Recorded ${result.manifestPath}\n`);
479
+ for (const warning of result.warnings) {
480
+ io.stdout.write(`Warning: ${warning}\n`);
481
+ }
482
+ io.stdout.write(`${result.message}\n`);
483
+ return 0;
484
+ }
485
+
486
+ if (parsed.command === 'bootstrap') {
487
+ const result = await bootstrapFromBundle(parsed.options, {
488
+ cwd: runtime.cwd,
489
+ env: runtime.env,
490
+ runtime,
491
+ });
492
+ io.stdout.write(`Bootstrap: ${result.status}\n`);
493
+ io.stdout.write(`Root: ${result.rootDir}\n`);
494
+ io.stdout.write(`Documents: ${result.documentCount}\n`);
495
+ if (result.mode) {
496
+ io.stdout.write(`Mode: ${result.mode}\n`);
497
+ }
498
+ if (result.manifestPath) {
499
+ io.stdout.write(`Recorded ${result.manifestPath}\n`);
500
+ }
501
+ for (const warning of result.warnings) {
502
+ io.stdout.write(`Warning: ${warning}\n`);
503
+ }
504
+ io.stdout.write(`${result.message}\n`);
505
+ return 0;
506
+ }
507
+
440
508
  if (parsed.command === 'pull-preview') {
441
509
  await writeCompatibilityPreflightWarnings(io, parsed.options, runtime);
442
510
  const result = await pullPreviewProjectMemory(parsed.options, {
@@ -708,6 +776,18 @@ export function parseCliArgs(argv) {
708
776
  return parsePushArgs(rest);
709
777
  }
710
778
 
779
+ if (command === 'promote-hosted' || command === 'demote-hosted') {
780
+ return parseModeTransitionArgs(command, rest);
781
+ }
782
+
783
+ if (command === 'sync-adopt') {
784
+ return parseSyncAdoptArgs(rest);
785
+ }
786
+
787
+ if (command === 'bootstrap') {
788
+ return parseBootstrapArgs(rest);
789
+ }
790
+
711
791
  if (command === 'pull-preview') {
712
792
  return parsePullPreviewArgs(rest);
713
793
  }
@@ -949,6 +1029,13 @@ function parseStartSessionArgs(argv) {
949
1029
  case '--claim':
950
1030
  parsed.claims = [...(parsed.claims ?? []), value];
951
1031
  break;
1032
+ case '--architecture-doc':
1033
+ parsed.architectureDocs = [...(parsed.architectureDocs ?? []), value];
1034
+ break;
1035
+ case '--decision-domain':
1036
+ case '--decision-domain-file':
1037
+ parsed.decisionDomainFiles = [...(parsed.decisionDomainFiles ?? []), value];
1038
+ break;
952
1039
  case '--branch':
953
1040
  parsed.branch = value;
954
1041
  break;
@@ -1769,6 +1856,122 @@ function parseDocsReviewArgs(argv) {
1769
1856
  };
1770
1857
  }
1771
1858
 
1859
+ function parseModeTransitionArgs(command, argv) {
1860
+ const parsed = {};
1861
+
1862
+ for (let index = 0; index < argv.length; index += 1) {
1863
+ const token = argv[index];
1864
+ if (!token.startsWith('--')) {
1865
+ throw new Error(`Unexpected argument "${token}".`);
1866
+ }
1867
+
1868
+ if (token === '--resume') {
1869
+ parsed.resume = true;
1870
+ continue;
1871
+ }
1872
+ if (token === '--abort') {
1873
+ parsed.abort = true;
1874
+ continue;
1875
+ }
1876
+ if (token === '--accept-divergence') {
1877
+ parsed.acceptDivergence = true;
1878
+ continue;
1879
+ }
1880
+
1881
+ const value = argv[index + 1];
1882
+ if (value === undefined) {
1883
+ throw new Error(`Flag "${token}" requires a value.`);
1884
+ }
1885
+ index += 1;
1886
+
1887
+ switch (token) {
1888
+ case '--root':
1889
+ parsed.rootDir = value;
1890
+ break;
1891
+ case '--sync-target':
1892
+ parsed.syncTarget = value;
1893
+ break;
1894
+ default:
1895
+ throw new Error(`Unknown flag "${token}" for ${command}.`);
1896
+ }
1897
+ }
1898
+
1899
+ return { command, options: parsed };
1900
+ }
1901
+
1902
+ function parseSyncAdoptArgs(argv) {
1903
+ const parsed = {};
1904
+
1905
+ for (let index = 0; index < argv.length; index += 1) {
1906
+ const token = argv[index];
1907
+ if (!token.startsWith('--')) {
1908
+ throw new Error(`Unexpected argument "${token}".`);
1909
+ }
1910
+
1911
+ if (token === '--accept-divergence') {
1912
+ parsed.acceptDivergence = true;
1913
+ continue;
1914
+ }
1915
+
1916
+ const value = argv[index + 1];
1917
+ if (value === undefined) {
1918
+ throw new Error(`Flag "${token}" requires a value.`);
1919
+ }
1920
+ index += 1;
1921
+
1922
+ switch (token) {
1923
+ case '--root':
1924
+ parsed.rootDir = value;
1925
+ break;
1926
+ case '--sync-target':
1927
+ parsed.syncTarget = value;
1928
+ break;
1929
+ default:
1930
+ throw new Error(`Unknown flag "${token}" for sync-adopt.`);
1931
+ }
1932
+ }
1933
+
1934
+ return {
1935
+ command: 'sync-adopt',
1936
+ options: parsed,
1937
+ };
1938
+ }
1939
+
1940
+ function parseBootstrapArgs(argv) {
1941
+ const parsed = {};
1942
+
1943
+ for (let index = 0; index < argv.length; index += 1) {
1944
+ const token = argv[index];
1945
+ if (!token.startsWith('--')) {
1946
+ throw new Error(`Unexpected argument "${token}".`);
1947
+ }
1948
+ const value = argv[index + 1];
1949
+ if (value === undefined) {
1950
+ throw new Error(`Flag "${token}" requires a value.`);
1951
+ }
1952
+ index += 1;
1953
+
1954
+ switch (token) {
1955
+ case '--root':
1956
+ parsed.rootDir = value;
1957
+ break;
1958
+ case '--bundle':
1959
+ parsed.bundlePath = value;
1960
+ break;
1961
+ case '--sync-target':
1962
+ parsed.syncTarget = value;
1963
+ break;
1964
+ default:
1965
+ throw new Error(`Unknown flag "${token}" for bootstrap.`);
1966
+ }
1967
+ }
1968
+
1969
+ return {
1970
+ command: 'bootstrap',
1971
+ options: parsed,
1972
+ };
1973
+ }
1974
+
1772
1975
  function parsePushArgs(argv) {
1773
1976
  return {
1774
1977
  command: 'push',
@@ -2176,6 +2379,10 @@ function usageText() {
2176
2379
  ' vibecompass next-decision-id [options]',
2177
2380
  ' vibecompass sync-agents [options]',
2178
2381
  ' vibecompass push [options]',
2382
+ ' vibecompass bootstrap --bundle <file> [--root <dir>]',
2383
+ ' vibecompass sync-adopt [--accept-divergence] [options]',
2384
+ ' vibecompass promote-hosted [--resume|--abort] [options]',
2385
+ ' vibecompass demote-hosted [--accept-divergence] [options]',
2179
2386
  ' vibecompass pull-preview [options]',
2180
2387
  ' vibecompass pull-export [options]',
2181
2388
  ' vibecompass apply-export [options]',
@@ -2248,6 +2455,8 @@ function usageText() {
2248
2455
  ' --feature <slug> Repeatable feature slug for the lane',
2249
2456
  ' --repo <id> Repeatable repo ID for the lane',
2250
2457
  ' --claim <path> Repeatable path claim for overlap warnings',
2458
+ ' --architecture-doc <path> Repeatable architecture doc path the lane expects to edit',
2459
+ ' --decision-domain-file <domain.md> Repeatable decision domain file the lane expects to edit (alias: --decision-domain)',
2251
2460
  ' --branch <name> Opt-in git binding (D-281): create or reuse this branch in every bound --repo; requires at least one --repo',
2252
2461
  ' --worktree With --branch: provision per-repo worktrees under <workspace>/worktrees/<lane-id>/<repo-id> (D-279) and write the lane marker into the container',
2253
2462
  ' --date <YYYY-MM-DD> Optional explicit session date',
@@ -434,6 +434,15 @@ async function applyDecisionArtifactLocked(options) {
434
434
  throw new Error(`Decision artifact ${artifactId} must include a non-empty rationale before it can be appended locally.`);
435
435
  }
436
436
 
437
+ // Idempotency guard: never append the same artifact twice. The marker is
438
+ // the fast path; the file-content check covers a crash between the file
439
+ // write and the marker update.
440
+ if (artifact.local_status === 'applied') {
441
+ throw new Error(
442
+ `Decision artifact ${artifactId} is already applied locally as ${artifact.local_decision_id ? `decision ${artifact.local_decision_id}` : 'a decision entry'}. Re-applying would append a duplicate entry.`,
443
+ );
444
+ }
445
+
437
446
  const nextDecisionId = await readNextDecisionId(options.rootDir);
438
447
  const targetFilePath = path.join(options.rootDir, targetPath);
439
448
  await mkdir(path.dirname(targetFilePath), { recursive: true });
@@ -446,27 +455,99 @@ async function applyDecisionArtifactLocked(options) {
446
455
  existingContent = `# ${path.basename(targetPath, '.md')} decisions\n`;
447
456
  }
448
457
 
449
- const entry = renderDecisionEntry({
450
- decisionId: nextDecisionId,
451
- title,
452
- decision,
453
- rationale,
454
- context,
455
- artifactId,
456
- });
457
- await writeFile(targetFilePath, `${existingContent.trimEnd()}\n\n${entry}`, 'utf8');
458
- const indexResult = options.refreshIndex
459
- ? await refreshDecisionIndex(options.rootDir)
460
- : {
458
+ const applyWarnings = [];
459
+ const alreadyInFile = existingContent.includes(
460
+ `hosted docs-review artifact ${artifactId}`,
461
+ );
462
+ let appliedDecisionIdForMarker = nextDecisionId;
463
+
464
+ if (alreadyInFile) {
465
+ // A previous apply wrote the file but crashed before recording the
466
+ // marker. Repair the marker instead of appending a duplicate.
467
+ applyWarnings.push(
468
+ `Artifact ${artifactId} was already present in ${targetPath}; repaired the marker without appending a duplicate entry.`,
469
+ );
470
+ const existingIdMatch = existingContent.match(
471
+ new RegExp(
472
+ `### (D-\\d+)[^\\n]*(?:\\n(?!### ).*)*hosted docs-review artifact ${artifactId.replaceAll(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}`,
473
+ ),
474
+ );
475
+ appliedDecisionIdForMarker = existingIdMatch
476
+ ? Number.parseInt(existingIdMatch[1].slice(2), 10)
477
+ : nextDecisionId;
478
+ } else {
479
+ const entry = renderDecisionEntry({
480
+ decisionId: nextDecisionId,
481
+ title,
482
+ decision,
483
+ rationale,
484
+ context,
485
+ artifactId,
486
+ });
487
+ const nextContent = `${existingContent.trimEnd()}\n\n${entry}`;
488
+ // Append-only enforced in code, not just convention: the previous file
489
+ // content must survive verbatim as a prefix of what we write.
490
+ if (!nextContent.startsWith(existingContent.trimEnd())) {
491
+ throw new Error(
492
+ `Refusing to apply artifact ${artifactId}: the write would not preserve the existing content of ${targetPath} (decisions are append-only).`,
493
+ );
494
+ }
495
+ await writeFile(targetFilePath, nextContent, 'utf8');
496
+
497
+ // Post-write verification: the canonical write must be durable and
498
+ // intact before the marker records the artifact as applied.
499
+ const verification = await readFile(targetFilePath, 'utf8');
500
+ if (verification !== nextContent) {
501
+ throw new Error(
502
+ `Post-write verification failed for ${targetPath}: the file on disk does not match the appended content. The marker was NOT updated; inspect the file and retry.`,
503
+ );
504
+ }
505
+ }
506
+
507
+ // Hosted-only roots can apply locally but can never push the result back
508
+ // until the project is demoted to local-primary — say so instead of letting
509
+ // the user discover it at push time.
510
+ try {
511
+ const projectConfig = parseSimpleYaml(
512
+ await readFile(path.join(options.rootDir, 'project.yaml'), 'utf8'),
513
+ { sourceName: 'project.yaml' },
514
+ );
515
+ if (projectConfig?.mode === 'hosted-only') {
516
+ applyWarnings.push(
517
+ 'This is a hosted-only project: the locally applied decision cannot be pushed back to the hosted canonical store. The hosted copy was already recorded when the artifact was accepted; treat this local root as a working copy.',
518
+ );
519
+ }
520
+ } catch {
521
+ // Unreadable project.yaml is surfaced by other commands; not fatal here.
522
+ }
523
+
524
+ let indexResult;
525
+ if (options.refreshIndex) {
526
+ try {
527
+ indexResult = await refreshDecisionIndex(options.rootDir);
528
+ } catch (error) {
529
+ // An index-refresh failure after a verified canonical write must not
530
+ // strand the marker (a retry would duplicate the entry): degrade to a
531
+ // warning and keep going.
532
+ indexResult = {
533
+ refreshed: false,
534
+ warnings: [
535
+ `decisions/INDEX.md refresh failed after the entry was appended: ${error.message}. Run vibecompass refresh-decision-index manually.`,
536
+ ],
537
+ };
538
+ }
539
+ } else {
540
+ indexResult = {
461
541
  refreshed: false,
462
542
  warnings: [
463
543
  'decisions/INDEX.md was not refreshed. Run again with --refresh-index if this root uses the package-generated flat index, or update the project-specific index manually.',
464
544
  ],
465
545
  };
546
+ }
466
547
 
467
548
  const appliedDecisionArtifact = {
468
549
  artifact_id: artifactId,
469
- decision_id: nextDecisionId,
550
+ decision_id: appliedDecisionIdForMarker,
470
551
  target_path: targetPath,
471
552
  applied_at: new Date().toISOString(),
472
553
  refreshed_index: indexResult.refreshed,
@@ -478,7 +559,7 @@ async function applyDecisionArtifactLocked(options) {
478
559
  ...current.hosted,
479
560
  artifacts: (current.hosted?.artifacts ?? []).map((item) =>
480
561
  item?.artifact_id === artifactId
481
- ? { ...item, local_status: 'applied', local_decision_id: nextDecisionId }
562
+ ? { ...item, local_status: 'applied', local_decision_id: appliedDecisionIdForMarker }
482
563
  : item,
483
564
  ),
484
565
  },
@@ -494,8 +575,10 @@ async function applyDecisionArtifactLocked(options) {
494
575
  hosted: current.hosted ?? null,
495
576
  appliedDecisionArtifact,
496
577
  reviewPrompt: null,
497
- warnings: indexResult.warnings,
498
- message: `Applied decision artifact ${artifactId} as D-${String(nextDecisionId).padStart(3, '0')} in ${targetPath}.`,
578
+ warnings: [...applyWarnings, ...indexResult.warnings],
579
+ message: alreadyInFile
580
+ ? `Repaired the marker for decision artifact ${artifactId} (already present in ${targetPath} as D-${String(appliedDecisionIdForMarker).padStart(3, '0')}).`
581
+ : `Applied decision artifact ${artifactId} as D-${String(appliedDecisionIdForMarker).padStart(3, '0')} in ${targetPath}.`,
499
582
  };
500
583
  }
501
584
 
@@ -1972,6 +2055,10 @@ function renderDecisionEntry(options) {
1972
2055
  `**Decision:** ${options.decision}`,
1973
2056
  `**Rationale:** ${options.rationale}`,
1974
2057
  ...(options.context ? [`**Context:** ${options.context}`] : []),
2058
+ // Provenance makes re-application detectable from the file itself, so a
2059
+ // crash between the file write and the marker update can never cause a
2060
+ // silent duplicate append on retry.
2061
+ ...(options.artifactId ? [`**Source:** hosted docs-review artifact ${options.artifactId}`] : []),
1975
2062
  '',
1976
2063
  ].join('\n');
1977
2064
  }
package/src/index.js CHANGED
@@ -6,8 +6,11 @@ export { getProjectStatus, renderStatusText, toStatusJson } from './status.js';
6
6
  export { initializeProjectMemory } from './init.js';
7
7
  export { preflightDocsReview } from './docs-review.js';
8
8
  export { planDocsUpdate, renderDocsUpdatePlan } from './docs-update.js';
9
+ export { demoteHosted, promoteHosted } from './mode-transition.js';
9
10
  export {
11
+ adoptRemoteHead,
10
12
  applyPullExport,
13
+ bootstrapFromBundle,
11
14
  pullExportProjectMemory,
12
15
  pullPreviewProjectMemory,
13
16
  pushProjectMemory,
@@ -108,7 +108,7 @@ export async function readLaneMarker(markerPath) {
108
108
  }
109
109
 
110
110
  const memoryRoot = typeof data.memory_root === 'string' ? data.memory_root.trim() : '';
111
- if (!memoryRoot || !path.isAbsolute(memoryRoot)) {
111
+ if (!memoryRoot || !isAbsolutePathForAnySupportedPlatform(memoryRoot)) {
112
112
  throw new Error(
113
113
  `Lane marker at ${markerPath} must record memory_root as an absolute path (found ${JSON.stringify(data.memory_root ?? null)}). ` +
114
114
  'Recreate the marker with `vibecompass write-lane-marker`, or pass both --root and --session to bypass it.',
@@ -188,6 +188,40 @@ export async function resolveLaneMarkerContext(options) {
188
188
  };
189
189
  }
190
190
 
191
+ /**
192
+ * In-lock revalidation for a marker snapshot captured before waiting on the
193
+ * memory-root lock. If the marker changed while the command waited, fail
194
+ * closed instead of using stale lane/root context for a mutating operation.
195
+ */
196
+ export async function assertLaneMarkerSnapshotCurrent(markerContext) {
197
+ const snapshot = markerContext?.marker;
198
+ if (!snapshot) {
199
+ return;
200
+ }
201
+
202
+ let current;
203
+ try {
204
+ current = await readLaneMarker(snapshot.markerPath);
205
+ } catch (error) {
206
+ throw new Error(
207
+ `Lane marker at ${snapshot.markerPath} changed while waiting for the project-memory lock. ` +
208
+ `Could not re-read the original marker (${error instanceof Error ? error.message : String(error)}). ` +
209
+ 'Rerun the command so lane selection and root inference are based on the current marker.',
210
+ );
211
+ }
212
+
213
+ if (
214
+ current.laneId !== snapshot.laneId ||
215
+ current.memoryRoot !== snapshot.memoryRoot ||
216
+ current.token !== snapshot.token
217
+ ) {
218
+ throw new Error(
219
+ `Lane marker at ${snapshot.markerPath} changed while waiting for the project-memory lock. ` +
220
+ 'Rerun the command so lane selection and root inference are based on the current marker.',
221
+ );
222
+ }
223
+ }
224
+
191
225
  /**
192
226
  * The D-277 lane selection order, shared by every lane-scoped resolver:
193
227
  * explicit --session wins (warning when it contradicts a resolvable marker,
@@ -329,6 +363,10 @@ async function canonicalizePath(value) {
329
363
  }
330
364
  }
331
365
 
366
+ function isAbsolutePathForAnySupportedPlatform(value) {
367
+ return path.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value) || /^\\\\/.test(value);
368
+ }
369
+
332
370
  async function fileExists(filePath) {
333
371
  try {
334
372
  await access(filePath);