@vibecompass/vibecompass 0.11.1 → 0.11.2

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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.11.2 - 2026-07-06
6
+
7
+ - Complete the remaining Phase 8 start-session overlap warnings: lanes can now
8
+ declare expected architecture doc paths with `--architecture-doc` and
9
+ decision domain files with `--decision-domain-file` / `--decision-domain`;
10
+ active-lane overlap warnings compare normalized path spellings for those
11
+ fields, and same-repo ownership ambiguity now covers one-sided or
12
+ unparseable path claims while treating single-repo unprefixed claims and
13
+ disjoint feature slugs as real non-overlapping ownership.
14
+ - Harden S2 lane-marker follow-ups: mutating session commands revalidate a
15
+ pre-lock marker snapshot after acquiring the memory-root lock, close-session
16
+ reports unrecorded cwd markers that name the closing lane, and marker parsing
17
+ round-trips Windows absolute `memory_root` values with backslashes.
18
+
3
19
  ## 0.11.1 - 2026-07-06
4
20
 
5
21
  - 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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibecompass/vibecompass",
3
- "version": "0.11.1",
3
+ "version": "0.11.2",
4
4
  "description": "Local-first project-memory core for VibeCompass",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -949,6 +949,13 @@ function parseStartSessionArgs(argv) {
949
949
  case '--claim':
950
950
  parsed.claims = [...(parsed.claims ?? []), value];
951
951
  break;
952
+ case '--architecture-doc':
953
+ parsed.architectureDocs = [...(parsed.architectureDocs ?? []), value];
954
+ break;
955
+ case '--decision-domain':
956
+ case '--decision-domain-file':
957
+ parsed.decisionDomainFiles = [...(parsed.decisionDomainFiles ?? []), value];
958
+ break;
952
959
  case '--branch':
953
960
  parsed.branch = value;
954
961
  break;
@@ -2248,6 +2255,8 @@ function usageText() {
2248
2255
  ' --feature <slug> Repeatable feature slug for the lane',
2249
2256
  ' --repo <id> Repeatable repo ID for the lane',
2250
2257
  ' --claim <path> Repeatable path claim for overlap warnings',
2258
+ ' --architecture-doc <path> Repeatable architecture doc path the lane expects to edit',
2259
+ ' --decision-domain-file <domain.md> Repeatable decision domain file the lane expects to edit (alias: --decision-domain)',
2251
2260
  ' --branch <name> Opt-in git binding (D-281): create or reuse this branch in every bound --repo; requires at least one --repo',
2252
2261
  ' --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
2262
  ' --date <YYYY-MM-DD> Optional explicit session date',
@@ -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);
package/src/scaffold.js CHANGED
@@ -276,7 +276,7 @@ ${renderWorkflowDefaults(workflow)}
276
276
  6. Read the relevant docs under \`${rootRelativePath}/architecture/\` and \`${rootRelativePath}/decisions/\`.
277
277
 
278
278
  ## Builder workflow
279
- At session start, prefer running \`vibecompass start-session --id <lane-id> --working-on "..." \`; add \`--branch <name> --repo <id> [--worktree]\` when the lane should work on its own branch or in isolated worktrees (D-281).
279
+ At session start, prefer running \`vibecompass start-session --id <lane-id> --working-on "..." \`; add \`--feature\`, \`--repo\`, \`--claim\`, \`--architecture-doc\`, and \`--decision-domain-file\` values when the lane's scope is known so overlap warnings can be precise. Add \`--branch <name> --repo <id> [--worktree]\` when the lane should work on its own branch or in isolated worktrees (D-281).
280
280
  If you manage files manually, create \`${rootRelativePath}/sessions/active/<lane-id>/session.yaml\` and \`${rootRelativePath}/sessions/active/index.yaml\`, then create \`${rootRelativePath}/sessions/active/<lane-id>/wip.md\` if it does not exist:
281
281
 
282
282
  \`\`\`md
package/src/session.js CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  } from './lane-runtime.js';
27
27
  import {
28
28
  LANE_MARKER_FILENAME,
29
+ assertLaneMarkerSnapshotCurrent,
29
30
  assertMarkerTargetDisjoint,
30
31
  findEnclosingGitDir,
31
32
  readLaneMarker,
@@ -141,6 +142,7 @@ export async function inferToolingRootForMemoryRoot(rootDir) {
141
142
  }
142
143
 
143
144
  async function startProjectSessionLocked(normalized, options, markerContext) {
145
+ await assertLaneMarkerSnapshotCurrent(markerContext);
144
146
  const workingOn = requireNonEmptyString(options?.workingOn, 'start-session requires a non-empty workingOn value.');
145
147
  const sessionDate = normalizeSessionDate(options?.date);
146
148
 
@@ -151,12 +153,17 @@ async function startProjectSessionLocked(normalized, options, markerContext) {
151
153
  const features = normalizeStringArray(options?.features);
152
154
  const repos = normalizeStringArray(options?.repos);
153
155
  const claims = normalizeStringArray(options?.claims);
156
+ const architectureDocs = normalizeStringArray(options?.architectureDocs).map(normalizeArchitectureDocPath);
157
+ const decisionDomainFiles = normalizeStringArray(options?.decisionDomainFiles ?? options?.decisionDomains)
158
+ .map(normalizeDecisionDomainFile);
154
159
  const existingLanes = await listActiveSessionLanes(normalized);
155
160
  const overlapWarnings = buildOverlapWarnings({
156
161
  sessionId,
157
162
  features,
158
163
  repos,
159
164
  claims,
165
+ architectureDocs,
166
+ decisionDomainFiles,
160
167
  existingLanes,
161
168
  hasWorktree: options?.worktree === true,
162
169
  });
@@ -276,6 +283,8 @@ async function startProjectSessionLocked(normalized, options, markerContext) {
276
283
  features,
277
284
  repos,
278
285
  claims,
286
+ architectureDocs,
287
+ decisionDomainFiles,
279
288
  baseRevisions: baseRevisionCapture.baseRevisions,
280
289
  runtime: laneRuntime,
281
290
  // Record-before-create (D-281): the full binding plan — including the
@@ -426,6 +435,7 @@ export async function closeProjectSession(options) {
426
435
  }
427
436
 
428
437
  async function closeProjectSessionLocked(normalized, options, markerContext) {
438
+ await assertLaneMarkerSnapshotCurrent(markerContext);
429
439
  const title = requireNonEmptyString(options?.title, 'session close requires a non-empty title.');
430
440
  const completed = normalizeStringArray(options?.completed);
431
441
  const models = normalizeStringArray(options?.models);
@@ -584,6 +594,17 @@ async function closeProjectSessionLocked(normalized, options, markerContext) {
584
594
  markerCleanupWarnings.push(...containerCleanup.warnings);
585
595
  }
586
596
  }
597
+ if (markerContext.marker?.laneId === sessionId) {
598
+ const contextMarkerPath = path.resolve(markerContext.marker.markerPath);
599
+ const recordedMarkerPath = closingLaneMetadata?.laneMarker?.path
600
+ ? path.resolve(closingLaneMetadata.laneMarker.path)
601
+ : null;
602
+ if (contextMarkerPath !== recordedMarkerPath && (await fileExists(contextMarkerPath))) {
603
+ markerCleanupWarnings.push(
604
+ `Lane marker at ${contextMarkerPath} names the closed lane "${sessionId}" but was not recorded in session.yaml; close-session left it in place. Remove it manually or recreate it with \`vibecompass write-lane-marker\` before reusing that directory.`,
605
+ );
606
+ }
607
+ }
587
608
  if (removal.attempted) {
588
609
  worktreeCleanup = {
589
610
  branch: closingLaneMetadata?.branch ?? null,
@@ -900,6 +921,7 @@ export async function switchProjectSession(options = {}) {
900
921
  }
901
922
 
902
923
  async function switchProjectSessionLocked(normalized, options, markerContext) {
924
+ await assertLaneMarkerSnapshotCurrent(markerContext);
903
925
  const sessionId = validateLaneId(requireNonEmptyString(options?.sessionId, 'switch-session requires a session ID.'));
904
926
  const lanes = await listActiveSessionLanes(normalized);
905
927
  const lane = lanes.find((item) => item.id === sessionId);
@@ -1020,6 +1042,7 @@ export async function writeLaneMarkerForSession(options) {
1020
1042
  }
1021
1043
 
1022
1044
  async function writeLaneMarkerForSessionLocked(normalized, options, markerContext) {
1045
+ await assertLaneMarkerSnapshotCurrent(markerContext);
1023
1046
  const sessionId = validateLaneId(
1024
1047
  requireNonEmptyString(options?.sessionId, 'write-lane-marker requires --session <lane-id> so the marker binds to an explicit lane.'),
1025
1048
  );
@@ -1506,6 +1529,8 @@ async function listActiveSessionLanes(normalized) {
1506
1529
  features: metadata.features,
1507
1530
  repos: metadata.repos,
1508
1531
  claims: metadata.claims,
1532
+ architectureDocs: metadata.architectureDocs,
1533
+ decisionDomainFiles: metadata.decisionDomainFiles,
1509
1534
  startedAt: metadata.startedAt,
1510
1535
  decisionSnapshot: metadata.decisionSnapshot,
1511
1536
  branch: metadata.branch ?? null,
@@ -1548,6 +1573,8 @@ async function readLaneMetadata(sessionFilePath) {
1548
1573
  features: normalizeStringArray(data.feature_slugs),
1549
1574
  repos: normalizeStringArray(data.repos),
1550
1575
  claims: normalizeStringArray(data.claimed_paths),
1576
+ architectureDocs: normalizeStringArray(data.architecture_docs).map(normalizeArchitectureDocPath),
1577
+ decisionDomainFiles: normalizeStringArray(data.decision_domain_files).map(normalizeDecisionDomainFile),
1551
1578
  startedAt: normalizeOptionalString(data.started_at),
1552
1579
  decisionSnapshot: {
1553
1580
  highestDecisionId: parseNullableNumber(data.decision_snapshot?.highest_decision_id),
@@ -1581,6 +1608,8 @@ async function readLaneMetadata(sessionFilePath) {
1581
1608
  features: [],
1582
1609
  repos: [],
1583
1610
  claims: [],
1611
+ architectureDocs: [],
1612
+ decisionDomainFiles: [],
1584
1613
  startedAt: null,
1585
1614
  decisionSnapshot: {
1586
1615
  highestDecisionId: null,
@@ -1839,6 +1868,8 @@ function renderLaneMetadata(options) {
1839
1868
  renderYamlArray('feature_slugs', options.features),
1840
1869
  renderYamlArray('repos', options.repos),
1841
1870
  renderYamlArray('claimed_paths', options.claims),
1871
+ renderYamlArray('architecture_docs', options.architectureDocs),
1872
+ renderYamlArray('decision_domain_files', options.decisionDomainFiles),
1842
1873
  `started_at: ${formatLocalDateTime(new Date())}`,
1843
1874
  // D-282: the runtime assignment travels with the lane record (local-only
1844
1875
  // per D-278) so lane-env and close-time cleanup read it back.
@@ -2021,13 +2052,32 @@ function buildOverlapWarnings(options) {
2021
2052
  );
2022
2053
  }
2023
2054
 
2024
- const claimOverlaps = findClaimOverlaps(options.claims, lane.claims ?? []);
2055
+ const claimOverlaps = findClaimOverlaps(
2056
+ options.claims,
2057
+ options.repos,
2058
+ lane.claims ?? [],
2059
+ lane.repos ?? [],
2060
+ );
2025
2061
  for (const overlap of claimOverlaps) {
2026
2062
  warnings.push(
2027
2063
  `Active session lane "${options.sessionId}" overlaps "${lane.id}" on claimed path ${overlap.repo}:${overlap.path}.`,
2028
2064
  );
2029
2065
  }
2030
2066
 
2067
+ const sharedArchitectureDocs = intersect(options.architectureDocs ?? [], lane.architectureDocs ?? []);
2068
+ if (sharedArchitectureDocs.length > 0) {
2069
+ warnings.push(
2070
+ `Active session lane "${options.sessionId}" overlaps "${lane.id}" on architecture doc(s): ${sharedArchitectureDocs.join(', ')}.`,
2071
+ );
2072
+ }
2073
+
2074
+ const sharedDecisionDomainFiles = intersect(options.decisionDomainFiles ?? [], lane.decisionDomainFiles ?? []);
2075
+ if (sharedDecisionDomainFiles.length > 0) {
2076
+ warnings.push(
2077
+ `Active session lane "${options.sessionId}" overlaps "${lane.id}" on decision domain file(s): ${sharedDecisionDomainFiles.join(', ')}.`,
2078
+ );
2079
+ }
2080
+
2031
2081
  const sharedRepos = intersect(options.repos, lane.repos ?? []);
2032
2082
  warnings.push(...buildSharedCheckoutWarnings({
2033
2083
  sessionId: options.sessionId,
@@ -2038,15 +2088,38 @@ function buildOverlapWarnings(options) {
2038
2088
  context: 'start-session',
2039
2089
  }));
2040
2090
 
2091
+ const bothLackClaims = options.claims.length === 0 && (lane.claims ?? []).length === 0;
2041
2092
  if (
2042
2093
  sharedRepos.length > 0 &&
2043
2094
  (options.hasWorktree || lane.worktreeContainer) &&
2044
- options.claims.length === 0 &&
2045
- (lane.claims ?? []).length === 0
2095
+ bothLackClaims
2046
2096
  ) {
2047
2097
  warnings.push(
2048
2098
  `Active session lane "${options.sessionId}" shares repo(s) ${sharedRepos.join(', ')} with "${lane.id}" without path claims; add --claim values to clarify ownership.`,
2049
2099
  );
2100
+ continue;
2101
+ }
2102
+
2103
+ const disambiguatedByFeatures =
2104
+ options.features.length > 0 &&
2105
+ (lane.features ?? []).length > 0 &&
2106
+ sharedFeatures.length === 0;
2107
+ const disambiguatedByClaims = hasNarrowNonOverlappingClaims({
2108
+ sharedRepos,
2109
+ leftClaims: options.claims,
2110
+ leftRepos: options.repos,
2111
+ rightClaims: lane.claims ?? [],
2112
+ rightRepos: lane.repos ?? [],
2113
+ });
2114
+ if (
2115
+ sharedRepos.length > 0 &&
2116
+ claimOverlaps.length === 0 &&
2117
+ !disambiguatedByFeatures &&
2118
+ !disambiguatedByClaims
2119
+ ) {
2120
+ warnings.push(
2121
+ `Active session lane "${options.sessionId}" shares repo(s) ${sharedRepos.join(', ')} with "${lane.id}" without narrower non-overlapping ownership; ownership is ambiguous. Add --claim paths or --feature slugs to both lanes to clarify ownership.`,
2122
+ );
2050
2123
  }
2051
2124
  }
2052
2125
 
@@ -2093,10 +2166,10 @@ function intersect(left, right) {
2093
2166
  return left.filter((item) => rightSet.has(item));
2094
2167
  }
2095
2168
 
2096
- function findClaimOverlaps(leftClaims, rightClaims) {
2169
+ function findClaimOverlaps(leftClaims, leftRepos, rightClaims, rightRepos) {
2097
2170
  const overlaps = [];
2098
- for (const left of leftClaims.map(parseClaim).filter(Boolean)) {
2099
- for (const right of rightClaims.map(parseClaim).filter(Boolean)) {
2171
+ for (const left of expandClaims(leftClaims, leftRepos)) {
2172
+ for (const right of expandClaims(rightClaims, rightRepos)) {
2100
2173
  if (left.repo !== right.repo) {
2101
2174
  continue;
2102
2175
  }
@@ -2113,20 +2186,94 @@ function findClaimOverlaps(leftClaims, rightClaims) {
2113
2186
  return overlaps;
2114
2187
  }
2115
2188
 
2116
- function parseClaim(value) {
2117
- const separatorIndex = value.indexOf(':');
2118
- if (separatorIndex <= 0 || separatorIndex === value.length - 1) {
2119
- return null;
2189
+ function hasNarrowNonOverlappingClaims(options) {
2190
+ if (options.sharedRepos.length === 0) {
2191
+ return false;
2120
2192
  }
2121
2193
 
2122
- return {
2123
- repo: value.slice(0, separatorIndex),
2124
- path: normalizeClaimPath(value.slice(separatorIndex + 1)),
2125
- };
2194
+ const leftClaims = expandClaims(options.leftClaims, options.leftRepos);
2195
+ const rightClaims = expandClaims(options.rightClaims, options.rightRepos);
2196
+ return options.sharedRepos.every((repo) => {
2197
+ const leftForRepo = leftClaims.filter((claim) => claim.repo === repo);
2198
+ const rightForRepo = rightClaims.filter((claim) => claim.repo === repo);
2199
+ if (leftForRepo.length === 0 || rightForRepo.length === 0) {
2200
+ return false;
2201
+ }
2202
+
2203
+ return leftForRepo.every((left) =>
2204
+ rightForRepo.every((right) => !pathPrefixesOverlap(left.path, right.path)));
2205
+ });
2206
+ }
2207
+
2208
+ function expandClaims(claims, repos) {
2209
+ const normalizedRepos = normalizeStringArray(repos);
2210
+ return normalizeStringArray(claims)
2211
+ .flatMap((claim) => parseClaim(claim, normalizedRepos))
2212
+ .filter(Boolean);
2213
+ }
2214
+
2215
+ function parseClaim(value, repos = []) {
2216
+ const source = String(value ?? '').trim();
2217
+ if (!source) {
2218
+ return [];
2219
+ }
2220
+
2221
+ const normalizedRepos = normalizeStringArray(repos);
2222
+ const separatorIndex = source.indexOf(':');
2223
+ const repoId = separatorIndex > 0 ? source.slice(0, separatorIndex) : '';
2224
+ const rawClaimPath = separatorIndex > 0 ? source.slice(separatorIndex + 1) : '';
2225
+ if (separatorIndex > 0 && !rawClaimPath) {
2226
+ return [];
2227
+ }
2228
+
2229
+ if (
2230
+ separatorIndex > 0 &&
2231
+ rawClaimPath &&
2232
+ (!isWindowsDrivePath(source) || (normalizedRepos.includes(repoId) && rawClaimPath.startsWith('/')))
2233
+ ) {
2234
+ const normalizedPath = normalizeClaimPath(rawClaimPath);
2235
+ if (!normalizedPath) {
2236
+ return [];
2237
+ }
2238
+
2239
+ return [{
2240
+ repo: repoId,
2241
+ path: normalizedPath,
2242
+ }];
2243
+ }
2244
+
2245
+ const normalized = normalizeClaimPath(source);
2246
+ if (!normalized) {
2247
+ return [];
2248
+ }
2249
+
2250
+ return normalizedRepos.map((repo) => ({
2251
+ repo,
2252
+ path: normalized,
2253
+ }));
2126
2254
  }
2127
2255
 
2128
2256
  function normalizeClaimPath(value) {
2129
- return value.replace(/^\/+/, '').replace(/\/+$/, '');
2257
+ return String(value ?? '')
2258
+ .trim()
2259
+ .replace(/\\/g, '/')
2260
+ .replace(/\/+/g, '/')
2261
+ .split('/')
2262
+ .filter((segment) => segment !== '' && segment !== '.')
2263
+ .join('/');
2264
+ }
2265
+
2266
+ function normalizeArchitectureDocPath(value) {
2267
+ return normalizeClaimPath(value);
2268
+ }
2269
+
2270
+ function normalizeDecisionDomainFile(value) {
2271
+ const normalized = normalizeClaimPath(value);
2272
+ return normalized.startsWith('decisions/') ? normalized : `decisions/${normalized}`;
2273
+ }
2274
+
2275
+ function isWindowsDrivePath(value) {
2276
+ return /^[A-Za-z]:[\\/]/.test(String(value ?? ''));
2130
2277
  }
2131
2278
 
2132
2279
  function pathPrefixesOverlap(left, right) {