@vibecompass/vibecompass 0.11.0 → 0.11.1

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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.1 - 2026-07-06
4
+
5
+ - 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.
6
+ - Make shared-checkout lane warnings honest about runnable isolation: unbound same-repo lanes now recommend `--branch <name> --worktree` for independent files or dev servers, including at `lane-env` where `PORT` is exported, while keeping `--claim` as the ownership-review fallback.
7
+ - Suppress shared-checkout warnings when either the selected lane or the sibling lane is already worktree-bound, and add regression coverage for the symlink-root reentrancy and worktree-bound warning paths.
8
+
3
9
  ## 0.11.0 - 2026-07-03
4
10
 
5
11
  Phase 8 concurrent multi-session lanes (stages S1–S4). Highlights: a per-root
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibecompass/vibecompass",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "Local-first project-memory core for VibeCompass",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -151,7 +151,7 @@ export async function runCli(argv, io = createDefaultIo(), runtime = {}) {
151
151
  }
152
152
  if (result.runtime) {
153
153
  io.stdout.write(`Runtime: port ${result.runtime.port}, temp dir ${result.runtime.tmpDir}\n`);
154
- io.stdout.write('Export into a shell with: eval "$(vibecompass lane-env)" (D-282; pass --session <lane-id> when multiple lanes are active).\n');
154
+ io.stdout.write('Export into a shell with: eval "$(vibecompass lane-env)" (D-282; pass --session <lane-id> when multiple lanes are active; use --worktree for independently runnable same-repo lanes).\n');
155
155
  }
156
156
  writeWarnings(io, result.warnings);
157
157
  writeAgentFileSyncResult(io, result.agentFileSync);
