@vibecompass/vibecompass 0.10.8 → 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/src/session.js CHANGED
@@ -1,4 +1,5 @@
1
- import { access, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
1
+ import { randomUUID } from 'node:crypto';
2
+ import { access, mkdir, readFile, readdir, realpath, rm, rmdir, stat, writeFile } from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
  import { scanProjectMemory } from './project-memory.js';
4
5
  import { parseSimpleYaml } from './simple-yaml.js';
@@ -6,6 +7,34 @@ import { buildCloseSessionGuidance, resolveWorkflowSettings } from './workflow.j
6
7
  import { syncAgentInstructionFiles } from './generators/agent-files/index.js';
7
8
  import { START_MARKER } from './generators/agent-files/markers.js';
8
9
  import { planDocsUpdate } from './docs-update.js';
10
+ import {
11
+ captureBaseRevisions,
12
+ normalizeBranchName,
13
+ preflightGitBinding,
14
+ provisionGitBinding,
15
+ removeLaneWorktreesAtClose,
16
+ rollbackGitBinding,
17
+ } from './git-binding.js';
18
+ import { withMemoryRootLock } from './serialization.js';
19
+ import { findDuplicateDecisionIds, formatDecisionId } from './decisions.js';
20
+ import {
21
+ assignLanePort,
22
+ buildLaneTmpDir,
23
+ computeLaneTmpRootKey,
24
+ removeLaneTmpDirAtClose,
25
+ resolveRuntimeSettings,
26
+ } from './lane-runtime.js';
27
+ import {
28
+ LANE_MARKER_FILENAME,
29
+ assertMarkerTargetDisjoint,
30
+ findEnclosingGitDir,
31
+ readLaneMarker,
32
+ renderLaneMarker,
33
+ resolveLaneMarkerContext,
34
+ resolveLaneSelection,
35
+ validateLaneId,
36
+ } from './lane-marker.js';
37
+ import { PACKAGE_VERSION } from './version.js';
9
38
  // This lazy-only cycle is intentional: manifest.js reads lane state from this
10
39
  // module, and session command handlers rewrite the derived manifest after
11
40
  // lane mutations. Keep both sides free of top-level calls into the other.
@@ -14,7 +43,6 @@ import { writeStateManifest } from './manifest.js';
14
43
  const CURRENT_SESSION_REQUIRED_FIELDS = ['Date:', 'Working on:', 'Last thing completed:', 'Blockers:', 'Next session should:'];
15
44
  const WIP_HEADER_PATTERN = /^# WIP — (\d{4}-\d{2}-\d{2}) \(session (\d+)\)$/m;
16
45
  const SESSION_FILENAME_PATTERN = /^(\d{4}-\d{2}-\d{2})-(\d+)-([a-z0-9-]+)\.md$/i;
17
- const LANE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/;
18
46
  const DOCUMENT_MAINTENANCE_STATUSES = new Set(['updated', 'not-needed', 'deferred']);
19
47
  const DOCUMENT_MAINTENANCE_FIELDS = [
20
48
  {
@@ -33,27 +61,86 @@ const DOCUMENT_MAINTENANCE_FIELDS = [
33
61
  flag: '--session-maintenance',
34
62
  },
35
63
  ];
36
- const RESERVED_LANE_IDS = new Set([
37
- 'active',
38
- 'current',
39
- 'default',
40
- 'false',
41
- 'handoff',
42
- 'index',
43
- 'new',
44
- 'no',
45
- 'null',
46
- 'off',
47
- 'on',
48
- 'sessions',
49
- 'state',
50
- 'true',
51
- 'wip',
52
- 'yes',
53
- ]);
54
-
55
64
  export async function startProjectSession(options) {
56
- const normalized = normalizeSessionPaths(options);
65
+ const { normalized, markerContext } = await resolveSessionCommandContext(options);
66
+ return withMemoryRootLock(normalized.rootDir, 'start-session', () =>
67
+ startProjectSessionLocked(normalized, options, markerContext));
68
+ }
69
+
70
+ /**
71
+ * Pre-lock command context (D-280): an explicit --root always wins; otherwise
72
+ * lane-scoped commands adopt the nearest valid worktree lane marker's
73
+ * memory_root, and only when no marker resolves does the `cwd/.compass`
74
+ * default apply. Commands that never resolve a lane from the marker skip the
75
+ * marker walk entirely when --root is explicit, so internal callers that
76
+ * always pass rootDir (manifest/status refresh) keep their existing behavior.
77
+ */
78
+ async function resolveSessionCommandContext(options, { needMarkerForLane = false } = {}) {
79
+ let markerContext;
80
+ if (options?.rootDir && !needMarkerForLane) {
81
+ const cwd = options?.cwd ? path.resolve(options.cwd) : process.cwd();
82
+ markerContext = { cwd, rootDir: path.resolve(cwd, options.rootDir), rootSource: 'flag', marker: null, warnings: [] };
83
+ } else {
84
+ markerContext = await resolveLaneMarkerContext({
85
+ cwd: options?.cwd,
86
+ explicitRootDir: options?.rootDir ?? null,
87
+ explicitSessionId: normalizeOptionalString(options?.sessionId),
88
+ });
89
+ }
90
+
91
+ const inferredToolingRootDir = await inferToolingRootDirForContext(options, markerContext);
92
+ const normalized = normalizeSessionPaths({
93
+ ...options,
94
+ cwd: markerContext.cwd,
95
+ rootDir: markerContext.rootDir,
96
+ ...(inferredToolingRootDir ? { toolingRootDir: inferredToolingRootDir } : {}),
97
+ });
98
+ return { normalized, markerContext };
99
+ }
100
+
101
+ /**
102
+ * The tooling root (CLAUDE.md home) defaults to cwd, but follows the memory
103
+ * root's placement in two cases: when the marker supplied the root (a
104
+ * worktree cwd must update the real Current-session block), and when an
105
+ * explicit --root is used from a cwd with no CLAUDE.md (e.g. the corrupt-
106
+ * marker bypass run from a worktree) — a case where the cwd default could
107
+ * only fail at readClaudeFile, so inferring is strictly recovery, never a
108
+ * behavior change for working flows.
109
+ */
110
+ async function inferToolingRootDirForContext(options, markerContext) {
111
+ if (options?.toolingRootDir) {
112
+ return undefined;
113
+ }
114
+
115
+ if (markerContext.rootSource === 'marker') {
116
+ return inferToolingRootForMemoryRoot(markerContext.rootDir);
117
+ }
118
+
119
+ if (markerContext.rootSource === 'flag' && !(await fileExists(path.join(markerContext.cwd, 'CLAUDE.md')))) {
120
+ return inferToolingRootForMemoryRoot(markerContext.rootDir);
121
+ }
122
+
123
+ return undefined;
124
+ }
125
+
126
+ /**
127
+ * The `<owner>/.compass` placement keeps CLAUDE.md one level above the memory
128
+ * root; the dedicated-memory-repo placement keeps it inside the root itself.
129
+ * Prefer whichever directory actually holds a CLAUDE.md, falling back to the
130
+ * parent (the actionable readClaudeFile error and --tooling-root remain the
131
+ * escape hatch for layouts with no CLAUDE.md at either).
132
+ * Exported for read-only surfaces (status) that adopt a marker-supplied root
133
+ * and must follow the same placement rule for the tooling root.
134
+ */
135
+ export async function inferToolingRootForMemoryRoot(rootDir) {
136
+ if (await fileExists(path.join(rootDir, 'CLAUDE.md'))) {
137
+ return rootDir;
138
+ }
139
+
140
+ return path.dirname(rootDir);
141
+ }
142
+
143
+ async function startProjectSessionLocked(normalized, options, markerContext) {
57
144
  const workingOn = requireNonEmptyString(options?.workingOn, 'start-session requires a non-empty workingOn value.');
58
145
  const sessionDate = normalizeSessionDate(options?.date);
59
146
 
@@ -71,8 +158,74 @@ export async function startProjectSession(options) {
71
158
  repos,
72
159
  claims,
73
160
  existingLanes,
161
+ hasWorktree: options?.worktree === true,
74
162
  });
75
163
  const highestDecisionId = await readHighestDecisionId(normalized.rootDir);
164
+ const projectConfig = await readProjectConfig(normalized.projectFilePath);
165
+ const baseRevisionCapture = await captureBaseRevisions({
166
+ workspaceDir: path.dirname(normalized.rootDir),
167
+ repoIds: repos,
168
+ projectRepos: Array.isArray(projectConfig?.repos) ? projectConfig.repos : [],
169
+ });
170
+
171
+ // D-282 per-lane runtime assignment, computed in preflight inside the
172
+ // memory-root lock so sibling lanes' recorded ports are stable while the
173
+ // lowest free slot is picked. Assignment is lane coordination on this root,
174
+ // not an OS-level reservation — no port is probed or bound here.
175
+ const runtimeSettings = resolveRuntimeSettings(projectConfig);
176
+ const laneRuntime = {
177
+ port: assignLanePort({
178
+ existingLanes,
179
+ portBase: runtimeSettings.portBase,
180
+ portStep: runtimeSettings.portStep,
181
+ }),
182
+ tmpDir: buildLaneTmpDir({
183
+ tmpBase: runtimeSettings.tmpBase,
184
+ rootKey: await computeLaneTmpRootKey(normalized.rootDir),
185
+ laneId: sessionId,
186
+ }),
187
+ };
188
+
189
+ // D-281 opt-in git binding: normalize + preflight before the first write so
190
+ // a refused binding leaves the root untouched.
191
+ if (options?.worktree && !options?.branch) {
192
+ throw new Error('--worktree requires --branch; a worktree checks out the lane branch.');
193
+ }
194
+ let gitBinding = null;
195
+ if (options?.branch) {
196
+ const branch = await normalizeBranchName(options.branch);
197
+ const workspaceDir = path.dirname(normalized.rootDir);
198
+ const containerDir = path.join(workspaceDir, 'worktrees', sessionId);
199
+ const useWorktree = options.worktree === true;
200
+ const preflight = await preflightGitBinding({
201
+ workspaceDir,
202
+ rootDir: normalized.rootDir,
203
+ branch,
204
+ worktree: useWorktree,
205
+ repoIds: repos,
206
+ projectRepos: Array.isArray(projectConfig?.repos) ? projectConfig.repos : [],
207
+ existingLanes,
208
+ containerDir,
209
+ });
210
+ gitBinding = {
211
+ branch,
212
+ containerDir: useWorktree ? containerDir : null,
213
+ repoPlans: preflight.repoPlans,
214
+ warnings: preflight.warnings,
215
+ marker: useWorktree
216
+ ? {
217
+ path: path.join(containerDir, LANE_MARKER_FILENAME),
218
+ token: randomUUID(),
219
+ createdAt: formatLocalDateTime(new Date()),
220
+ }
221
+ : null,
222
+ };
223
+ // Bound repos base on what the binding checks out (create: start point;
224
+ // reuse: existing branch tip), overriding the plain HEAD capture.
225
+ for (const plan of preflight.repoPlans) {
226
+ baseRevisionCapture.baseRevisions[plan.repoId] = plan.baseRevision;
227
+ }
228
+ }
76
229
 
77
230
  const nextSessionNumber = await getNextSessionNumber(normalized.sessionsDir, sessionDate);
78
231
  const currentSession = parseCurrentSessionBlock(claude.content, { optional: true });
@@ -87,6 +240,21 @@ export async function startProjectSession(options) {
87
240
  nextSessionShould:
88
241
  normalizeOptionalString(options?.nextSessionShould) ??
89
242
  'Finish the active session and close it with a finalized session note.',
243
+ }, {
244
+ lanes: [
245
+ ...existingLanes.map((lane) => ({ id: lane.id, workingOn: lane.workingOn })),
246
+ { id: sessionId, workingOn },
247
+ ],
248
+ selectedId: sessionId,
249
+ });
250
+
251
+ // D-282: the lane temp dir is created before the first root write, so a
252
+ // creation failure aborts start-session with the root untouched (empty
253
+ // parent namespace dirs left behind by a failure are benign).
254
+ await mkdir(laneRuntime.tmpDir, { recursive: true }).catch((error) => {
255
+ throw new Error(
256
+ `Could not create the lane temp directory ${laneRuntime.tmpDir}: ${error instanceof Error ? error.message : String(error)} (D-282). Configure project.yaml runtime.tmp_base if the default namespace is not writable.`,
257
+ );
90
258
  });
91
259
 
92
260
  await mkdir(normalized.activeSessionsDir, { recursive: true });
@@ -108,6 +276,25 @@ export async function startProjectSession(options) {
108
276
  features,
109
277
  repos,
110
278
  claims,
279
+ baseRevisions: baseRevisionCapture.baseRevisions,
280
+ runtime: laneRuntime,
281
+ // Record-before-create (D-281): the full binding plan — including the
282
+ // marker path/token — is in session.yaml before any git artifact or
283
+ // marker file exists, so a crash leaves only benign recorded-but-missing
284
+ // entries.
285
+ ...(gitBinding
286
+ ? {
287
+ branch: gitBinding.branch,
288
+ worktreeContainer: gitBinding.containerDir,
289
+ worktrees: gitBinding.containerDir
290
+ ? Object.fromEntries(gitBinding.repoPlans.map((plan) => [plan.repoId, plan.worktreePath]))
291
+ : null,
292
+ worktreeSources: gitBinding.containerDir
293
+ ? Object.fromEntries(gitBinding.repoPlans.map((plan) => [plan.repoId, plan.sourceDir]))
294
+ : null,
295
+ laneMarker: gitBinding.marker,
296
+ }
297
+ : {}),
111
298
  highestDecisionId,
112
299
  }),
