@vibecompass/vibecompass 0.10.8 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
 
@@ -73,6 +160,71 @@ export async function startProjectSession(options) {
73
160
  existingLanes,
74
161
  });
75
162
  const highestDecisionId = await readHighestDecisionId(normalized.rootDir);
163
+ const projectConfig = await readProjectConfig(normalized.projectFilePath);
164
+ const baseRevisionCapture = await captureBaseRevisions({
165
+ workspaceDir: path.dirname(normalized.rootDir),
166
+ repoIds: repos,
167
+ projectRepos: Array.isArray(projectConfig?.repos) ? projectConfig.repos : [],
168
+ });
169
+
170
+ // D-282 per-lane runtime assignment, computed in preflight inside the
171
+ // memory-root lock so sibling lanes' recorded ports are stable while the
172
+ // lowest free slot is picked. Assignment is lane coordination on this root,
173
+ // not an OS-level reservation — no port is probed or bound here.
174
+ const runtimeSettings = resolveRuntimeSettings(projectConfig);
175
+ const laneRuntime = {
176
+ port: assignLanePort({
177
+ existingLanes,
178
+ portBase: runtimeSettings.portBase,
179
+ portStep: runtimeSettings.portStep,
180
+ }),
181
+ tmpDir: buildLaneTmpDir({
182
+ tmpBase: runtimeSettings.tmpBase,
183
+ rootKey: await computeLaneTmpRootKey(normalized.rootDir),
184
+ laneId: sessionId,
185
+ }),
186
+ };
187
+
188
+ // D-281 opt-in git binding: normalize + preflight before the first write so
189
+ // a refused binding leaves the root untouched.
190
+ if (options?.worktree && !options?.branch) {
191
+ throw new Error('--worktree requires --branch; a worktree checks out the lane branch.');
192
+ }
193
+ let gitBinding = null;
194
+ if (options?.branch) {
195
+ const branch = await normalizeBranchName(options.branch);
196
+ const workspaceDir = path.dirname(normalized.rootDir);
197
+ const containerDir = path.join(workspaceDir, 'worktrees', sessionId);
198
+ const useWorktree = options.worktree === true;
199
+ const preflight = await preflightGitBinding({
200
+ workspaceDir,
201
+ rootDir: normalized.rootDir,
202
+ branch,
203
+ worktree: useWorktree,
204
+ repoIds: repos,
205
+ projectRepos: Array.isArray(projectConfig?.repos) ? projectConfig.repos : [],
206
+ existingLanes,
207
+ containerDir,
208
+ });
209
+ gitBinding = {
210
+ branch,
211
+ containerDir: useWorktree ? containerDir : null,
212
+ repoPlans: preflight.repoPlans,
213
+ warnings: preflight.warnings,
214
+ marker: useWorktree
215
+ ? {
216
+ path: path.join(containerDir, LANE_MARKER_FILENAME),
217
+ token: randomUUID(),
218
+ createdAt: formatLocalDateTime(new Date()),
219
+ }
220
+ : null,
221
+ };
222
+ // Bound repos base on what the binding checks out (create: start point;
223
+ // reuse: existing branch tip), overriding the plain HEAD capture.
224
+ for (const plan of preflight.repoPlans) {
225
+ baseRevisionCapture.baseRevisions[plan.repoId] = plan.baseRevision;
226
+ }
227
+ }
76
228
 
77
229
  const nextSessionNumber = await getNextSessionNumber(normalized.sessionsDir, sessionDate);
78
230
  const currentSession = parseCurrentSessionBlock(claude.content, { optional: true });
@@ -87,6 +239,21 @@ export async function startProjectSession(options) {
87
239
  nextSessionShould:
88
240
  normalizeOptionalString(options?.nextSessionShould) ??
89
241
  'Finish the active session and close it with a finalized session note.',
242
+ }, {
243
+ lanes: [
244
+ ...existingLanes.map((lane) => ({ id: lane.id, workingOn: lane.workingOn })),
245
+ { id: sessionId, workingOn },
246
+ ],
247
+ selectedId: sessionId,
248
+ });
249
+
250
+ // D-282: the lane temp dir is created before the first root write, so a
251
+ // creation failure aborts start-session with the root untouched (empty
252
+ // parent namespace dirs left behind by a failure are benign).
253
+ await mkdir(laneRuntime.tmpDir, { recursive: true }).catch((error) => {
254
+ throw new Error(
255
+ `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.`,
256
+ );
90
257
  });
91
258
 
92
259
  await mkdir(normalized.activeSessionsDir, { recursive: true });
@@ -108,6 +275,25 @@ export async function startProjectSession(options) {
108
275
  features,
109
276
  repos,
110
277
  claims,
278
+ baseRevisions: baseRevisionCapture.baseRevisions,
279
+ runtime: laneRuntime,
280
+ // Record-before-create (D-281): the full binding plan — including the
281
+ // marker path/token — is in session.yaml before any git artifact or
282
+ // marker file exists, so a crash leaves only benign recorded-but-missing
283
+ // entries.
284
+ ...(gitBinding
285
+ ? {
286
+ branch: gitBinding.branch,
287
+ worktreeContainer: gitBinding.containerDir,
288
+ worktrees: gitBinding.containerDir
289
+ ? Object.fromEntries(gitBinding.repoPlans.map((plan) => [plan.repoId, plan.worktreePath]))
290
+ : null,
291
+ worktreeSources: gitBinding.containerDir
292
+ ? Object.fromEntries(gitBinding.repoPlans.map((plan) => [plan.repoId, plan.sourceDir]))
293
+ : null,
294
+ laneMarker: gitBinding.marker,
295
+ }
296
+ : {}),
111
297
  highestDecisionId,
112
298
  }),
113
299
  'utf8',
@@ -132,11 +318,57 @@ export async function startProjectSession(options) {
132
318
  }),
133
319
  'utf8',
134
320
  );
321
+ const priorIndexContent = await readFile(normalized.activeSessionsIndexPath, 'utf8').catch(() => null);
135
322
  await upsertActiveSessionIndex(normalized, {
136
323
  id: sessionId,
137
324
  status: 'active',
138
325
  workingOn,
139
326
  }, { current: sessionId });