@@ -2290,7 +2290,7 @@ function usageText() {
2290
2290
  ' --session <lane-id> Lane to export. Omitted: nearest worktree lane marker, else the single active lane; 2+ active lanes require --session or a marker (D-277)',
2291
2291
  ' --json Print the runtime assignment as JSON instead of shell export lines',
2292
2292
  ' --no-conventional Omit the conventional PORT/TMPDIR alias exports',
2293
- ' Consume with: eval "$(vibecompass lane-env)"; defaults come from project.yaml runtime.{port_base,port_step,tmp_base}',
2293
+ ' Consume with: eval "$(vibecompass lane-env)"; defaults come from project.yaml runtime.{port_base,port_step,tmp_base}; use --worktree for independent same-repo dev servers',
2294
2294
  '',
2295
2295
  'Decision-log options (append-decision / next-decision-id):',
2296
2296
  ' --root <path> Project-memory root. Defaults to .compass',
@@ -1,6 +1,6 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
2
  import { randomUUID } from 'node:crypto';
3
- import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { mkdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
 
6
6
  /**
@@ -47,9 +47,9 @@ export function memoryRootLockPath(rootDir) {
47
47
  * a nested call from inside the lock holder runs immediately.
48
48
  */
49
49
  export async function withMemoryRootLock(rootDir, label, fn, options = {}) {
50
- const key = path.resolve(rootDir);
50
+ const key = await canonicalizeMemoryRootKey(rootDir);
51
51
  const held = heldRoots.getStore();
52
- if (held?.has(key)) {
52
+ if (await findHeldMemoryRootKey(held, key)) {
53
53
  return fn();
54
54
  }
55
55
 
@@ -78,6 +78,45 @@ export async function withMemoryRootLock(rootDir, label, fn, options = {}) {
78
78
  }
79
79
  }
80
80
 
81
+ async function canonicalizeMemoryRootKey(rootDir) {
82
+ const resolved = path.resolve(rootDir);
83
+ try {
84
+ return await realpath(resolved);
85
+ } catch {
86
+ try {
87
+ return path.join(await realpath(path.dirname(resolved)), path.basename(resolved));
88
+ } catch {
89
+ return resolved;
90
+ }
91
+ }
92
+ }
93
+
94
+ async function findHeldMemoryRootKey(held, key) {
95
+ if (!held) {
96
+ return null;
97
+ }
98
+
99
+ if (held.has(key)) {
100
+ return key;
101
+ }
102
+
103
+ for (const heldKey of held) {
104
+ if (await memoryRootKeysReferToSamePath(heldKey, key)) {
105
+ return heldKey;
106
+ }
107
+ }
108
+
109
+ return null;
110
+ }
111
+
112
+ async function memoryRootKeysReferToSamePath(left, right) {
113
+ try {
114
+ return (await realpath(left)) === (await realpath(right));
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+
81
120
  async function acquireDiskLock(key, label, options) {
82
121
  const lockDir = memoryRootLockPath(key);
83
122
  const attempts = options.attempts ?? DEFAULT_ATTEMPTS;
package/src/session.js CHANGED
@@ -158,6 +158,7 @@ async function startProjectSessionLocked(normalized, options, markerContext) {
158
158
  repos,
159
159
  claims,
160
160
  existingLanes,
161
+ hasWorktree: options?.worktree === true,
161
162
  });
162
163
  const highestDecisionId = await readHighestDecisionId(normalized.rootDir);
163
164
  const projectConfig = await readProjectConfig(normalized.projectFilePath);
@@ -850,7 +851,18 @@ export async function readLaneEnvironment(options = {}) {
850
851
  port: lane.runtime.port,
851
852
  tmpDir: lane.runtime.tmpDir,
852
853
  env,
853
- warnings: [...markerContext.warnings, ...selection.warnings],
854
+ warnings: [
855
+ ...markerContext.warnings,
856
+ ...selection.warnings,
857
+ ...buildSharedCheckoutWarnings({
858
+ sessionId: selection.sessionId,
859
+ repos: lane.repos ?? [],
860
+ claims: lane.claims ?? [],
861
+ existingLanes: lanes.filter((item) => item.id !== selection.sessionId),
862
+ hasWorktree: Boolean(lane.worktreeContainer),
863
+ context: 'lane-env',
864
+ }),
865
+ ],
854
866
  };
855
867
  }
856
868
 
@@ -2017,7 +2029,21 @@ function buildOverlapWarnings(options) {
2017
2029
  }
2018
2030
 
2019
2031
  const sharedRepos = intersect(options.repos, lane.repos ?? []);
2020
- if (sharedRepos.length > 0 && options.claims.length === 0 && (lane.claims ?? []).length === 0) {
2032
+ warnings.push(...buildSharedCheckoutWarnings({
2033
+ sessionId: options.sessionId,
2034
+ repos: options.repos,
2035
+ claims: options.claims,
2036
+ existingLanes: [lane],
2037
+ hasWorktree: options.hasWorktree,
2038
+ context: 'start-session',
2039
+ }));
2040
+
2041
+ if (
2042
+ sharedRepos.length > 0 &&
2043
+ (options.hasWorktree || lane.worktreeContainer) &&
2044
+ options.claims.length === 0 &&
2045
+ (lane.claims ?? []).length === 0
2046
+ ) {
2021
2047
  warnings.push(
2022
2048
  `Active session lane "${options.sessionId}" shares repo(s) ${sharedRepos.join(', ')} with "${lane.id}" without path claims; add --claim values to clarify ownership.`,
2023
2049
  );
@@ -2027,6 +2053,37 @@ function buildOverlapWarnings(options) {
2027
2053
  return warnings;
2028
2054
  }
2029
2055
 
2056
+ function buildSharedCheckoutWarnings(options) {
2057
+ if (options.hasWorktree) {
2058
+ return [];
2059
+ }
2060
+
2061
+ const warnings = [];
2062
+ for (const lane of options.existingLanes) {
2063
+ if (lane.worktreeContainer) {
2064
+ continue;
2065
+ }
2066
+
2067
+ const sharedRepos = intersect(options.repos, lane.repos ?? []);
2068
+ if (sharedRepos.length === 0) {
2069
+ continue;
2070
+ }
2071
+
2072
+ const ownershipHint = options.claims.length === 0 && (lane.claims ?? []).length === 0
2073
+ ? ' Add --claim values when shared-checkout work is intentional so ownership is reviewable.'
2074
+ : '';
2075
+ const runtimeHint = options.context === 'lane-env'
2076
+ ? ' The exported PORT is a runtime assignment, not an independently runnable dev server for a shared checkout.'
2077
+ : '';
2078
+ warnings.push(
2079
+ `Active session lane "${options.sessionId}" shares repo(s) ${sharedRepos.join(', ')} with unbound lane "${lane.id}" in the same checkout. ` +
2080
+ `Use --branch <name> --worktree when lanes need independent files or dev servers; worktrees are the isolation boundary.${runtimeHint}${ownershipHint}`,
2081
+ );
2082
+ }
2083
+
2084
+ return warnings;
2085
+ }
2086
+
2030
2087
  function collectLaneMetadataWarnings(lanes) {
2031
2088
  return lanes.flatMap((lane) => lane.warnings ?? []);
2032
2089
  }