113
300
  'utf8',
@@ -132,11 +319,57 @@ export async function startProjectSession(options) {
132
319
  }),
133
320
  'utf8',
134
321
  );
322
+ const priorIndexContent = await readFile(normalized.activeSessionsIndexPath, 'utf8').catch(() => null);
135
323
  await upsertActiveSessionIndex(normalized, {
136
324
  id: sessionId,
137
325
  status: 'active',
138
326
  workingOn,
139
327
  }, { current: sessionId });
328
+
329
+ if (gitBinding) {
330
+ const progress = { worktrees: [], createdBranches: [] };
331
+ try {
332
+ if (gitBinding.containerDir) {
333
+ await mkdir(gitBinding.containerDir, { recursive: true });
334
+ await writeFile(
335
+ gitBinding.marker.path,
336
+ renderLaneMarker({
337
+ laneId: sessionId,
338
+ memoryRoot: normalized.rootDir,
339
+ token: gitBinding.marker.token,
340
+ createdAt: gitBinding.marker.createdAt,
341
+ createdBy: PACKAGE_VERSION,
342
+ }),
343
+ 'utf8',
344
+ );
345
+ }
346
+ await provisionGitBinding({ branch: gitBinding.branch, repoPlans: gitBinding.repoPlans }, progress);
347
+ } catch (error) {
348
+ // D-281: start-session is atomic — unwind git artifacts created this
349
+ // call, then the lane files, CLAUDE.md, and index this call wrote.
350
+ const rollbackNotes = await rollbackGitBinding(progress, gitBinding.branch);
351
+ if (gitBinding.containerDir) {
352
+ await rm(gitBinding.containerDir, { recursive: true, force: true }).catch(() => {});
353
+ }
354
+ await rm(lanePaths.laneDir, { recursive: true, force: true }).catch(() => {});
355
+ // D-282: the lane temp dir was created this call; unwind it with the rest.
356
+ await rm(laneRuntime.tmpDir, { recursive: true, force: true }).catch(() => {});
357
+ await writeFile(normalized.claudePath, claude.content, 'utf8').catch(() => {});
358
+ if (priorIndexContent === null) {
359
+ await rm(normalized.activeSessionsIndexPath, { force: true }).catch(() => {});
360
+ } else {
361
+ await writeFile(normalized.activeSessionsIndexPath, priorIndexContent, 'utf8').catch(() => {});
362
+ }
363
+ await refreshStateManifestSafely(normalized.rootDir);
364
+ const message = error instanceof Error ? error.message : String(error);
365
+ throw new Error(
366
+ `${message}\nGit binding failed; start-session was rolled back and lane "${sessionId}" was not created.` +
367
+ (rollbackNotes.length > 0 ? `\nRollback notes: ${rollbackNotes.join('; ')}` : '') +
368
+ '\nIf a crash interrupts this rollback, run `vibecompass rebuild-active-index` and close the lane normally.',
369
+ );
370
+ }
371
+ }
372
+
140
373
  const manifestRefresh = await refreshStateManifestSafely(normalized.rootDir);
141
374
  const auditWarnings = await buildDocsReviewWarnings(normalized);
142
375
  const metadataWarnings = collectLaneMetadataWarnings(existingLanes);