327
+
328
+ if (gitBinding) {
329
+ const progress = { worktrees: [], createdBranches: [] };
330
+ try {
331
+ if (gitBinding.containerDir) {
332
+ await mkdir(gitBinding.containerDir, { recursive: true });
333
+ await writeFile(
334
+ gitBinding.marker.path,
335
+ renderLaneMarker({
336
+ laneId: sessionId,
337
+ memoryRoot: normalized.rootDir,
338
+ token: gitBinding.marker.token,
339
+ createdAt: gitBinding.marker.createdAt,
340
+ createdBy: PACKAGE_VERSION,
341
+ }),
342
+ 'utf8',
343
+ );
344
+ }
345
+ await provisionGitBinding({ branch: gitBinding.branch, repoPlans: gitBinding.repoPlans }, progress);
346
+ } catch (error) {
347
+ // D-281: start-session is atomic — unwind git artifacts created this
348
+ // call, then the lane files, CLAUDE.md, and index this call wrote.
349
+ const rollbackNotes = await rollbackGitBinding(progress, gitBinding.branch);
350
+ if (gitBinding.containerDir) {
351
+ await rm(gitBinding.containerDir, { recursive: true, force: true }).catch(() => {});
352
+ }
353
+ await rm(lanePaths.laneDir, { recursive: true, force: true }).catch(() => {});
354
+ // D-282: the lane temp dir was created this call; unwind it with the rest.
355
+ await rm(laneRuntime.tmpDir, { recursive: true, force: true }).catch(() => {});
356
+ await writeFile(normalized.claudePath, claude.content, 'utf8').catch(() => {});
357
+ if (priorIndexContent === null) {
358
+ await rm(normalized.activeSessionsIndexPath, { force: true }).catch(() => {});
359
+ } else {
360
+ await writeFile(normalized.activeSessionsIndexPath, priorIndexContent, 'utf8').catch(() => {});
361
+ }
362
+ await refreshStateManifestSafely(normalized.rootDir);
363
+ const message = error instanceof Error ? error.message : String(error);
364
+ throw new Error(
365
+ `${message}\nGit binding failed; start-session was rolled back and lane "${sessionId}" was not created.` +
366
+ (rollbackNotes.length > 0 ? `\nRollback notes: ${rollbackNotes.join('; ')}` : '') +
367
+ '\nIf a crash interrupts this rollback, run `vibecompass rebuild-active-index` and close the lane normally.',
368
+ );
369
+ }
370
+ }
371
+
140
372
  const manifestRefresh = await refreshStateManifestSafely(normalized.rootDir);
141
373
  const auditWarnings = await buildDocsReviewWarnings(normalized);
142
374
  const metadataWarnings = collectLaneMetadataWarnings(existingLanes);
@@ -155,19 +387,44 @@ export async function startProjectSession(options) {
155
387
  handoffFilePath: lanePaths.handoffFilePath,
156
388
  sessionDate,
157
389
  sessionNumber: nextSessionNumber,
390
+ runtime: laneRuntime,
158
391
  warnings: [
392
+ ...markerContext.warnings,
159
393
  ...metadataWarnings,
160
394
  ...overlapWarnings,
395
+ ...baseRevisionCapture.warnings,
396
+ ...runtimeSettings.warnings,
397
+ ...(gitBinding?.warnings ?? []),
161
398
  ...manifestRefresh.warnings,
162
399
  ...auditWarnings,
163
400
  ],
401
+ ...(gitBinding
402
+ ? {
403
+ gitBinding: {
404
+ branch: gitBinding.branch,
405
+ worktreeContainer: gitBinding.containerDir,
406
+ markerPath: gitBinding.marker?.path ?? null,
407
+ repos: gitBinding.repoPlans.map((plan) => ({
408
+ repoId: plan.repoId,
409
+ mode: plan.mode,
410
+ baseRevision: plan.baseRevision,
411
+ worktreePath: plan.worktreePath,
412
+ })),
413
+ },
414
+ }
415
+ : {}),
164
416
  manifest: manifestRefresh.manifest,
165
417
  agentFileSync,
166
418
  };
167
419
  }
168
420
 
169
421
  export async function closeProjectSession(options) {
170
- const normalized = normalizeSessionPaths(options);
422
+ const { normalized, markerContext } = await resolveSessionCommandContext(options, { needMarkerForLane: true });
423
+ return withMemoryRootLock(normalized.rootDir, 'close-session', () =>
424
+ closeProjectSessionLocked(normalized, options, markerContext));
425
+ }
426
+
427
+ async function closeProjectSessionLocked(normalized, options, markerContext) {
171
428
  const title = requireNonEmptyString(options?.title, 'session close requires a non-empty title.');
172
429
  const completed = normalizeStringArray(options?.completed);
173
430
  const models = normalizeStringArray(options?.models);
@@ -184,16 +441,42 @@ export async function closeProjectSession(options) {
184
441
  }
185
442
 
186
443
  await ensureInitializedProjectMemory(normalized.rootDir, { allowMissingProjectFile: true });
444
+
445
+ const duplicateDecisionIds = await findDuplicateDecisionIds(normalized.rootDir);
446
+ if (duplicateDecisionIds.length > 0) {
447
+ throw new Error(
448
+ `close-session is blocked: duplicate decision IDs detected — ${duplicateDecisionIds
449
+ .map((duplicate) => `${formatDecisionId(duplicate.id)} (${duplicate.occurrences.join(', ')})`)
450
+ .join('; ')}. Repair the canonical decision files by hand before closing (D-276); duplicates are never auto-renumbered.`,
451
+ );
452
+ }
453
+
187
454
  const claude = await readClaudeFile(normalized.claudePath);
188
455
  const projectConfig = await readProjectConfig(normalized.projectFilePath);
189
456
  const workflowSettings = resolveWorkflowSettings(projectConfig);
190
- const sessionId = await resolveExistingSessionId(normalized, options);
457
+ const laneSelection = await resolveExistingSessionId(normalized, options, markerContext, 'close');
458
+ const sessionId = laneSelection.sessionId;
191
459
  const lanePaths = sessionId ? getLanePaths(normalized, sessionId) : normalized;
460
+ const closingLaneMetadata = sessionId ? await readLaneMetadata(lanePaths.sessionFilePath) : null;
461
+ // Fail closed on a null-degraded lane parse, mirroring the write-lane-marker
462
+ // rebind guard: a degraded parse reads as "no worktrees recorded", so
463
+ // closing would destroy session.yaml — the only record of the worktree
464
+ // paths, sources, container, and marker token — while the worktrees (and
465
+ // any uncommitted work in them) survive unmanaged behind a permanently
466
+ // stale marker (D-281).
467
+ if (closingLaneMetadata && closingLaneMetadata.warnings.length > 0) {
468
+ throw new Error(
469
+ `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.`,
470
+ );
471
+ }
192
472
  const documentMaintenance = normalizeDocumentMaintenanceCheckpoint(options?.documentMaintenance);
193
473
  const docsUpdate = await planDocsUpdateSafely({
194
474
  rootDir: normalized.rootDir,
195
475
  cwd: normalized.cwd,
196
476
  sessionId,
477
+ // close-session already resolved (and warned about) the lane selection;
478
+ // the embedded plan must not repeat the flag-vs-marker warning.
479
+ suppressLaneWarnings: true,
197
480
  // Test injection seam; production callers should let close-session use the default planner.
198
481
  planner: options?.docsUpdatePlanner,
199
482
  });
@@ -227,7 +510,9 @@ export async function closeProjectSession(options) {
227
510
  const sessionFilePath = path.join(normalized.rootDir, sessionRelativePath);
228
511
 
229
512
  if (await fileExists(sessionFilePath)) {
230
- throw new Error(`Session note already exists at ${sessionFilePath}.`);
513
+ throw new Error(
514
+ `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.`,
515
+ );
231
516
  }
232
517
 
233
518
  await writeFile(
@@ -248,8 +533,86 @@ export async function closeProjectSession(options) {
248
533
  );
249
534
 
250
535
  const hadHandoff = await fileExists(lanePaths.handoffFilePath);
536
+ const markerCleanupWarnings = [];
251
537
  let activeSessionIndex = null;
538
+ let worktreeCleanup = null;
539
+ let runtimeCleanup = null;
252
540
  if (sessionId) {
541
+ // D-281 close-side guarded removal (S3-3): runs after the session note is
542
+ // written (a failed note write leaves everything intact) and before the
543
+ // lane records are destroyed. A crash between removal and the lane-dir
544
+ // delete leaves only benign recorded-but-missing entries.
545
+ const removal = closingLaneMetadata
546
+ ? await removeLaneWorktreesAtClose({
547
+ laneId: sessionId,
548
+ worktrees: closingLaneMetadata.worktrees,
549
+ worktreeSources: closingLaneMetadata.worktreeSources,
550
+ worktreeContainer: closingLaneMetadata.worktreeContainer,
551
+ recordedMarker: closingLaneMetadata.laneMarker,
552
+ cwd: normalized.cwd,
553
+ })
554
+ : { attempted: false, removed: [], surviving: [], warnings: [] };
555
+ markerCleanupWarnings.push(...removal.warnings);
556
+
557
+ let markerRemoved = false;
558
+ let markerKept = false;
559
+ let containerRemoved = false;
560
+ if (removal.surviving.length > 0) {
561
+ // D-281: keep the marker while any recorded worktree survives, so the
562
+ // leftover worktrees keep an actionable fail-closed breadcrumb instead
563
+ // of a silent wrong-root fallback. Only claim the marker "was kept"
564
+ // when it actually exists — removal may have been refused precisely
565
+ // because the marker is missing.
566
+ if (closingLaneMetadata?.laneMarker?.path && (await fileExists(closingLaneMetadata.laneMarker.path))) {
567
+ markerKept = true;
568
+ markerCleanupWarnings.push(
569
+ `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.`,
570
+ );
571
+ }
572
+ } else {
573
+ if (closingLaneMetadata?.laneMarker?.path) {
574
+ const cleanup = await removeRecordedLaneMarker(closingLaneMetadata.laneMarker);
575
+ markerRemoved = cleanup.removed;
576
+ markerCleanupWarnings.push(...cleanup.warnings);
577
+ }
578
+ if (removal.attempted && closingLaneMetadata?.worktreeContainer) {
579
+ // rmdir-if-empty: never recursive — a stray user file in the
580
+ // container keeps it in place with a note rather than being deleted.
581
+ const containerCleanup = await removeEmptyWorktreeContainer(closingLaneMetadata.worktreeContainer, normalized.cwd);
582
+ containerRemoved = containerCleanup.removed;
583
+ markerCleanupWarnings.push(...containerCleanup.warnings);
584
+ }
585
+ }
586
+ if (removal.attempted) {
587
+ worktreeCleanup = {
588
+ branch: closingLaneMetadata?.branch ?? null,
589
+ removed: removal.removed,
590
+ surviving: removal.surviving,
591
+ markerRemoved,
592
+ // Explicit kept status (reviewer pass): "kept" requires the marker
593
+ // file to exist — surviving worktrees with a missing marker must not
594
+ // render a phantom breadcrumb line.
595
+ markerKept,
596
+ containerRemoved,
597
+ };
598
+ }
599
+
600
+ // D-282 guarded lane temp-dir removal: runs before the lane record —
601
+ // the only pointer to the temp dir — is destroyed. The namespace is
602
+ // verified from the recorded path's own <root-key>/<lane-id> tail against
603
+ // the env-independent rootKey, never a close-time recompute of tmp_base
604
+ // (os.tmpdir() reads TMPDIR, which lane-env pollutes). Lanes without a
605
+ // recorded runtime block (pre-S4) close exactly as before.
606
+ if (closingLaneMetadata?.runtime?.tmpDir) {
607
+ runtimeCleanup = await removeLaneTmpDirAtClose({
608
+ recordedTmpDir: closingLaneMetadata.runtime.tmpDir,
609
+ laneId: sessionId,
610
+ rootKey: await computeLaneTmpRootKey(normalized.rootDir),
611
+ cwd: normalized.cwd,
612
+ });
613
+ markerCleanupWarnings.push(...runtimeCleanup.warnings);
614
+ }
615
+
253
616
  await rm(lanePaths.laneDir, { recursive: true, force: true });
254
617
  activeSessionIndex = await removeActiveSessionFromIndex(normalized, sessionId);
255
618
  } else {
@@ -260,20 +623,34 @@ export async function closeProjectSession(options) {
260
623
  }
261
624
 
262
625
  const closedSummary = `Closed session ${activeSession.sessionNumber} and wrote \`${sessionRelativePath}\`.`;
626
+ const survivingLanes = activeSessionIndex?.lanes ?? [];
627
+ const survivorListing = { lanes: survivingLanes.map((lane) => ({ id: lane.id, workingOn: lane.workingOn })), selectedId: activeSessionIndex?.current ?? null };
263
628
  const updatedClaude = replaceCurrentSessionBlock(
264
629
  claude.content,
265
630
  activeSessionIndex?.current
266
- ? buildCurrentSessionFieldsForLane(activeSessionIndex.lanes.find((lane) => lane.id === activeSessionIndex.current), {
631
+ ? buildCurrentSessionFieldsForLane(survivingLanes.find((lane) => lane.id === activeSessionIndex.current), {
267
632
  lastThingCompleted: normalizeOptionalString(options?.lastThingCompleted) ?? closedSummary,
268
633
  })
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
- },
634
+ : survivingLanes.length >= 2
635
+ ? {
636
+ date: `${activeSession.sessionDate} (session ${activeSession.sessionNumber})`,
637
+ workingOn:
638
+ 'Multiple lanes remain active. Select one explicitly with `vibecompass switch-session <lane-id>`, --session, or a worktree lane marker.',
639
+ lastThingCompleted: normalizeOptionalString(options?.lastThingCompleted) ?? closedSummary,
640
+ blockers: blockers.length > 0 ? summarizeList(blockers) : 'No blocker remains.',
641
+ nextSessionShould:
642
+ normalizeOptionalString(options?.nextSessionShould) ??
643
+ 'Pick the next lane explicitly (D-277: no implicit current-lane fallback while multiple lanes are active).',
644
+ }
645
+ : {
646
+ date: `${activeSession.sessionDate} (session ${activeSession.sessionNumber})`,
647
+ workingOn: 'Session closed. Ready for the next builder session.',
648
+ lastThingCompleted: normalizeOptionalString(options?.lastThingCompleted) ?? closedSummary,
649
+ blockers: blockers.length > 0 ? summarizeList(blockers) : 'No blocker remains.',
650
+ nextSessionShould:
651
+ normalizeOptionalString(options?.nextSessionShould) ?? summarizeOrderedList(nextSteps),
652
+ },
653
+ survivorListing,
277
654
  );
278
655
 
279
656
  await writeFile(normalized.claudePath, updatedClaude, 'utf8');
@@ -299,7 +676,16 @@ export async function closeProjectSession(options) {
299
676
  projectConfig,
300
677
  rootFlag: buildRootFlagForGuidance(normalized),
301
678
  }),
679
+ worktreeCleanup,
680
+ runtimeCleanup,
302
681
  warnings: [
682
+ ...markerContext.warnings,
683
+ ...laneSelection.warnings,
684
+ ...markerCleanupWarnings,
685
+ // A:192 (D-281): the pre-close staleness set computed by docs-update is
686
+ // re-emitted at close as warnings, so the closer sees it even when the
687
+ // embedded plan scrolls past.
688
+ ...collectStalenessWarnings(docsUpdate.plan),
303
689
  ...docsUpdate.warnings,
304
690
  ...manifestRefresh.warnings,
305
691
  ],
@@ -312,6 +698,62 @@ export async function closeProjectSession(options) {
312
698
  };
313
699
  }
314
700
 
701
+ /**
702
+ * D-281 rmdir-if-empty: after the worktrees and the container marker are
703
+ * gone, an empty container is removed non-recursively. Anything left inside
704
+ * (stray user files) keeps the container in place with a note — close-out
705
+ * never deletes content it did not create. The D-281 cwd clause applies here
706
+ * too: POSIX happily rmdirs the invoking process's own working directory,
707
+ * which would strand the caller's shell in a deleted path.
708
+ */
709
+ async function removeEmptyWorktreeContainer(containerDir, cwd) {
710
+ const realContainer = await canonicalizePathBestEffort(containerDir);
711
+ const realCwd = await canonicalizePathBestEffort(cwd ?? process.cwd());
712
+ const cwdRelative = path.relative(realContainer, realCwd);
713
+ if (cwdRelative === '' || (!cwdRelative.startsWith('..') && !path.isAbsolute(cwdRelative))) {
714
+ return {
715
+ removed: false,
716
+ warnings: [
717
+ `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.`,
718
+ ],
719
+ };
720
+ }
721
+
722
+ try {
723
+ await rmdir(containerDir);
724
+ return { removed: true, warnings: [] };
725
+ } catch (error) {
726
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
727
+ return { removed: false, warnings: [] };
728
+ }
729
+
730
+ return {
731
+ removed: false,
732
+ warnings: [
733
+ `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.`,
734
+ ],
735
+ };
736
+ }
737
+ }
738
+
739
+ async function canonicalizePathBestEffort(value) {
740
+ try {
741
+ return await realpath(value);
742
+ } catch {
743
+ return path.resolve(value);
744
+ }
745
+ }
746
+
747
+ /** Flattens the docs-update pre-close staleness set into close warnings (A:192 re-emission). */
748
+ function collectStalenessWarnings(plan) {
749
+ const staleness = plan?.staleness;
750
+ if (!staleness) {
751
+ return [];
752
+ }
753
+
754
+ return (staleness.entries ?? []).map((entry) => `Pre-close staleness: ${entry}`);
755
+ }
756
+
315
757
  function normalizeDocumentMaintenanceCheckpoint(value) {
316
758
  const source = value && typeof value === 'object' && !Array.isArray(value)
317
759
  ? value
@@ -352,21 +794,100 @@ function normalizeDocumentMaintenanceCheckpoint(value) {
352
794
  return checkpoint;
353
795
  }
354
796
 
797
+ /**
798
+ * Read-only lane runtime export (D-282): resolves the lane per D-277/D-280
799
+ * (explicit --session, else nearest worktree marker, else single active lane)
800
+ * and returns the recorded runtime assignment plus a ready-to-export env map.
801
+ * The conventional PORT/TMPDIR aliases let unmodified dev servers and tools
802
+ * pick the assignment up; pass conventionalAliases: false to omit them.
803
+ */
804
+ export async function readLaneEnvironment(options = {}) {
805
+ const { normalized, markerContext } = await resolveSessionCommandContext(options, { needMarkerForLane: true });
806
+ await ensureInitializedProjectMemory(normalized.rootDir, { allowMissingProjectFile: true });
807
+ const lanes = await listActiveSessionLanes(normalized);
808
+ const explicitId = normalizeOptionalString(options?.sessionId);
809
+ const selection = resolveLaneSelection({
810
+ explicitSessionId: explicitId ? validateLaneId(explicitId) : null,
811
+ marker: markerContext.marker,
812
+ laneIds: lanes.map((lane) => lane.id),
813
+ rootDir: normalized.rootDir,
814
+ purpose: 'export runtime env for',
815
+ });
816
+
817
+ if (!selection.sessionId) {
818
+ throw new Error('No active session lane exists. Run `vibecompass start-session` first (D-282: runtime assignments are per-lane).');
819
+ }
820
+
821
+ const lane = lanes.find((item) => item.id === selection.sessionId);
822
+ if (lane.warnings.length > 0) {
823
+ throw new Error(
824
+ `Lane "${selection.sessionId}"'s session.yaml could not be parsed (${lane.warnings[0]}); repair the lane metadata before exporting its runtime env.`,
825
+ );
826
+ }
827
+
828
+ if (!lane.runtime || !Number.isInteger(lane.runtime.port) || !lane.runtime.tmpDir) {
829
+ throw new Error(
830
+ `Lane "${selection.sessionId}" has no recorded runtime assignment (lanes started before D-282 have none). Close and re-open the lane to assign one.`,
831
+ );
832
+ }
833
+
834
+ const conventionalAliases = options.conventionalAliases !== false;
835
+ const env = {
836
+ VIBECOMPASS_LANE_ID: selection.sessionId,
837
+ VIBECOMPASS_LANE_PORT: String(lane.runtime.port),
838
+ VIBECOMPASS_LANE_TMPDIR: lane.runtime.tmpDir,
839
+ ...(conventionalAliases
840
+ ? {
841
+ PORT: String(lane.runtime.port),
842
+ TMPDIR: lane.runtime.tmpDir,
843
+ }
844
+ : {}),
845
+ };
846
+
847
+ return {
848
+ rootDir: normalized.rootDir,
849
+ sessionId: selection.sessionId,
850
+ port: lane.runtime.port,
851
+ tmpDir: lane.runtime.tmpDir,
852
+ env,
853
+ warnings: [...markerContext.warnings, ...selection.warnings],
854
+ };
855
+ }
856
+
355
857
  export async function listProjectSessions(options = {}) {
356
- const normalized = normalizeSessionPaths(options);
858
+ const { normalized } = await resolveSessionCommandContext(options);
357
859
  await ensureInitializedProjectMemory(normalized.rootDir, { allowMissingProjectFile: true });
358
860
  const index = await readActiveSessionIndex(normalized);
359
861
  const lanes = await listActiveSessionLanes(normalized);
360
862
 
361
863
  return {
362
864
  rootDir: normalized.rootDir,
363
- current: index.current ?? lanes[0]?.id ?? null,
865
+ current: resolveValidatedCurrentLane(index.current, lanes),
364
866
  lanes,
365
867
  };
366
868
  }
367
869
 
870
+ /**
871
+ * D-277: the index pointer is only reported as current when it names an
872
+ * existing active lane; a single active lane may be current implicitly, but
873
+ * with two or more lanes an invalid/missing pointer is never repaired by
874
+ * picking one — current becomes null until an explicit selection.
875
+ */
876
+ function resolveValidatedCurrentLane(pointer, lanes) {
877
+ if (pointer && lanes.some((lane) => lane.id === pointer)) {
878
+ return pointer;
879
+ }
880
+
881
+ return lanes.length === 1 ? lanes[0].id : null;
882
+ }
883
+
368
884
  export async function switchProjectSession(options = {}) {
369
- const normalized = normalizeSessionPaths(options);
885
+ const { normalized, markerContext } = await resolveSessionCommandContext(options);
886
+ return withMemoryRootLock(normalized.rootDir, 'switch-session', () =>
887
+ switchProjectSessionLocked(normalized, options, markerContext));
888
+ }
889
+
890
+ async function switchProjectSessionLocked(normalized, options, markerContext) {
370
891
  const sessionId = validateLaneId(requireNonEmptyString(options?.sessionId, 'switch-session requires a session ID.'));
371
892
  const lanes = await listActiveSessionLanes(normalized);
372
893
  const lane = lanes.find((item) => item.id === sessionId);
@@ -375,6 +896,16 @@ export async function switchProjectSession(options = {}) {
375
896
  throw new Error(`Active session lane "${sessionId}" does not exist.`);
376
897
  }
377
898
 
899
+ // D-280 disagreement warning: switching to a lane other than the one the
900
+ // enclosing marker binds proceeds, but names both lanes.
901
+ const selection = resolveLaneSelection({
902
+ explicitSessionId: sessionId,
903
+ marker: markerContext?.marker ?? null,
904
+ laneIds: lanes.map((item) => item.id),
905
+ rootDir: normalized.rootDir,
906
+ purpose: 'switch to',
907
+ });
908
+
378
909
  await upsertActiveSessionIndex(normalized, null, { current: sessionId });
379
910
  const claude = await readClaudeFile(normalized.claudePath);
380
911
  await writeFile(
@@ -384,6 +915,7 @@ export async function switchProjectSession(options = {}) {
384
915
  buildCurrentSessionFieldsForLane(lane, {
385
916
  lastThingCompleted: `Switched current lane to ${sessionId}.`,
386
917
  }),
918
+ { lanes: lanes.map((item) => ({ id: item.id, workingOn: item.workingOn })), selectedId: sessionId },
387
919
  ),
388
920
  'utf8',
389
921
  );
@@ -398,12 +930,244 @@ export async function switchProjectSession(options = {}) {
398
930
  claudePath: normalized.claudePath,
399
931
  current: sessionId,
400
932
  lanes,
401
- warnings: manifestRefresh.warnings,
933
+ warnings: [...markerContext.warnings, ...selection.warnings, ...manifestRefresh.warnings],
402
934
  manifest: manifestRefresh.manifest,
403
935
  agentFileSync,
404
936
  };
405
937
  }
406
938
 
939
+ /**
940
+ * Rebuilds `sessions/active/index.yaml` from the active lane directories plus
941
+ * an explicit current-lane selection (D-266/D-277 recovery path). The current
942
+ * pointer is never derived from enumeration alone: an explicit `current` wins,
943
+ * a still-valid existing pointer is preserved, and only a single surviving
944
+ * lane may become current implicitly.
945
+ */
946
+ export async function rebuildActiveSessionIndex(options = {}) {
947
+ const { normalized } = await resolveSessionCommandContext(options);
948
+ return withMemoryRootLock(normalized.rootDir, 'rebuild-active-index', async () => {
949
+ await ensureInitializedProjectMemory(normalized.rootDir, { allowMissingProjectFile: true });
950
+ const lanes = await listActiveSessionLanes(normalized);
951
+ const requested = options?.current == null || options.current === ''
952
+ ? null
953
+ : validateLaneId(requireNonEmptyString(options.current, 'rebuild-active-index requires a non-empty --current lane ID when provided.'));
954
+
955
+ if (requested && !lanes.some((lane) => lane.id === requested)) {
956
+ throw new Error(`Active session lane "${requested}" does not exist; cannot set it as the current lane.`);
957
+ }
958
+
959
+ if (lanes.length === 0) {
960
+ await rm(normalized.activeSessionsIndexPath, { force: true });
961
+ return {
962
+ rootDir: normalized.rootDir,
963
+ indexPath: normalized.activeSessionsIndexPath,
964
+ current: null,
965
+ lanes,
966
+ removed: true,
967
+ };
968
+ }
969
+
970
+ const existing = await readActiveSessionIndex(normalized);
971
+ const current =
972
+ requested ??
973
+ (lanes.some((lane) => lane.id === existing.current) ? existing.current : null) ??
974
+ (lanes.length === 1 ? lanes[0].id : null);
975
+
976
+ if (!current) {
977
+ throw new Error(
978
+ '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).',
979
+ );
980
+ }
981
+
982
+ await mkdir(normalized.activeSessionsDir, { recursive: true });
983
+ await writeFile(normalized.activeSessionsIndexPath, renderActiveSessionIndex(current, lanes), 'utf8');
984
+ return {
985
+ rootDir: normalized.rootDir,
986
+ indexPath: normalized.activeSessionsIndexPath,
987
+ current,
988
+ lanes,
989
+ removed: false,
990
+ };
991
+ });
992
+ }
993
+
994
+ /**
995
+ * Writes the worktree lane marker for an active lane (D-280). Markers are
996
+ * never written implicitly: this explicit command and S3 worktree
997
+ * provisioning are the only producers. The target must be path-disjoint from
998
+ * the memory root, and the lane's session.yaml records the marker under a
999
+ * `lane_marker:` block map so close-session and S3 removal can token-match.
1000
+ */
1001
+ export async function writeLaneMarkerForSession(options) {
1002
+ // needMarkerForLane: the enclosing marker is consulted even under an
1003
+ // explicit --root so the D-280 flag-vs-marker disagreement warning fires
1004
+ // when this command binds a different lane than the invoking context.
1005
+ const { normalized, markerContext } = await resolveSessionCommandContext(options, { needMarkerForLane: true });
1006
+ return withMemoryRootLock(normalized.rootDir, 'write-lane-marker', () =>
1007
+ writeLaneMarkerForSessionLocked(normalized, options, markerContext));
1008
+ }
1009
+
1010
+ async function writeLaneMarkerForSessionLocked(normalized, options, markerContext) {
1011
+ const sessionId = validateLaneId(
1012
+ requireNonEmptyString(options?.sessionId, 'write-lane-marker requires --session <lane-id> so the marker binds to an explicit lane.'),
1013
+ );
1014
+ await ensureInitializedProjectMemory(normalized.rootDir, { allowMissingProjectFile: true });
1015
+ const warnings = [...markerContext.warnings];
1016
+ const lanes = await listActiveSessionLanes(normalized);
1017
+ // Shared D-277/D-280 selection: validates the explicit lane is active and
1018
+ // emits the disagreement warning when an enclosing marker names another lane.
1019
+ const selection = resolveLaneSelection({
1020
+ explicitSessionId: sessionId,
1021
+ marker: markerContext.marker,
1022
+ laneIds: lanes.map((lane) => lane.id),
1023
+ rootDir: normalized.rootDir,
1024
+ purpose: 'bind the marker to',
1025
+ });
1026
+ warnings.push(...selection.warnings);
1027
+
1028
+ const lanePaths = getLanePaths(normalized, sessionId);
1029
+ if (!(await fileExists(lanePaths.sessionFilePath))) {
1030
+ throw new Error(
1031
+ `Lane "${sessionId}" has no session.yaml at ${lanePaths.sessionFilePath}; the marker cannot be recorded. Repair the lane (rebuild-active-index) before writing a marker.`,
1032
+ );
1033
+ }
1034
+
1035
+ const targetDir = path.resolve(normalized.cwd, options?.dir ?? '.');
1036
+ const targetStat = await stat(targetDir).catch(() => null);
1037
+ if (!targetStat) {
1038
+ throw new Error(
1039
+ `write-lane-marker target directory ${targetDir} does not exist. Create it first; markers are never provisioned implicitly (D-280).`,
1040
+ );
1041
+ }
1042
+ if (!targetStat.isDirectory()) {
1043
+ throw new Error(`write-lane-marker target ${targetDir} is not a directory.`);
1044
+ }
1045
+
1046
+ await assertMarkerTargetDisjoint(targetDir, normalized.rootDir);
1047
+ const gitDir = await findEnclosingGitDir(targetDir);
1048
+ if (gitDir) {
1049
+ warnings.push(
1050
+ `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.`,
1051
+ );
1052
+ }
1053
+
1054
+ const markerPath = path.join(targetDir, LANE_MARKER_FILENAME);
1055
+ if (await fileExists(markerPath)) {
1056
+ try {
1057
+ const existing = await readLaneMarker(markerPath);
1058
+ if (existing.laneId !== sessionId) {
1059
+ warnings.push(`Replaced the marker at ${markerPath} previously bound to lane "${existing.laneId}".`);
1060
+ }
1061
+ } catch {
1062
+ warnings.push(`Replaced an unreadable marker at ${markerPath}.`);
1063
+ }
1064
+ }
1065
+
1066
+ const laneMetadata = await readLaneMetadata(lanePaths.sessionFilePath);
1067
+ // Fail closed when the lane metadata is unreadable: a null-degraded parse
1068
+ // would hide a recorded worktree container and let a rebind orphan the
1069
+ // removal guard (D-281).
1070
+ if (laneMetadata.warnings.length > 0) {
1071
+ throw new Error(
1072
+ `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.`,
1073
+ );
1074
+ }
1075
+ // D-281: a provisioned lane's container marker is what authorizes guarded
1076
+ // worktree removal at close; rebinding would token-delete it and orphan
1077
+ // the worktrees permanently.
1078
+ if (laneMetadata.worktreeContainer) {
1079
+ throw new Error(
1080
+ `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.`,
1081
+ );
1082
+ }
1083
+ if (laneMetadata.laneMarker?.path && path.resolve(laneMetadata.laneMarker.path) !== markerPath) {
1084
+ const cleanup = await removeRecordedLaneMarker(laneMetadata.laneMarker);
1085
+ warnings.push(...cleanup.warnings);
1086
+ }
1087
+
1088
+ const token = randomUUID();
1089
+ const createdAt = formatLocalDateTime(new Date());
1090
+ const sessionYaml = await readFile(lanePaths.sessionFilePath, 'utf8');
1091
+ // Record before writing the marker file: a crash between the two leaves the
1092
+ // benign recorded-but-missing state (cleanup is a no-op) instead of an
1093
+ // unrecorded on-disk marker that nothing can remove or report.
1094
+ await writeFile(
1095
+ lanePaths.sessionFilePath,
1096
+ upsertLaneMarkerBlock(sessionYaml, { path: markerPath, token, createdAt }),
1097
+ 'utf8',
1098
+ );
1099
+ await writeFile(
1100
+ markerPath,
1101
+ renderLaneMarker({
1102
+ laneId: sessionId,
1103
+ memoryRoot: normalized.rootDir,
1104
+ token,
1105
+ createdAt,
1106
+ createdBy: PACKAGE_VERSION,
1107
+ }),
1108
+ 'utf8',
1109
+ );
1110
+
1111
+ return {
1112
+ rootDir: normalized.rootDir,
1113
+ sessionId,
1114
+ markerPath,
1115
+ token,
1116
+ warnings,
1117
+ };
1118
+ }
1119
+
1120
+ /**
1121
+ * Removes a lane's recorded marker file only when the on-disk token still
1122
+ * matches the recorded token (D-280 guarded removal); anything else is
1123
+ * reported and left in place.
1124
+ */
1125
+ async function removeRecordedLaneMarker(recorded) {
1126
+ const warnings = [];
1127
+ if (!recorded?.path) {
1128
+ return { removed: false, warnings };
1129
+ }
1130
+
1131
+ if (!(await fileExists(recorded.path))) {
1132
+ return { removed: false, warnings };
1133
+ }
1134
+
1135
+ try {
1136
+ const marker = await readLaneMarker(recorded.path);
1137
+ if (recorded.token && marker.token === recorded.token) {
1138
+ await rm(recorded.path, { force: true });
1139
+ return { removed: true, warnings };
1140
+ }
1141
+
1142
+ warnings.push(
1143
+ `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).`,
1144
+ );
1145
+ } catch (error) {
1146
+ warnings.push(
1147
+ `Lane marker at ${recorded.path} left in place: ${error instanceof Error ? error.message : String(error)}`,
1148
+ );
1149
+ }
1150
+
1151
+ return { removed: false, warnings };
1152
+ }
1153
+
1154
+ /** Replaces (or appends) the `lane_marker:` block map in a session.yaml body. */
1155
+ function upsertLaneMarkerBlock(content, marker) {
1156
+ // CRLF-tolerant to match parseSimpleYaml's /\r?\n/ splitting; a missed
1157
+ // strip here would append a duplicate key and void the whole lane metadata.
1158
+ const stripped = content.replace(/^lane_marker:\r?\n(?:[ \t]+.*\r?\n?)*/m, '');
1159
+ const base = stripped.endsWith('\n') ? stripped : `${stripped}\n`;
1160
+ const block = [
1161
+ 'lane_marker:',
1162
+ ` path: ${quoteYamlString(marker.path)}`,
1163
+ ` token: ${quoteYamlString(marker.token)}`,
1164
+ ` created_at: ${marker.createdAt}`,
1165
+ '',
1166
+ ].join('\n');
1167
+
1168
+ return `${base}${block}`;
1169
+ }
1170
+
407
1171
  async function refreshStateManifestSafely(rootDir) {
408
1172
  try {
409
1173
  const manifest = await writeStateManifest(rootDir);
@@ -429,6 +1193,7 @@ async function planDocsUpdateSafely(options) {
429
1193
  rootDir: options.rootDir,
430
1194
  cwd: options.cwd,
431
1195
  sessionId: options.sessionId,
1196
+ suppressLaneWarnings: options.suppressLaneWarnings === true,
432
1197
  }),
433
1198
  warnings: [],
434
1199
  };
@@ -588,9 +1353,9 @@ function parseCurrentSessionBlock(content, options = {}) {
588
1353
  };
589
1354
  }
590
1355
 
591
- function replaceCurrentSessionBlock(content, fields) {
1356
+ function replaceCurrentSessionBlock(content, fields, laneListing = null) {
592
1357
  const sessionFence = findCurrentSessionFence(content);
593
- const blockBody = renderCurrentSessionBlockBody(fields);
1358
+ const blockBody = renderCurrentSessionBlockBody(fields, laneListing);
594
1359
 
595
1360
  if (!sessionFence) {
596
1361
  return insertCurrentSessionBlock(content, blockBody);
@@ -599,14 +1364,32 @@ function replaceCurrentSessionBlock(content, fields) {
599
1364
  return `${content.slice(0, sessionFence.bodyStart)}${blockBody}${content.slice(sessionFence.bodyEnd)}`;
600
1365
  }
601
1366
 
602
- function renderCurrentSessionBlockBody(fields) {
603
- return [
604
- `Date: ${fields.date}`,
1367
+ /**
1368
+ * D-277: with two or more active lanes the block becomes a derived multi-lane
1369
+ * listing (one line per active lane plus the selected lane); with at most one
1370
+ * lane it keeps the exact legacy five-field shape. The five field labels stay
1371
+ * present in both shapes so parseCurrentSessionBlock/findCurrentSessionFence
1372
+ * keep working on either.
1373
+ */
1374
+ function renderCurrentSessionBlockBody(fields, laneListing = null) {
1375
+ const lines = [`Date: ${fields.date}`];
1376
+
1377
+ if (laneListing && Array.isArray(laneListing.lanes) && laneListing.lanes.length >= 2) {
1378
+ lines.push('Active lanes:');
1379
+ for (const lane of [...laneListing.lanes].sort((left, right) => left.id.localeCompare(right.id))) {
1380
+ const selectedSuffix = laneListing.selectedId === lane.id ? ' [selected]' : '';
1381
+ lines.push(`- ${lane.id} — ${lane.workingOn ?? 'No summary recorded'}${selectedSuffix}`);
1382
+ }
1383
+ }
1384
+
1385
+ lines.push(
605
1386
  `Working on: ${fields.workingOn}`,
606
1387
  `Last thing completed: ${fields.lastThingCompleted}`,
607
1388
  `Blockers: ${fields.blockers}`,
608
1389
  `Next session should: ${fields.nextSessionShould}`,
609
- ].join('\n');
1390
+ );
1391
+
1392
+ return lines.join('\n');
610
1393
  }
611
1394
 
612
1395
  function insertCurrentSessionBlock(content, blockBody) {
@@ -678,22 +1461,17 @@ async function resolveStartSessionId(normalized, options) {
678
1461
  throw new Error('start-session requires --id <lane-id> so each active lane has a meaningful name.');
679
1462
  }
680
1463
 
681
- async function resolveExistingSessionId(normalized, options) {
1464
+ async function resolveExistingSessionId(normalized, options, markerContext, purpose) {
682
1465
  const explicitId = normalizeOptionalString(options?.sessionId);
683
- if (explicitId) {
684
- return validateLaneId(explicitId);
685
- }
686
-
687
1466
  const lanes = await listActiveSessionLanes(normalized);
688
- if (lanes.length === 1) {
689
- return lanes[0].id;
690
- }
691
1467
 
692
- if (lanes.length > 1) {
693
- throw new Error('Multiple active session lanes exist. Pass --session to choose which lane to close.');
694
- }
695
-
696
- return null;
1468
+ return resolveLaneSelection({
1469
+ explicitSessionId: explicitId ? validateLaneId(explicitId) : null,
1470
+ marker: markerContext?.marker ?? null,
1471
+ laneIds: lanes.map((lane) => lane.id),
1472
+ rootDir: normalized.rootDir,
1473
+ purpose,
1474
+ });
697
1475
  }
698
1476
 
699
1477
  async function listActiveSessionLanes(normalized) {
@@ -718,6 +1496,12 @@ async function listActiveSessionLanes(normalized) {
718
1496
  claims: metadata.claims,
719
1497
  startedAt: metadata.startedAt,
720
1498
  decisionSnapshot: metadata.decisionSnapshot,
1499
+ branch: metadata.branch ?? null,
1500
+ worktreeContainer: metadata.worktreeContainer ?? null,
1501
+ worktrees: metadata.worktrees ?? {},
1502
+ worktreeSources: metadata.worktreeSources ?? {},
1503
+ baseRevisions: metadata.baseRevisions ?? {},
1504
+ runtime: metadata.runtime ?? null,
721
1505
  warnings: metadata.warnings,
722
1506
  sessionDate: metadata.sessionDate ?? wipSession?.sessionDate ?? null,
723
1507
  sessionNumber: metadata.sessionNumber ?? wipSession?.sessionNumber ?? 0,
@@ -756,6 +1540,24 @@ async function readLaneMetadata(sessionFilePath) {
756
1540
  decisionSnapshot: {
757
1541
  highestDecisionId: parseNullableNumber(data.decision_snapshot?.highest_decision_id),
758
1542
  },
1543
+ laneMarker: data.lane_marker && typeof data.lane_marker === 'object'
1544
+ ? {
1545
+ path: normalizeOptionalString(data.lane_marker.path),
1546
+ token: normalizeOptionalString(data.lane_marker.token),
1547
+ createdAt: normalizeOptionalString(data.lane_marker.created_at),
1548
+ }
1549
+ : null,
1550
+ branch: normalizeOptionalString(data.branch),
1551
+ worktreeContainer: normalizeOptionalString(data.worktree_container),
1552
+ worktrees: normalizeStringMap(data.worktrees),
1553
+ worktreeSources: normalizeStringMap(data.worktree_sources),
1554
+ baseRevisions: normalizeStringMap(data.base_revisions),
1555
+ runtime: data.runtime && typeof data.runtime === 'object' && !Array.isArray(data.runtime)
1556
+ ? {
1557
+ port: parseNullableNumber(data.runtime.port),
1558
+ tmpDir: normalizeOptionalString(data.runtime.tmp_dir),
1559
+ }
1560
+ : null,
759
1561
  sessionDate: normalizeOptionalString(data.session_date),
760
1562
  sessionNumber: typeof data.session_number === 'number' ? data.session_number : Number(data.session_number) || null,
761
1563
  warnings: [],
@@ -771,6 +1573,13 @@ async function readLaneMetadata(sessionFilePath) {
771
1573
  decisionSnapshot: {
772
1574
  highestDecisionId: null,
773
1575
  },
1576
+ laneMarker: null,
1577
+ branch: null,
1578
+ worktreeContainer: null,
1579
+ worktrees: {},
1580
+ worktreeSources: {},
1581
+ baseRevisions: {},
1582
+ runtime: null,
774
1583
  sessionDate: null,
775
1584
  sessionNumber: null,
776
1585
  warnings: [
@@ -780,6 +1589,27 @@ async function readLaneMetadata(sessionFilePath) {
780
1589
  }
781
1590
  }
782
1591
 
1592
+ /**
1593
+ * One-level block map of string values (worktrees, worktree_sources,
1594
+ * base_revisions). Unquoted all-digit values come back numerically coerced
1595
+ * from parseScalar — normalize them back to strings.
1596
+ */
1597
+ function normalizeStringMap(value) {
1598
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
1599
+ return {};
1600
+ }
1601
+
1602
+ const result = {};
1603
+ for (const [key, entry] of Object.entries(value)) {
1604
+ const normalized = normalizeOptionalString(typeof entry === 'number' ? String(entry) : entry);
1605
+ if (normalized) {
1606
+ result[key] = normalized;
1607
+ }
1608
+ }
1609
+
1610
+ return result;
1611
+ }
1612
+
783
1613
  async function readActiveSessionIndex(normalized) {
784
1614
  try {
785
1615
  const data = parseSimpleYaml(await readFile(normalized.activeSessionsIndexPath, 'utf8'), {
@@ -796,7 +1626,12 @@ async function readActiveSessionIndex(normalized) {
796
1626
  }
797
1627
 
798
1628
  async function upsertActiveSessionIndex(normalized, lane, options = {}) {
799
- const currentIndex = await readActiveSessionIndex(normalized);
1629
+ // D-277: callers must select `current` explicitly; the upsert never derives
1630
+ // a current lane from the lane being written or a stale existing pointer.
1631
+ if (!('current' in options)) {
1632
+ throw new Error('upsertActiveSessionIndex requires an explicit current-lane selection.');
1633
+ }
1634
+
800
1635
  const lanes = await listActiveSessionLanes(normalized);
801
1636
  const laneMap = new Map(lanes.map((item) => [item.id, item]));
802
1637
  if (lane) {
@@ -807,9 +1642,8 @@ async function upsertActiveSessionIndex(normalized, lane, options = {}) {
807
1642
  });
808
1643
  }
809
1644
 
810
- const current = options.current ?? currentIndex.current ?? lane?.id ?? null;
811
1645
  await mkdir(normalized.activeSessionsDir, { recursive: true });
812
- await writeFile(normalized.activeSessionsIndexPath, renderActiveSessionIndex(current, [...laneMap.values()]), 'utf8');
1646
+ await writeFile(normalized.activeSessionsIndexPath, renderActiveSessionIndex(options.current, [...laneMap.values()]), 'utf8');
813
1647
  }
814
1648
 
815
1649
  /**
@@ -820,7 +1654,15 @@ async function upsertActiveSessionIndex(normalized, lane, options = {}) {
820
1654
  async function removeActiveSessionFromIndex(normalized, sessionId) {
821
1655
  const index = await readActiveSessionIndex(normalized);
822
1656
  const lanes = (await listActiveSessionLanes(normalized)).filter((lane) => lane.id !== sessionId);
823
- const current = index.current === sessionId ? lanes[0]?.id ?? null : index.current;
1657
+ // D-277: a surviving pointer is kept only when it names a surviving lane; a
1658
+ // sole survivor may become current implicitly, but with 2+ survivors the
1659
+ // pointer goes null until an explicit switch-session / marker selection.
1660
+ // This also repairs stale pointers that named an already-missing lane.
1661
+ const keptPointer =
1662
+ index.current && index.current !== sessionId && lanes.some((lane) => lane.id === index.current)
1663
+ ? index.current
1664
+ : null;
1665
+ const current = keptPointer ?? (lanes.length === 1 ? lanes[0].id : null);
824
1666
  if (lanes.length === 0) {
825
1667
  await rm(normalized.activeSessionsIndexPath, { force: true });
826
1668
  return {
@@ -876,19 +1718,6 @@ function parseActiveSession(wipContent, wipFilePath = 'sessions/active/<lane-id>
876
1718
  };
877
1719
  }
878
1720
 
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
1721
  function extractSectionBody(content, sectionTitle) {
893
1722
  const heading = `## ${sectionTitle}`.toLowerCase();
894
1723
  const lines = content.split('\n');
@@ -999,12 +1828,51 @@ function renderLaneMetadata(options) {
999
1828
  renderYamlArray('repos', options.repos),
1000
1829
  renderYamlArray('claimed_paths', options.claims),
1001
1830
  `started_at: ${formatLocalDateTime(new Date())}`,
1831
+ // D-282: the runtime assignment travels with the lane record (local-only
1832
+ // per D-278) so lane-env and close-time cleanup read it back.
1833
+ ...(options.runtime
1834
+ ? [
1835
+ 'runtime:',
1836
+ ` port: ${options.runtime.port}`,
1837
+ ` tmp_dir: ${quoteYamlString(options.runtime.tmpDir)}`,
1838
+ ]
1839
+ : []),
1840
+ ...(options.branch ? [`branch: ${quoteYamlString(options.branch)}`] : []),
1841
+ ...(options.worktreeContainer ? [`worktree_container: ${quoteYamlString(options.worktreeContainer)}`] : []),
1842
+ ...renderYamlBlockMapLines('worktrees', options.worktrees),
1843
+ ...renderYamlBlockMapLines('worktree_sources', options.worktreeSources),
1844
+ ...renderYamlBlockMapLines('base_revisions', options.baseRevisions),
1845
+ ...(options.laneMarker
1846
+ ? [
1847
+ 'lane_marker:',
1848
+ ` path: ${quoteYamlString(options.laneMarker.path)}`,
1849
+ ` token: ${quoteYamlString(options.laneMarker.token)}`,
1850
+ ` created_at: ${options.laneMarker.createdAt}`,
1851
+ ]
1852
+ : []),
1002
1853
  'decision_snapshot:',
1003
1854
  ` highest_decision_id: ${options.highestDecisionId ?? 'null'}`,
1004
1855
  '',
1005
1856
  ].join('\n');
1006
1857
  }
1007
1858
 
1859
+ /**
1860
+ * One-level block map of id keys to quoted string values, omitted entirely
1861
+ * when empty. Writers validate keys with isSimpleYamlKeySafe before recording
1862
+ * them — an unparseable key would null-degrade the whole lane for readers.
1863
+ */
1864
+ function renderYamlBlockMapLines(key, map) {
1865
+ const entries = map ? Object.entries(map).filter(([, value]) => value !== null && value !== undefined) : [];
1866
+ if (entries.length === 0) {
1867
+ return [];
1868
+ }
1869
+
1870
+ return [
1871
+ `${key}:`,
1872
+ ...entries.map(([mapKey, value]) => ` ${mapKey}: ${quoteYamlString(String(value))}`),
1873
+ ];
1874
+ }
1875
+
1008
1876
  function renderActiveSessionIndex(current, lanes) {
1009
1877
  const lines = [
1010
1878
  `current: ${current ?? 'null'}`,