@@ -155,19 +388,44 @@ export async function startProjectSession(options) {
155
388
  handoffFilePath: lanePaths.handoffFilePath,
156
389
  sessionDate,
157
390
  sessionNumber: nextSessionNumber,
391
+ runtime: laneRuntime,
158
392
  warnings: [
393
+ ...markerContext.warnings,
159
394
  ...metadataWarnings,
160
395
  ...overlapWarnings,
396
+ ...baseRevisionCapture.warnings,
397
+ ...runtimeSettings.warnings,
398
+ ...(gitBinding?.warnings ?? []),
161
399
  ...manifestRefresh.warnings,
162
400
  ...auditWarnings,
163
401
  ],
402
+ ...(gitBinding
403
+ ? {
404
+ gitBinding: {
405
+ branch: gitBinding.branch,
406
+ worktreeContainer: gitBinding.containerDir,
407
+ markerPath: gitBinding.marker?.path ?? null,
408
+ repos: gitBinding.repoPlans.map((plan) => ({
409
+ repoId: plan.repoId,
410
+ mode: plan.mode,
411
+ baseRevision: plan.baseRevision,
412
+ worktreePath: plan.worktreePath,
413
+ })),
414
+ },
415
+ }
416
+ : {}),
164
417
  manifest: manifestRefresh.manifest,
165
418
  agentFileSync,
166
419
  };
167
420
  }
168
421
 
169
422
  export async function closeProjectSession(options) {
170
- const normalized = normalizeSessionPaths(options);
423
+ const { normalized, markerContext } = await resolveSessionCommandContext(options, { needMarkerForLane: true });
424
+ return withMemoryRootLock(normalized.rootDir, 'close-session', () =>
425
+ closeProjectSessionLocked(normalized, options, markerContext));
426
+ }
427
+
428
+ async function closeProjectSessionLocked(normalized, options, markerContext) {
171
429
  const title = requireNonEmptyString(options?.title, 'session close requires a non-empty title.');
172
430
  const completed = normalizeStringArray(options?.completed);
173
431
  const models = normalizeStringArray(options?.models);
@@ -184,16 +442,42 @@ export async function closeProjectSession(options) {
184
442
  }
185
443
 
186
444
  await ensureInitializedProjectMemory(normalized.rootDir, { allowMissingProjectFile: true });
445
+
446
+ const duplicateDecisionIds = await findDuplicateDecisionIds(normalized.rootDir);
447
+ if (duplicateDecisionIds.length > 0) {
448
+ throw new Error(
449
+ `close-session is blocked: duplicate decision IDs detected — ${duplicateDecisionIds
450
+ .map((duplicate) => `${formatDecisionId(duplicate.id)} (${duplicate.occurrences.join(', ')})`)
451
+ .join('; ')}. Repair the canonical decision files by hand before closing (D-276); duplicates are never auto-renumbered.`,
452
+ );
453
+ }
454
+
187
455
  const claude = await readClaudeFile(normalized.claudePath);
188
456
  const projectConfig = await readProjectConfig(normalized.projectFilePath);
189
457
  const workflowSettings = resolveWorkflowSettings(projectConfig);
190
- const sessionId = await resolveExistingSessionId(normalized, options);
458
+ const laneSelection = await resolveExistingSessionId(normalized, options, markerContext, 'close');
459
+ const sessionId = laneSelection.sessionId;
191
460
  const lanePaths = sessionId ? getLanePaths(normalized, sessionId) : normalized;
461
+ const closingLaneMetadata = sessionId ? await readLaneMetadata(lanePaths.sessionFilePath) : null;
462
+ // Fail closed on a null-degraded lane parse, mirroring the write-lane-marker
463
+ // rebind guard: a degraded parse reads as "no worktrees recorded", so
464
+ // closing would destroy session.yaml — the only record of the worktree
465
+ // paths, sources, container, and marker token — while the worktrees (and
466
+ // any uncommitted work in them) survive unmanaged behind a permanently
467
+ // stale marker (D-281).
468
+ if (closingLaneMetadata && closingLaneMetadata.warnings.length > 0) {
469
+ throw new Error(
470
+ `Lane "${sessionId}"'s session.yaml could not be parsed (${closingLaneMetadata.warnings[0]}); refusing to close — a null-degraded parse would hide recorded worktrees and closing would destroy the removal-guard records while the worktrees survive (D-281). Repair the lane metadata first, then rerun close-session.`,
471
+ );
472
+ }
192
473
  const documentMaintenance = normalizeDocumentMaintenanceCheckpoint(options?.documentMaintenance);
193
474
  const docsUpdate = await planDocsUpdateSafely({
194
475
  rootDir: normalized.rootDir,
195
476
  cwd: normalized.cwd,
196
477
  sessionId,
478
+ // close-session already resolved (and warned about) the lane selection;
479
+ // the embedded plan must not repeat the flag-vs-marker warning.
480
+ suppressLaneWarnings: true,
197
481
  // Test injection seam; production callers should let close-session use the default planner.
198
482
  planner: options?.docsUpdatePlanner,
199
483
  });
@@ -227,7 +511,9 @@ export async function closeProjectSession(options) {
227
511
  const sessionFilePath = path.join(normalized.rootDir, sessionRelativePath);
228
512
 
229
513
  if (await fileExists(sessionFilePath)) {
230
- throw new Error(`Session note already exists at ${sessionFilePath}.`);
514
+ throw new Error(
515
+ `Session note already exists at ${sessionFilePath}. If a previous close-session was interrupted after writing the note (for example during worktree removal), move or delete that file and rerun close-session — recorded-but-missing worktrees from the interrupted run are benign.`,
516
+ );
231
517
  }
232
518
 
233
519
  await writeFile(
@@ -248,8 +534,86 @@ export async function closeProjectSession(options) {
248
534
  );
249
535
 
250
536
  const hadHandoff = await fileExists(lanePaths.handoffFilePath);
537
+ const markerCleanupWarnings = [];
251
538
  let activeSessionIndex = null;
539
+ let worktreeCleanup = null;
540
+ let runtimeCleanup = null;
252
541
  if (sessionId) {
542
+ // D-281 close-side guarded removal (S3-3): runs after the session note is
543
+ // written (a failed note write leaves everything intact) and before the
544
+ // lane records are destroyed. A crash between removal and the lane-dir
545
+ // delete leaves only benign recorded-but-missing entries.
546
+ const removal = closingLaneMetadata
547
+ ? await removeLaneWorktreesAtClose({
548
+ laneId: sessionId,
549
+ worktrees: closingLaneMetadata.worktrees,
550
+ worktreeSources: closingLaneMetadata.worktreeSources,
551
+ worktreeContainer: closingLaneMetadata.worktreeContainer,
552
+ recordedMarker: closingLaneMetadata.laneMarker,
553
+ cwd: normalized.cwd,
554
+ })
555
+ : { attempted: false, removed: [], surviving: [], warnings: [] };
556
+ markerCleanupWarnings.push(...removal.warnings);
557
+
558
+ let markerRemoved = false;
559
+ let markerKept = false;
560
+ let containerRemoved = false;
561
+ if (removal.surviving.length > 0) {
562
+ // D-281: keep the marker while any recorded worktree survives, so the
563
+ // leftover worktrees keep an actionable fail-closed breadcrumb instead
564
+ // of a silent wrong-root fallback. Only claim the marker "was kept"
565
+ // when it actually exists — removal may have been refused precisely
566
+ // because the marker is missing.
567
+ if (closingLaneMetadata?.laneMarker?.path && (await fileExists(closingLaneMetadata.laneMarker.path))) {
568
+ markerKept = true;
569
+ markerCleanupWarnings.push(
570
+ `Lane "${sessionId}" closed with ${removal.surviving.length} surviving worktree(s); the container marker at ${closingLaneMetadata.laneMarker.path} was kept as a breadcrumb (D-281). After removing the worktrees, delete the marker and the container directory manually.`,
571
+ );
572
+ }
573
+ } else {
574
+ if (closingLaneMetadata?.laneMarker?.path) {
575
+ const cleanup = await removeRecordedLaneMarker(closingLaneMetadata.laneMarker);
576
+ markerRemoved = cleanup.removed;
577
+ markerCleanupWarnings.push(...cleanup.warnings);
578
+ }
579
+ if (removal.attempted && closingLaneMetadata?.worktreeContainer) {
580
+ // rmdir-if-empty: never recursive — a stray user file in the
581
+ // container keeps it in place with a note rather than being deleted.
582
+ const containerCleanup = await removeEmptyWorktreeContainer(closingLaneMetadata.worktreeContainer, normalized.cwd);
583
+ containerRemoved = containerCleanup.removed;
584
+ markerCleanupWarnings.push(...containerCleanup.warnings);
585
+ }
586
+ }
587
+ if (removal.attempted) {
588
+ worktreeCleanup = {
589
+ branch: closingLaneMetadata?.branch ?? null,
590
+ removed: removal.removed,
591
+ surviving: removal.surviving,
592
+ markerRemoved,
593
+ // Explicit kept status (reviewer pass): "kept" requires the marker
594
+ // file to exist — surviving worktrees with a missing marker must not
595
+ // render a phantom breadcrumb line.
596
+ markerKept,
597
+ containerRemoved,
598
+ };
599
+ }
600
+
601
+ // D-282 guarded lane temp-dir removal: runs before the lane record —
602
+ // the only pointer to the temp dir — is destroyed. The namespace is
603
+ // verified from the recorded path's own <root-key>/<lane-id> tail against
604
+ // the env-independent rootKey, never a close-time recompute of tmp_base
605
+ // (os.tmpdir() reads TMPDIR, which lane-env pollutes). Lanes without a
606
+ // recorded runtime block (pre-S4) close exactly as before.
607
+ if (closingLaneMetadata?.runtime?.tmpDir) {
608
+ runtimeCleanup = await removeLaneTmpDirAtClose({
609
+ recordedTmpDir: closingLaneMetadata.runtime.tmpDir,
610
+ laneId: sessionId,
611
+ rootKey: await computeLaneTmpRootKey(normalized.rootDir),
612
+ cwd: normalized.cwd,
613
+ });
614
+ markerCleanupWarnings.push(...runtimeCleanup.warnings);
615
+ }
616
+
253
617
  await rm(lanePaths.laneDir, { recursive: true, force: true });
254
618
  activeSessionIndex = await removeActiveSessionFromIndex(normalized, sessionId);
255
619
  } else {
@@ -260,20 +624,34 @@ export async function closeProjectSession(options) {
260
624
  }
261
625
 
262
626
  const closedSummary = `Closed session ${activeSession.sessionNumber} and wrote \`${sessionRelativePath}\`.`;
627
+ const survivingLanes = activeSessionIndex?.lanes ?? [];
628
+ const survivorListing = { lanes: survivingLanes.map((lane) => ({ id: lane.id, workingOn: lane.workingOn })), selectedId: activeSessionIndex?.current ?? null };
263
629
  const updatedClaude = replaceCurrentSessionBlock(
264
630
  claude.content,
265
631
  activeSessionIndex?.current
266
- ? buildCurrentSessionFieldsForLane(activeSessionIndex.lanes.find((lane) => lane.id === activeSessionIndex.current), {
632
+ ? buildCurrentSessionFieldsForLane(survivingLanes.find((lane) => lane.id === activeSessionIndex.current), {
267
633
  lastThingCompleted: normalizeOptionalString(options?.lastThingCompleted) ?? closedSummary,
268
634
  })
269
- : {
270
- date: `${activeSession.sessionDate} (session ${activeSession.sessionNumber})`,
271
- workingOn: 'Session closed. Ready for the next builder session.',
272
- lastThingCompleted: normalizeOptionalString(options?.lastThingCompleted) ?? closedSummary,
273
- blockers: blockers.length > 0 ? summarizeList(blockers) : 'No blocker remains.',
274
- nextSessionShould:
275
- normalizeOptionalString(options?.nextSessionShould) ?? summarizeOrderedList(nextSteps),
276
- },
635
+ : survivingLanes.length >= 2
636
+ ? {
637
+ date: `${activeSession.sessionDate} (session ${activeSession.sessionNumber})`,
638
+ workingOn:
639
+ 'Multiple lanes remain active. Select one explicitly with `vibecompass switch-session <lane-id>`, --session, or a worktree lane marker.',
640
+ lastThingCompleted: normalizeOptionalString(options?.lastThingCompleted) ?? closedSummary,
641
+ blockers: blockers.length > 0 ? summarizeList(blockers) : 'No blocker remains.',
642
+ nextSessionShould:
643
+ normalizeOptionalString(options?.nextSessionShould) ??
644
+ 'Pick the next lane explicitly (D-277: no implicit current-lane fallback while multiple lanes are active).',
645
+ }
646
+ : {
647
+ date: `${activeSession.sessionDate} (session ${activeSession.sessionNumber})`,
648
+ workingOn: 'Session closed. Ready for the next builder session.',
649
+ lastThingCompleted: normalizeOptionalString(options?.lastThingCompleted) ?? closedSummary,
650
+ blockers: blockers.length > 0 ? summarizeList(blockers) : 'No blocker remains.',
651
+ nextSessionShould:
652
+ normalizeOptionalString(options?.nextSessionShould) ?? summarizeOrderedList(nextSteps),
653
+ },
654
+ survivorListing,
277
655
  );
278
656
 
279
657
  await writeFile(normalized.claudePath, updatedClaude, 'utf8');
@@ -299,7 +677,16 @@ export async function closeProjectSession(options) {
299
677
  projectConfig,
300
678
  rootFlag: buildRootFlagForGuidance(normalized),
301
679
  }),
680
+ worktreeCleanup,
681
+ runtimeCleanup,
302
682
  warnings: [
683
+ ...markerContext.warnings,
684
+ ...laneSelection.warnings,
685
+ ...markerCleanupWarnings,
686
+ // A:192 (D-281): the pre-close staleness set computed by docs-update is
687
+ // re-emitted at close as warnings, so the closer sees it even when the
688
+ // embedded plan scrolls past.
689
+ ...collectStalenessWarnings(docsUpdate.plan),
303
690
  ...docsUpdate.warnings,
304
691
  ...manifestRefresh.warnings,
305
692
  ],
@@ -312,6 +699,62 @@ export async function closeProjectSession(options) {
312
699
  };
313
700
  }
314
701
 
702
+ /**
703
+ * D-281 rmdir-if-empty: after the worktrees and the container marker are
704
+ * gone, an empty container is removed non-recursively. Anything left inside
705
+ * (stray user files) keeps the container in place with a note — close-out
706
+ * never deletes content it did not create. The D-281 cwd clause applies here
707
+ * too: POSIX happily rmdirs the invoking process's own working directory,
708
+ * which would strand the caller's shell in a deleted path.
709
+ */
710
+ async function removeEmptyWorktreeContainer(containerDir, cwd) {
711
+ const realContainer = await canonicalizePathBestEffort(containerDir);
712
+ const realCwd = await canonicalizePathBestEffort(cwd ?? process.cwd());
713
+ const cwdRelative = path.relative(realContainer, realCwd);
714
+ if (cwdRelative === '' || (!cwdRelative.startsWith('..') && !path.isAbsolute(cwdRelative))) {
715
+ return {
716
+ removed: false,
717
+ warnings: [
718
+ `Worktree container ${containerDir} was left in place: the current working directory is inside it (D-281). cd out of the container, then delete it manually.`,
719
+ ],
720
+ };
721
+ }
722
+
723
+ try {
724
+ await rmdir(containerDir);
725
+ return { removed: true, warnings: [] };
726
+ } catch (error) {
727
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
728
+ return { removed: false, warnings: [] };
729
+ }
730
+
731
+ return {
732
+ removed: false,
733
+ warnings: [
734
+ `Worktree container ${containerDir} was left in place: it is not empty (close-session only removes an empty container; D-281). Inspect and delete it manually.`,
735
+ ],
736
+ };
737
+ }
738
+ }
739
+
740
+ async function canonicalizePathBestEffort(value) {
741
+ try {
742
+ return await realpath(value);
743
+ } catch {
744
+ return path.resolve(value);
745
+ }
746
+ }
747
+
748
+ /** Flattens the docs-update pre-close staleness set into close warnings (A:192 re-emission). */
749
+ function collectStalenessWarnings(plan) {
750
+ const staleness = plan?.staleness;
751
+ if (!staleness) {
752
+ return [];
753
+ }
754
+
755
+ return (staleness.entries ?? []).map((entry) => `Pre-close staleness: ${entry}`);
756
+ }
757
+
315
758
  function normalizeDocumentMaintenanceCheckpoint(value) {
316
759
  const source = value && typeof value === 'object' && !Array.isArray(value)
317
760
  ? value
@@ -352,21 +795,111 @@ function normalizeDocumentMaintenanceCheckpoint(value) {
352
795
  return checkpoint;
353
796
  }
354
797
 
798
+ /**
799
+ * Read-only lane runtime export (D-282): resolves the lane per D-277/D-280
800
+ * (explicit --session, else nearest worktree marker, else single active lane)
801
+ * and returns the recorded runtime assignment plus a ready-to-export env map.
802
+ * The conventional PORT/TMPDIR aliases let unmodified dev servers and tools
803
+ * pick the assignment up; pass conventionalAliases: false to omit them.
804
+ */
805
+ export async function readLaneEnvironment(options = {}) {
806
+ const { normalized, markerContext } = await resolveSessionCommandContext(options, { needMarkerForLane: true });
807
+ await ensureInitializedProjectMemory(normalized.rootDir, { allowMissingProjectFile: true });
808
+ const lanes = await listActiveSessionLanes(normalized);
809
+ const explicitId = normalizeOptionalString(options?.sessionId);
810
+ const selection = resolveLaneSelection({
811
+ explicitSessionId: explicitId ? validateLaneId(explicitId) : null,
812
+ marker: markerContext.marker,
813
+ laneIds: lanes.map((lane) => lane.id),
814
+ rootDir: normalized.rootDir,
815
+ purpose: 'export runtime env for',
816
+ });
817
+
818
+ if (!selection.sessionId) {
819
+ throw new Error('No active session lane exists. Run `vibecompass start-session` first (D-282: runtime assignments are per-lane).');
820
+ }
821
+
822
+ const lane = lanes.find((item) => item.id === selection.sessionId);
823
+ if (lane.warnings.length > 0) {
824
+ throw new Error(
825
+ `Lane "${selection.sessionId}"'s session.yaml could not be parsed (${lane.warnings[0]}); repair the lane metadata before exporting its runtime env.`,
826
+ );
827
+ }
828
+
829
+ if (!lane.runtime || !Number.isInteger(lane.runtime.port) || !lane.runtime.tmpDir) {
830
+ throw new Error(
831
+ `Lane "${selection.sessionId}" has no recorded runtime assignment (lanes started before D-282 have none). Close and re-open the lane to assign one.`,
832
+ );
833
+ }
834
+
835
+ const conventionalAliases = options.conventionalAliases !== false;
836
+ const env = {
837
+ VIBECOMPASS_LANE_ID: selection.sessionId,
838
+ VIBECOMPASS_LANE_PORT: String(lane.runtime.port),
839
+ VIBECOMPASS_LANE_TMPDIR: lane.runtime.tmpDir,
840
+ ...(conventionalAliases
841
+ ? {
842
+ PORT: String(lane.runtime.port),
843
+ TMPDIR: lane.runtime.tmpDir,
844
+ }
845
+ : {}),
846
+ };
847
+
848
+ return {
849
+ rootDir: normalized.rootDir,
850
+ sessionId: selection.sessionId,
851
+ port: lane.runtime.port,
852
+ tmpDir: lane.runtime.tmpDir,
853
+ env,
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
+ ],
866
+ };
867
+ }
868
+
355
869
  export async function listProjectSessions(options = {}) {
356
- const normalized = normalizeSessionPaths(options);
870
+ const { normalized } = await resolveSessionCommandContext(options);
357
871
  await ensureInitializedProjectMemory(normalized.rootDir, { allowMissingProjectFile: true });
358
872
  const index = await readActiveSessionIndex(normalized);
359
873
  const lanes = await listActiveSessionLanes(normalized);
360
874
 
361
875
  return {
362
876
  rootDir: normalized.rootDir,
363
- current: index.current ?? lanes[0]?.id ?? null,
877
+ current: resolveValidatedCurrentLane(index.current, lanes),
364
878
  lanes,
365
879
  };
366
880
  }
367
881
 
882
+ /**
883
+ * D-277: the index pointer is only reported as current when it names an
884
+ * existing active lane; a single active lane may be current implicitly, but
885
+ * with two or more lanes an invalid/missing pointer is never repaired by
886
+ * picking one — current becomes null until an explicit selection.
887
+ */
888
+ function resolveValidatedCurrentLane(pointer, lanes) {
889
+ if (pointer && lanes.some((lane) => lane.id === pointer)) {
890
+ return pointer;
891
+ }
892
+
893
+ return lanes.length === 1 ? lanes[0].id : null;
894
+ }
895
+
368
896
  export async function switchProjectSession(options = {}) {
369
- const normalized = normalizeSessionPaths(options);
897
+ const { normalized, markerContext } = await resolveSessionCommandContext(options);
898
+ return withMemoryRootLock(normalized.rootDir, 'switch-session', () =>
899
+ switchProjectSessionLocked(normalized, options, markerContext));
900
+ }
901
+
902
+ async function switchProjectSessionLocked(normalized, options, markerContext) {
370
903
  const sessionId = validateLaneId(requireNonEmptyString(options?.sessionId, 'switch-session requires a session ID.'));
371
904
  const lanes = await listActiveSessionLanes(normalized);
372
905
  const lane = lanes.find((item) => item.id === sessionId);
@@ -375,6 +908,16 @@ export async function switchProjectSession(options = {}) {
375
908
  throw new Error(`Active session lane "${sessionId}" does not exist.`);
376
909
  }
377
910
 
911
+ // D-280 disagreement warning: switching to a lane other than the one the
912
+ // enclosing marker binds proceeds, but names both lanes.
913
+ const selection = resolveLaneSelection({
914
+ explicitSessionId: sessionId,
915
+ marker: markerContext?.marker ?? null,
916
+ laneIds: lanes.map((item) => item.id),
917
+ rootDir: normalized.rootDir,
918
+ purpose: 'switch to',
919
+ });
920
+
378
921
  await upsertActiveSessionIndex(normalized, null, { current: sessionId });
379
922
  const claude = await readClaudeFile(normalized.claudePath);
380
923
  await writeFile(
@@ -384,6 +927,7 @@ export async function switchProjectSession(options = {}) {
384
927
  buildCurrentSessionFieldsForLane(lane, {
385
928
  lastThingCompleted: `Switched current lane to ${sessionId}.`,
386
929
  }),
930
+ { lanes: lanes.map((item) => ({ id: item.id, workingOn: item.workingOn })), selectedId: sessionId },
387
931
  ),
388
932
  'utf8',
389
933
  );
@@ -398,12 +942,244 @@ export async function switchProjectSession(options = {}) {
398
942
  claudePath: normalized.claudePath,
399
943
  current: sessionId,
400
944
  lanes,
401
- warnings: manifestRefresh.warnings,
945
+ warnings: [...markerContext.warnings, ...selection.warnings, ...manifestRefresh.warnings],
402
946
  manifest: manifestRefresh.manifest,
403
947
  agentFileSync,
404
948
  };
405
949
  }
406
950
 
951
+ /**
952
+ * Rebuilds `sessions/active/index.yaml` from the active lane directories plus
953
+ * an explicit current-lane selection (D-266/D-277 recovery path). The current
954
+ * pointer is never derived from enumeration alone: an explicit `current` wins,
955
+ * a still-valid existing pointer is preserved, and only a single surviving
956
+ * lane may become current implicitly.
957
+ */
958
+ export async function rebuildActiveSessionIndex(options = {}) {
959
+ const { normalized } = await resolveSessionCommandContext(options);
960
+ return withMemoryRootLock(normalized.rootDir, 'rebuild-active-index', async () => {
961
+ await ensureInitializedProjectMemory(normalized.rootDir, { allowMissingProjectFile: true });
962
+ const lanes = await listActiveSessionLanes(normalized);
963
+ const requested = options?.current == null || options.current === ''
964
+ ? null
965
+ : validateLaneId(requireNonEmptyString(options.current, 'rebuild-active-index requires a non-empty --current lane ID when provided.'));
966
+
967
+ if (requested && !lanes.some((lane) => lane.id === requested)) {
968
+ throw new Error(`Active session lane "${requested}" does not exist; cannot set it as the current lane.`);
969
+ }
970
+
971
+ if (lanes.length === 0) {
972
+ await rm(normalized.activeSessionsIndexPath, { force: true });
973
+ return {
974
+ rootDir: normalized.rootDir,
975
+ indexPath: normalized.activeSessionsIndexPath,
976
+ current: null,
977
+ lanes,
978
+ removed: true,
979
+ };
980
+ }
981
+
982
+ const existing = await readActiveSessionIndex(normalized);
983
+ const current =
984
+ requested ??
985
+ (lanes.some((lane) => lane.id === existing.current) ? existing.current : null) ??
986
+ (lanes.length === 1 ? lanes[0].id : null);
987
+
988
+ if (!current) {
989
+ throw new Error(
990
+ 'rebuild-active-index needs an explicit --current <lane-id> because more than one lane is active and the existing pointer is not valid (D-277: the current lane is an explicit selection, not a derived default).',
991
+ );
992
+ }
993
+
994
+ await mkdir(normalized.activeSessionsDir, { recursive: true });
995
+ await writeFile(normalized.activeSessionsIndexPath, renderActiveSessionIndex(current, lanes), 'utf8');
996
+ return {
997
+ rootDir: normalized.rootDir,
998
+ indexPath: normalized.activeSessionsIndexPath,
999
+ current,
1000
+ lanes,
1001
+ removed: false,
1002
+ };
1003
+ });
1004
+ }
1005
+
1006
+ /**
1007
+ * Writes the worktree lane marker for an active lane (D-280). Markers are
1008
+ * never written implicitly: this explicit command and S3 worktree
1009
+ * provisioning are the only producers. The target must be path-disjoint from
1010
+ * the memory root, and the lane's session.yaml records the marker under a
1011
+ * `lane_marker:` block map so close-session and S3 removal can token-match.
1012
+ */
1013
+ export async function writeLaneMarkerForSession(options) {
1014
+ // needMarkerForLane: the enclosing marker is consulted even under an
1015
+ // explicit --root so the D-280 flag-vs-marker disagreement warning fires
1016
+ // when this command binds a different lane than the invoking context.
1017
+ const { normalized, markerContext } = await resolveSessionCommandContext(options, { needMarkerForLane: true });
1018
+ return withMemoryRootLock(normalized.rootDir, 'write-lane-marker', () =>
1019
+ writeLaneMarkerForSessionLocked(normalized, options, markerContext));
1020
+ }
1021
+
1022
+ async function writeLaneMarkerForSessionLocked(normalized, options, markerContext) {
1023
+ const sessionId = validateLaneId(
1024
+ requireNonEmptyString(options?.sessionId, 'write-lane-marker requires --session <lane-id> so the marker binds to an explicit lane.'),
1025
+ );
1026
+ await ensureInitializedProjectMemory(normalized.rootDir, { allowMissingProjectFile: true });
1027
+ const warnings = [...markerContext.warnings];
1028
+ const lanes = await listActiveSessionLanes(normalized);
1029
+ // Shared D-277/D-280 selection: validates the explicit lane is active and
1030
+ // emits the disagreement warning when an enclosing marker names another lane.
1031
+ const selection = resolveLaneSelection({
1032
+ explicitSessionId: sessionId,
1033
+ marker: markerContext.marker,
1034
+ laneIds: lanes.map((lane) => lane.id),
1035
+ rootDir: normalized.rootDir,
1036
+ purpose: 'bind the marker to',
1037
+ });
1038
+ warnings.push(...selection.warnings);
1039
+
1040
+ const lanePaths = getLanePaths(normalized, sessionId);
1041
+ if (!(await fileExists(lanePaths.sessionFilePath))) {
1042
+ throw new Error(
1043
+ `Lane "${sessionId}" has no session.yaml at ${lanePaths.sessionFilePath}; the marker cannot be recorded. Repair the lane (rebuild-active-index) before writing a marker.`,
1044
+ );
1045
+ }
1046
+
1047
+ const targetDir = path.resolve(normalized.cwd, options?.dir ?? '.');
1048
+ const targetStat = await stat(targetDir).catch(() => null);
1049
+ if (!targetStat) {
1050
+ throw new Error(
1051
+ `write-lane-marker target directory ${targetDir} does not exist. Create it first; markers are never provisioned implicitly (D-280).`,
1052
+ );
1053
+ }
1054
+ if (!targetStat.isDirectory()) {
1055
+ throw new Error(`write-lane-marker target ${targetDir} is not a directory.`);
1056
+ }
1057
+
1058
+ await assertMarkerTargetDisjoint(targetDir, normalized.rootDir);
1059
+ const gitDir = await findEnclosingGitDir(targetDir);
1060
+ if (gitDir) {
1061
+ warnings.push(
1062
+ `Marker target ${targetDir} sits inside a git work tree (${gitDir}); lane markers are local-only (D-278) — make sure ${LANE_MARKER_FILENAME} is git-ignored there.`,
1063
+ );
1064
+ }
1065
+
1066
+ const markerPath = path.join(targetDir, LANE_MARKER_FILENAME);
1067
+ if (await fileExists(markerPath)) {
1068
+ try {
1069
+ const existing = await readLaneMarker(markerPath);
1070
+ if (existing.laneId !== sessionId) {
1071
+ warnings.push(`Replaced the marker at ${markerPath} previously bound to lane "${existing.laneId}".`);
1072
+ }
1073
+ } catch {
1074
+ warnings.push(`Replaced an unreadable marker at ${markerPath}.`);
1075
+ }
1076
+ }
1077
+
1078
+ const laneMetadata = await readLaneMetadata(lanePaths.sessionFilePath);
1079
+ // Fail closed when the lane metadata is unreadable: a null-degraded parse
1080
+ // would hide a recorded worktree container and let a rebind orphan the
1081
+ // removal guard (D-281).
1082
+ if (laneMetadata.warnings.length > 0) {
1083
+ throw new Error(
1084
+ `Lane "${sessionId}"'s session.yaml could not be parsed (${laneMetadata.warnings[0]}); refusing to rebind its marker — a provisioned worktree container would be undetectable (D-281). Repair the lane metadata first.`,
1085
+ );
1086
+ }
1087
+ // D-281: a provisioned lane's container marker is what authorizes guarded
1088
+ // worktree removal at close; rebinding would token-delete it and orphan
1089
+ // the worktrees permanently.
1090
+ if (laneMetadata.worktreeContainer) {
1091
+ throw new Error(
1092
+ `Lane "${sessionId}" has a provisioned worktree container at ${laneMetadata.worktreeContainer}; its marker is managed by start/close-session and rebinding it would orphan the worktree-removal guard (D-281). Close the lane to remove the worktrees instead.`,
1093
+ );
1094
+ }
1095
+ if (laneMetadata.laneMarker?.path && path.resolve(laneMetadata.laneMarker.path) !== markerPath) {
1096
+ const cleanup = await removeRecordedLaneMarker(laneMetadata.laneMarker);
1097
+ warnings.push(...cleanup.warnings);
1098
+ }
1099
+
1100
+ const token = randomUUID();
1101
+ const createdAt = formatLocalDateTime(new Date());
1102
+ const sessionYaml = await readFile(lanePaths.sessionFilePath, 'utf8');
1103
+ // Record before writing the marker file: a crash between the two leaves the
1104
+ // benign recorded-but-missing state (cleanup is a no-op) instead of an
1105
+ // unrecorded on-disk marker that nothing can remove or report.
1106
+ await writeFile(
1107
+ lanePaths.sessionFilePath,
1108
+ upsertLaneMarkerBlock(sessionYaml, { path: markerPath, token, createdAt }),
1109
+ 'utf8',
1110
+ );
1111
+ await writeFile(
1112
+ markerPath,
1113
+ renderLaneMarker({
1114
+ laneId: sessionId,
1115
+ memoryRoot: normalized.rootDir,
1116
+ token,
1117
+ createdAt,
1118
+ createdBy: PACKAGE_VERSION,
1119
+ }),
1120
+ 'utf8',
1121
+ );
1122
+
1123
+ return {
1124
+ rootDir: normalized.rootDir,
1125
+ sessionId,
1126
+ markerPath,
1127
+ token,
1128
+ warnings,
1129
+ };
1130
+ }
1131
+
1132
+ /**
1133
+ * Removes a lane's recorded marker file only when the on-disk token still
1134
+ * matches the recorded token (D-280 guarded removal); anything else is
1135
+ * reported and left in place.
1136
+ */
1137
+ async function removeRecordedLaneMarker(recorded) {
1138
+ const warnings = [];
1139
+ if (!recorded?.path) {
1140
+ return { removed: false, warnings };
1141
+ }
1142
+
1143
+ if (!(await fileExists(recorded.path))) {
1144
+ return { removed: false, warnings };
1145
+ }
1146
+
1147
+ try {
1148
+ const marker = await readLaneMarker(recorded.path);
1149
+ if (recorded.token && marker.token === recorded.token) {
1150
+ await rm(recorded.path, { force: true });
1151
+ return { removed: true, warnings };
1152
+ }
1153
+
1154
+ warnings.push(
1155
+ `Lane marker at ${recorded.path} left in place: its token does not match the lane's recorded marker (D-280: only token-matched markers are removed).`,
1156
+ );
1157
+ } catch (error) {
1158
+ warnings.push(
1159
+ `Lane marker at ${recorded.path} left in place: ${error instanceof Error ? error.message : String(error)}`,
1160
+ );
1161
+ }
1162
+
1163
+ return { removed: false, warnings };
1164
+ }
1165
+
1166
+ /** Replaces (or appends) the `lane_marker:` block map in a session.yaml body. */
1167
+ function upsertLaneMarkerBlock(content, marker) {
1168
+ // CRLF-tolerant to match parseSimpleYaml's /\r?\n/ splitting; a missed
1169
+ // strip here would append a duplicate key and void the whole lane metadata.
1170
+ const stripped = content.replace(/^lane_marker:\r?\n(?:[ \t]+.*\r?\n?)*/m, '');
1171
+ const base = stripped.endsWith('\n') ? stripped : `${stripped}\n`;
1172
+ const block = [
1173
+ 'lane_marker:',
1174
+ ` path: ${quoteYamlString(marker.path)}`,
1175
+ ` token: ${quoteYamlString(marker.token)}`,
1176
+ ` created_at: ${marker.createdAt}`,
1177
+ '',
1178
+ ].join('\n');
1179
+
1180
+ return `${base}${block}`;
1181
+ }
1182
+
407
1183
  async function refreshStateManifestSafely(rootDir) {
408
1184
  try {
409
1185
  const manifest = await writeStateManifest(rootDir);
@@ -429,6 +1205,7 @@ async function planDocsUpdateSafely(options) {
429
1205
  rootDir: options.rootDir,
430
1206
  cwd: options.cwd,
431
1207
  sessionId: options.sessionId,
1208
+ suppressLaneWarnings: options.suppressLaneWarnings === true,
432
1209
  }),
433
1210
  warnings: [],
434
1211
  };
@@ -588,9 +1365,9 @@ function parseCurrentSessionBlock(content, options = {}) {
588
1365
  };
589
1366
  }
590
1367
 
591
- function replaceCurrentSessionBlock(content, fields) {
1368
+ function replaceCurrentSessionBlock(content, fields, laneListing = null) {
592
1369
  const sessionFence = findCurrentSessionFence(content);
593
- const blockBody = renderCurrentSessionBlockBody(fields);
1370
+ const blockBody = renderCurrentSessionBlockBody(fields, laneListing);
594
1371
 
595
1372
  if (!sessionFence) {
596
1373
  return insertCurrentSessionBlock(content, blockBody);
@@ -599,14 +1376,32 @@ function replaceCurrentSessionBlock(content, fields) {
599
1376
  return `${content.slice(0, sessionFence.bodyStart)}${blockBody}${content.slice(sessionFence.bodyEnd)}`;
600
1377
  }
601
1378
 
602
- function renderCurrentSessionBlockBody(fields) {
603
- return [
604
- `Date: ${fields.date}`,
1379
+ /**
1380
+ * D-277: with two or more active lanes the block becomes a derived multi-lane
1381
+ * listing (one line per active lane plus the selected lane); with at most one
1382
+ * lane it keeps the exact legacy five-field shape. The five field labels stay
1383
+ * present in both shapes so parseCurrentSessionBlock/findCurrentSessionFence
1384
+ * keep working on either.
1385
+ */
1386
+ function renderCurrentSessionBlockBody(fields, laneListing = null) {
1387
+ const lines = [`Date: ${fields.date}`];
1388
+
1389
+ if (laneListing && Array.isArray(laneListing.lanes) && laneListing.lanes.length >= 2) {
1390
+ lines.push('Active lanes:');
1391
+ for (const lane of [...laneListing.lanes].sort((left, right) => left.id.localeCompare(right.id))) {
1392
+ const selectedSuffix = laneListing.selectedId === lane.id ? ' [selected]' : '';
1393
+ lines.push(`- ${lane.id} — ${lane.workingOn ?? 'No summary recorded'}${selectedSuffix}`);
1394
+ }
1395
+ }
1396
+
1397
+ lines.push(
605
1398
  `Working on: ${fields.workingOn}`,
606
1399
  `Last thing completed: ${fields.lastThingCompleted}`,
607
1400
  `Blockers: ${fields.blockers}`,
608
1401
  `Next session should: ${fields.nextSessionShould}`,
609
- ].join('\n');
1402
+ );
1403
+
1404
+ return lines.join('\n');
610
1405
  }
611
1406
 
612
1407
  function insertCurrentSessionBlock(content, blockBody) {
@@ -678,22 +1473,17 @@ async function resolveStartSessionId(normalized, options) {
678
1473
  throw new Error('start-session requires --id <lane-id> so each active lane has a meaningful name.');
679
1474
  }
680
1475
 
681
- async function resolveExistingSessionId(normalized, options) {
1476
+ async function resolveExistingSessionId(normalized, options, markerContext, purpose) {
682
1477
  const explicitId = normalizeOptionalString(options?.sessionId);
683
- if (explicitId) {
684
- return validateLaneId(explicitId);
685
- }
686
-
687
1478
  const lanes = await listActiveSessionLanes(normalized);
688
- if (lanes.length === 1) {
689
- return lanes[0].id;
690
- }
691
-
692
- if (lanes.length > 1) {
693
- throw new Error('Multiple active session lanes exist. Pass --session to choose which lane to close.');
694
- }
695
1479
 
696
- return null;
1480
+ return resolveLaneSelection({
1481
+ explicitSessionId: explicitId ? validateLaneId(explicitId) : null,
1482
+ marker: markerContext?.marker ?? null,
1483
+ laneIds: lanes.map((lane) => lane.id),
1484
+ rootDir: normalized.rootDir,
1485
+ purpose,
1486
+ });
697
1487
  }
698
1488
 
699
1489
  async function listActiveSessionLanes(normalized) {
@@ -718,6 +1508,12 @@ async function listActiveSessionLanes(normalized) {
718
1508
  claims: metadata.claims,
719
1509
  startedAt: metadata.startedAt,
720
1510
  decisionSnapshot: metadata.decisionSnapshot,
1511
+ branch: metadata.branch ?? null,
1512
+ worktreeContainer: metadata.worktreeContainer ?? null,
1513
+ worktrees: metadata.worktrees ?? {},
1514
+ worktreeSources: metadata.worktreeSources ?? {},
1515
+ baseRevisions: metadata.baseRevisions ?? {},
1516
+ runtime: metadata.runtime ?? null,
721
1517
  warnings: metadata.warnings,
722
1518
  sessionDate: metadata.sessionDate ?? wipSession?.sessionDate ?? null,
723
1519
  sessionNumber: metadata.sessionNumber ?? wipSession?.sessionNumber ?? 0,
@@ -756,6 +1552,24 @@ async function readLaneMetadata(sessionFilePath) {
756
1552
  decisionSnapshot: {
757
1553
  highestDecisionId: parseNullableNumber(data.decision_snapshot?.highest_decision_id),
758
1554
  },
1555
+ laneMarker: data.lane_marker && typeof data.lane_marker === 'object'
1556
+ ? {
1557
+ path: normalizeOptionalString(data.lane_marker.path),
1558
+ token: normalizeOptionalString(data.lane_marker.token),
1559
+ createdAt: normalizeOptionalString(data.lane_marker.created_at),
1560
+ }
1561
+ : null,
1562
+ branch: normalizeOptionalString(data.branch),
1563
+ worktreeContainer: normalizeOptionalString(data.worktree_container),
1564
+ worktrees: normalizeStringMap(data.worktrees),
1565
+ worktreeSources: normalizeStringMap(data.worktree_sources),
1566
+ baseRevisions: normalizeStringMap(data.base_revisions),
1567
+ runtime: data.runtime && typeof data.runtime === 'object' && !Array.isArray(data.runtime)
1568
+ ? {
1569
+ port: parseNullableNumber(data.runtime.port),
1570
+ tmpDir: normalizeOptionalString(data.runtime.tmp_dir),
1571
+ }
1572
+ : null,
759
1573
  sessionDate: normalizeOptionalString(data.session_date),
760
1574
  sessionNumber: typeof data.session_number === 'number' ? data.session_number : Number(data.session_number) || null,
761
1575
  warnings: [],
@@ -771,6 +1585,13 @@ async function readLaneMetadata(sessionFilePath) {
771
1585
  decisionSnapshot: {
772
1586
  highestDecisionId: null,
773
1587
  },
1588
+ laneMarker: null,
1589
+ branch: null,
1590
+ worktreeContainer: null,
1591
+ worktrees: {},
1592
+ worktreeSources: {},
1593
+ baseRevisions: {},
1594
+ runtime: null,
774
1595
  sessionDate: null,
775
1596
  sessionNumber: null,
776
1597
  warnings: [
@@ -780,6 +1601,27 @@ async function readLaneMetadata(sessionFilePath) {
780
1601
  }
781
1602
  }
782
1603
 
1604
+ /**
1605
+ * One-level block map of string values (worktrees, worktree_sources,
1606
+ * base_revisions). Unquoted all-digit values come back numerically coerced
1607
+ * from parseScalar — normalize them back to strings.
1608
+ */
1609
+ function normalizeStringMap(value) {
1610
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
1611
+ return {};
1612
+ }
1613
+
1614
+ const result = {};
1615
+ for (const [key, entry] of Object.entries(value)) {
1616
+ const normalized = normalizeOptionalString(typeof entry === 'number' ? String(entry) : entry);
1617
+ if (normalized) {
1618
+ result[key] = normalized;
1619
+ }
1620
+ }
1621
+
1622
+ return result;
1623
+ }
1624
+
783
1625
  async function readActiveSessionIndex(normalized) {
784
1626
  try {
785
1627
  const data = parseSimpleYaml(await readFile(normalized.activeSessionsIndexPath, 'utf8'), {
@@ -796,7 +1638,12 @@ async function readActiveSessionIndex(normalized) {
796
1638
  }
797
1639
 
798
1640
  async function upsertActiveSessionIndex(normalized, lane, options = {}) {
799
- const currentIndex = await readActiveSessionIndex(normalized);
1641
+ // D-277: callers must select `current` explicitly; the upsert never derives
1642
+ // a current lane from the lane being written or a stale existing pointer.
1643
+ if (!('current' in options)) {
1644
+ throw new Error('upsertActiveSessionIndex requires an explicit current-lane selection.');
1645
+ }
1646
+
800
1647
  const lanes = await listActiveSessionLanes(normalized);
801
1648
  const laneMap = new Map(lanes.map((item) => [item.id, item]));
802
1649
  if (lane) {
@@ -807,9 +1654,8 @@ async function upsertActiveSessionIndex(normalized, lane, options = {}) {
807
1654
  });
808
1655
  }
809
1656
 
810
- const current = options.current ?? currentIndex.current ?? lane?.id ?? null;
811
1657
  await mkdir(normalized.activeSessionsDir, { recursive: true });
812
- await writeFile(normalized.activeSessionsIndexPath, renderActiveSessionIndex(current, [...laneMap.values()]), 'utf8');
1658
+ await writeFile(normalized.activeSessionsIndexPath, renderActiveSessionIndex(options.current, [...laneMap.values()]), 'utf8');
813
1659
  }
814
1660
 
815
1661
  /**
@@ -820,7 +1666,15 @@ async function upsertActiveSessionIndex(normalized, lane, options = {}) {
820
1666
  async function removeActiveSessionFromIndex(normalized, sessionId) {
821
1667
  const index = await readActiveSessionIndex(normalized);
822
1668
  const lanes = (await listActiveSessionLanes(normalized)).filter((lane) => lane.id !== sessionId);
823
- const current = index.current === sessionId ? lanes[0]?.id ?? null : index.current;
1669
+ // D-277: a surviving pointer is kept only when it names a surviving lane; a
1670
+ // sole survivor may become current implicitly, but with 2+ survivors the
1671
+ // pointer goes null until an explicit switch-session / marker selection.
1672
+ // This also repairs stale pointers that named an already-missing lane.
1673
+ const keptPointer =
1674
+ index.current && index.current !== sessionId && lanes.some((lane) => lane.id === index.current)
1675
+ ? index.current
1676
+ : null;
1677
+ const current = keptPointer ?? (lanes.length === 1 ? lanes[0].id : null);
824
1678
  if (lanes.length === 0) {
825
1679
  await rm(normalized.activeSessionsIndexPath, { force: true });
826
1680
  return {
@@ -876,19 +1730,6 @@ function parseActiveSession(wipContent, wipFilePath = 'sessions/active/<lane-id>
876
1730
  };
877
1731
  }
878
1732
 
879
- function validateLaneId(value) {
880
- const normalized = value.trim();
881
- if (!LANE_ID_PATTERN.test(normalized)) {
882
- throw new Error('Session lane ID must be a lowercase slug 3-64 characters long using letters, numbers, and hyphens.');
883
- }
884
-
885
- if (RESERVED_LANE_IDS.has(normalized)) {
886
- throw new Error(`Session lane ID "${normalized}" is reserved.`);
887
- }
888
-
889
- return normalized;
890
- }
891
-
892
1733
  function extractSectionBody(content, sectionTitle) {
893
1734
  const heading = `## ${sectionTitle}`.toLowerCase();
894
1735
  const lines = content.split('\n');
@@ -999,12 +1840,51 @@ function renderLaneMetadata(options) {
999
1840
  renderYamlArray('repos', options.repos),
1000
1841
  renderYamlArray('claimed_paths', options.claims),
1001
1842
  `started_at: ${formatLocalDateTime(new Date())}`,
1843
+ // D-282: the runtime assignment travels with the lane record (local-only
1844
+ // per D-278) so lane-env and close-time cleanup read it back.
1845
+ ...(options.runtime
1846
+ ? [
1847
+ 'runtime:',
1848
+ ` port: ${options.runtime.port}`,
1849
+ ` tmp_dir: ${quoteYamlString(options.runtime.tmpDir)}`,
1850
+ ]
1851
+ : []),
1852
+ ...(options.branch ? [`branch: ${quoteYamlString(options.branch)}`] : []),
1853
+ ...(options.worktreeContainer ? [`worktree_container: ${quoteYamlString(options.worktreeContainer)}`] : []),
1854
+ ...renderYamlBlockMapLines('worktrees', options.worktrees),
1855
+ ...renderYamlBlockMapLines('worktree_sources', options.worktreeSources),
1856
+ ...renderYamlBlockMapLines('base_revisions', options.baseRevisions),
1857
+ ...(options.laneMarker
1858
+ ? [
1859
+ 'lane_marker:',
1860
+ ` path: ${quoteYamlString(options.laneMarker.path)}`,
1861
+ ` token: ${quoteYamlString(options.laneMarker.token)}`,
1862
+ ` created_at: ${options.laneMarker.createdAt}`,
1863
+ ]
1864
+ : []),
1002
1865
  'decision_snapshot:',
1003
1866
  ` highest_decision_id: ${options.highestDecisionId ?? 'null'}`,
1004
1867
  '',
1005
1868
  ].join('\n');
1006
1869
  }
1007
1870
 
1871
+ /**
1872
+ * One-level block map of id keys to quoted string values, omitted entirely
1873
+ * when empty. Writers validate keys with isSimpleYamlKeySafe before recording
1874
+ * them — an unparseable key would null-degrade the whole lane for readers.
1875
+ */
1876
+ function renderYamlBlockMapLines(key, map) {
1877
+ const entries = map ? Object.entries(map).filter(([, value]) => value !== null && value !== undefined) : [];
1878
+ if (entries.length === 0) {
1879
+ return [];
1880
+ }
1881
+
1882
+ return [
1883
+ `${key}:`,
1884
+ ...entries.map(([mapKey, value]) => ` ${mapKey}: ${quoteYamlString(String(value))}`),
1885
+ ];
1886
+ }
1887
+
1008
1888
  function renderActiveSessionIndex(current, lanes) {
1009
1889
  const lines = [
1010
1890
  `current: ${current ?? 'null'}`,
@@ -1149,7 +2029,21 @@ function buildOverlapWarnings(options) {
1149
2029
  }
1150
2030
 
1151
2031
  const sharedRepos = intersect(options.repos, lane.repos ?? []);
1152
- 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
+ ) {
1153
2047
  warnings.push(
1154
2048
  `Active session lane "${options.sessionId}" shares repo(s) ${sharedRepos.join(', ')} with "${lane.id}" without path claims; add --claim values to clarify ownership.`,
1155
2049
  );
@@ -1159,6 +2053,37 @@ function buildOverlapWarnings(options) {
1159
2053
  return warnings;
1160
2054
  }
1161
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
+
1162
2087
  function collectLaneMetadataWarnings(lanes) {
1163
2088
  return lanes.flatMap((lane) => lane.warnings ?? []);
1164
2089
  }