@vibecompass/vibecompass 0.10.7 → 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,15 +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
- const workflowSettings = await readWorkflowSettings(normalized.projectFilePath);
189
- const sessionId = await resolveExistingSessionId(normalized, options);
455
+ const projectConfig = await readProjectConfig(normalized.projectFilePath);
456
+ const workflowSettings = resolveWorkflowSettings(projectConfig);
457
+ const laneSelection = await resolveExistingSessionId(normalized, options, markerContext, 'close');
458
+ const sessionId = laneSelection.sessionId;
190
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
+ }
191
472
  const documentMaintenance = normalizeDocumentMaintenanceCheckpoint(options?.documentMaintenance);
192
473
  const docsUpdate = await planDocsUpdateSafely({
193
474
  rootDir: normalized.rootDir,
194
475
  cwd: normalized.cwd,
195
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,
196
480
  // Test injection seam; production callers should let close-session use the default planner.
197
481
  planner: options?.docsUpdatePlanner,
198
482
  });
@@ -226,7 +510,9 @@ export async function closeProjectSession(options) {
226
510
  const sessionFilePath = path.join(normalized.rootDir, sessionRelativePath);
227
511
 
228
512
  if (await fileExists(sessionFilePath)) {
229
- 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
+ );
230
516
  }
231
517
 
232
518
  await writeFile(
@@ -247,8 +533,86 @@ export async function closeProjectSession(options) {
247
533
  );
248
534
 
249
535
  const hadHandoff = await fileExists(lanePaths.handoffFilePath);
536
+ const markerCleanupWarnings = [];
250
537
  let activeSessionIndex = null;
538
+ let worktreeCleanup = null;
539
+ let runtimeCleanup = null;
251
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
+
252
616
  await rm(lanePaths.laneDir, { recursive: true, force: true });
253
617
  activeSessionIndex = await removeActiveSessionFromIndex(normalized, sessionId);
254
618
  } else {
@@ -259,20 +623,34 @@ export async function closeProjectSession(options) {
259
623
  }
260
624
 
261
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 };
262
628
  const updatedClaude = replaceCurrentSessionBlock(
263
629
  claude.content,
264
630
  activeSessionIndex?.current
265
- ? buildCurrentSessionFieldsForLane(activeSessionIndex.lanes.find((lane) => lane.id === activeSessionIndex.current), {
631
+ ? buildCurrentSessionFieldsForLane(survivingLanes.find((lane) => lane.id === activeSessionIndex.current), {
266
632
  lastThingCompleted: normalizeOptionalString(options?.lastThingCompleted) ?? closedSummary,
267
633
  })
268
- : {
269
- date: `${activeSession.sessionDate} (session ${activeSession.sessionNumber})`,
270
- workingOn: 'Session closed. Ready for the next builder session.',
271
- lastThingCompleted: normalizeOptionalString(options?.lastThingCompleted) ?? closedSummary,
272
- blockers: blockers.length > 0 ? summarizeList(blockers) : 'No blocker remains.',
273
- nextSessionShould:
274
- normalizeOptionalString(options?.nextSessionShould) ?? summarizeOrderedList(nextSteps),
275
- },
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,
276
654
  );
277
655
 
278
656
  await writeFile(normalized.claudePath, updatedClaude, 'utf8');
@@ -294,8 +672,20 @@ export async function closeProjectSession(options) {
294
672
  sessionNumber: activeSession.sessionNumber,
295
673
  documentMaintenance,
296
674
  docsUpdatePlan: docsUpdate.plan,
297
- workflowGuidance: buildCloseSessionGuidance(workflowSettings),
675
+ workflowGuidance: buildCloseSessionGuidance(workflowSettings, {
676
+ projectConfig,
677
+ rootFlag: buildRootFlagForGuidance(normalized),
678
+ }),
679
+ worktreeCleanup,
680
+ runtimeCleanup,
298
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),
299
689
  ...docsUpdate.warnings,
300
690
  ...manifestRefresh.warnings,
301
691
  ],
@@ -308,6 +698,62 @@ export async function closeProjectSession(options) {
308
698
  };
309
699
  }
310
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
+
311
757
  function normalizeDocumentMaintenanceCheckpoint(value) {
312
758
  const source = value && typeof value === 'object' && !Array.isArray(value)
313
759
  ? value
@@ -348,21 +794,100 @@ function normalizeDocumentMaintenanceCheckpoint(value) {
348
794
  return checkpoint;
349
795
  }
350
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
+
351
857
  export async function listProjectSessions(options = {}) {
352
- const normalized = normalizeSessionPaths(options);
858
+ const { normalized } = await resolveSessionCommandContext(options);
353
859
  await ensureInitializedProjectMemory(normalized.rootDir, { allowMissingProjectFile: true });
354
860
  const index = await readActiveSessionIndex(normalized);
355
861
  const lanes = await listActiveSessionLanes(normalized);
356
862
 
357
863
  return {
358
864
  rootDir: normalized.rootDir,
359
- current: index.current ?? lanes[0]?.id ?? null,
865
+ current: resolveValidatedCurrentLane(index.current, lanes),
360
866
  lanes,
361
867
  };
362
868
  }
363
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
+
364
884
  export async function switchProjectSession(options = {}) {
365
- 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) {
366
891
  const sessionId = validateLaneId(requireNonEmptyString(options?.sessionId, 'switch-session requires a session ID.'));
367
892
  const lanes = await listActiveSessionLanes(normalized);
368
893
  const lane = lanes.find((item) => item.id === sessionId);
@@ -371,6 +896,16 @@ export async function switchProjectSession(options = {}) {
371
896
  throw new Error(`Active session lane "${sessionId}" does not exist.`);
372
897
  }
373
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
+
374
909
  await upsertActiveSessionIndex(normalized, null, { current: sessionId });
375
910
  const claude = await readClaudeFile(normalized.claudePath);
376
911
  await writeFile(
@@ -380,6 +915,7 @@ export async function switchProjectSession(options = {}) {
380
915
  buildCurrentSessionFieldsForLane(lane, {
381
916
  lastThingCompleted: `Switched current lane to ${sessionId}.`,
382
917
  }),
918
+ { lanes: lanes.map((item) => ({ id: item.id, workingOn: item.workingOn })), selectedId: sessionId },
383
919
  ),
384
920
  'utf8',
385
921
  );
@@ -394,12 +930,244 @@ export async function switchProjectSession(options = {}) {
394
930
  claudePath: normalized.claudePath,
395
931
  current: sessionId,
396
932
  lanes,
397
- warnings: manifestRefresh.warnings,
933
+ warnings: [...markerContext.warnings, ...selection.warnings, ...manifestRefresh.warnings],
398
934
  manifest: manifestRefresh.manifest,
399
935
  agentFileSync,
400
936
  };
401
937
  }
402
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
+
403
1171
  async function refreshStateManifestSafely(rootDir) {
404
1172
  try {
405
1173
  const manifest = await writeStateManifest(rootDir);
@@ -425,6 +1193,7 @@ async function planDocsUpdateSafely(options) {
425
1193
  rootDir: options.rootDir,
426
1194
  cwd: options.cwd,
427
1195
  sessionId: options.sessionId,
1196
+ suppressLaneWarnings: options.suppressLaneWarnings === true,
428
1197
  }),
429
1198
  warnings: [],
430
1199
  };
@@ -527,16 +1296,19 @@ async function ensureInitializedProjectMemory(rootDir, options = {}) {
527
1296
  }
528
1297
  }
529
1298
 
530
- async function readWorkflowSettings(projectFilePath) {
1299
+ async function readProjectConfig(projectFilePath) {
531
1300
  try {
532
1301
  const source = await readFile(projectFilePath, 'utf8');
533
- const data = parseSimpleYaml(source, { sourceName: projectFilePath });
534
- return resolveWorkflowSettings(data);
1302
+ return parseSimpleYaml(source, { sourceName: projectFilePath });
535
1303
  } catch {
536
- return resolveWorkflowSettings(null);
1304
+ return null;
537
1305
  }
538
1306
  }
539
1307
 
1308
+ function buildRootFlagForGuidance(normalized) {
1309
+ return `--root ${toPosix(path.relative(normalized.cwd, normalized.rootDir) || '.')}`;
1310
+ }
1311
+
540
1312
  async function readClaudeFile(claudePath) {
541
1313
  if (!(await fileExists(claudePath))) {
542
1314
  throw new Error(
@@ -581,9 +1353,9 @@ function parseCurrentSessionBlock(content, options = {}) {
581
1353
  };
582
1354
  }
583
1355
 
584
- function replaceCurrentSessionBlock(content, fields) {
1356
+ function replaceCurrentSessionBlock(content, fields, laneListing = null) {
585
1357
  const sessionFence = findCurrentSessionFence(content);
586
- const blockBody = renderCurrentSessionBlockBody(fields);
1358
+ const blockBody = renderCurrentSessionBlockBody(fields, laneListing);
587
1359
 
588
1360
  if (!sessionFence) {
589
1361
  return insertCurrentSessionBlock(content, blockBody);
@@ -592,14 +1364,32 @@ function replaceCurrentSessionBlock(content, fields) {
592
1364
  return `${content.slice(0, sessionFence.bodyStart)}${blockBody}${content.slice(sessionFence.bodyEnd)}`;
593
1365
  }
594
1366
 
595
- function renderCurrentSessionBlockBody(fields) {
596
- return [
597
- `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(
598
1386
  `Working on: ${fields.workingOn}`,
599
1387
  `Last thing completed: ${fields.lastThingCompleted}`,
600
1388
  `Blockers: ${fields.blockers}`,
601
1389
  `Next session should: ${fields.nextSessionShould}`,
602
- ].join('\n');
1390
+ );
1391
+
1392
+ return lines.join('\n');
603
1393
  }
604
1394
 
605
1395
  function insertCurrentSessionBlock(content, blockBody) {
@@ -671,22 +1461,17 @@ async function resolveStartSessionId(normalized, options) {
671
1461
  throw new Error('start-session requires --id <lane-id> so each active lane has a meaningful name.');
672
1462
  }
673
1463
 
674
- async function resolveExistingSessionId(normalized, options) {
1464
+ async function resolveExistingSessionId(normalized, options, markerContext, purpose) {
675
1465
  const explicitId = normalizeOptionalString(options?.sessionId);
676
- if (explicitId) {
677
- return validateLaneId(explicitId);
678
- }
679
-
680
1466
  const lanes = await listActiveSessionLanes(normalized);
681
- if (lanes.length === 1) {
682
- return lanes[0].id;
683
- }
684
1467
 
685
- if (lanes.length > 1) {
686
- throw new Error('Multiple active session lanes exist. Pass --session to choose which lane to close.');
687
- }
688
-
689
- 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
+ });
690
1475
  }
691
1476
 
692
1477
  async function listActiveSessionLanes(normalized) {
@@ -711,6 +1496,12 @@ async function listActiveSessionLanes(normalized) {
711
1496
  claims: metadata.claims,
712
1497
  startedAt: metadata.startedAt,
713
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,
714
1505
  warnings: metadata.warnings,
715
1506
  sessionDate: metadata.sessionDate ?? wipSession?.sessionDate ?? null,
716
1507
  sessionNumber: metadata.sessionNumber ?? wipSession?.sessionNumber ?? 0,
@@ -749,6 +1540,24 @@ async function readLaneMetadata(sessionFilePath) {
749
1540
  decisionSnapshot: {
750
1541
  highestDecisionId: parseNullableNumber(data.decision_snapshot?.highest_decision_id),
751
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,
752
1561
  sessionDate: normalizeOptionalString(data.session_date),
753
1562
  sessionNumber: typeof data.session_number === 'number' ? data.session_number : Number(data.session_number) || null,
754
1563
  warnings: [],
@@ -764,6 +1573,13 @@ async function readLaneMetadata(sessionFilePath) {
764
1573
  decisionSnapshot: {
765
1574
  highestDecisionId: null,
766
1575
  },
1576
+ laneMarker: null,
1577
+ branch: null,
1578
+ worktreeContainer: null,
1579
+ worktrees: {},
1580
+ worktreeSources: {},
1581
+ baseRevisions: {},
1582
+ runtime: null,
767
1583
  sessionDate: null,
768
1584
  sessionNumber: null,
769
1585
  warnings: [
@@ -773,6 +1589,27 @@ async function readLaneMetadata(sessionFilePath) {
773
1589
  }
774
1590
  }
775
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
+
776
1613
  async function readActiveSessionIndex(normalized) {
777
1614
  try {
778
1615
  const data = parseSimpleYaml(await readFile(normalized.activeSessionsIndexPath, 'utf8'), {
@@ -789,7 +1626,12 @@ async function readActiveSessionIndex(normalized) {
789
1626
  }
790
1627
 
791
1628
  async function upsertActiveSessionIndex(normalized, lane, options = {}) {
792
- 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
+
793
1635
  const lanes = await listActiveSessionLanes(normalized);
794
1636
  const laneMap = new Map(lanes.map((item) => [item.id, item]));
795
1637
  if (lane) {
@@ -800,9 +1642,8 @@ async function upsertActiveSessionIndex(normalized, lane, options = {}) {
800
1642
  });
801
1643
  }
802
1644
 
803
- const current = options.current ?? currentIndex.current ?? lane?.id ?? null;
804
1645
  await mkdir(normalized.activeSessionsDir, { recursive: true });
805
- await writeFile(normalized.activeSessionsIndexPath, renderActiveSessionIndex(current, [...laneMap.values()]), 'utf8');
1646
+ await writeFile(normalized.activeSessionsIndexPath, renderActiveSessionIndex(options.current, [...laneMap.values()]), 'utf8');
806
1647
  }
807
1648
 
808
1649
  /**
@@ -813,7 +1654,15 @@ async function upsertActiveSessionIndex(normalized, lane, options = {}) {
813
1654
  async function removeActiveSessionFromIndex(normalized, sessionId) {
814
1655
  const index = await readActiveSessionIndex(normalized);
815
1656
  const lanes = (await listActiveSessionLanes(normalized)).filter((lane) => lane.id !== sessionId);
816
- 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);
817
1666
  if (lanes.length === 0) {
818
1667
  await rm(normalized.activeSessionsIndexPath, { force: true });
819
1668
  return {
@@ -869,19 +1718,6 @@ function parseActiveSession(wipContent, wipFilePath = 'sessions/active/<lane-id>
869
1718
  };
870
1719
  }
871
1720
 
872
- function validateLaneId(value) {
873
- const normalized = value.trim();
874
- if (!LANE_ID_PATTERN.test(normalized)) {
875
- throw new Error('Session lane ID must be a lowercase slug 3-64 characters long using letters, numbers, and hyphens.');
876
- }
877
-
878
- if (RESERVED_LANE_IDS.has(normalized)) {
879
- throw new Error(`Session lane ID "${normalized}" is reserved.`);
880
- }
881
-
882
- return normalized;
883
- }
884
-
885
1721
  function extractSectionBody(content, sectionTitle) {
886
1722
  const heading = `## ${sectionTitle}`.toLowerCase();
887
1723
  const lines = content.split('\n');
@@ -992,12 +1828,51 @@ function renderLaneMetadata(options) {
992
1828
  renderYamlArray('repos', options.repos),
993
1829
  renderYamlArray('claimed_paths', options.claims),
994
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
+ : []),
995
1853
  'decision_snapshot:',
996
1854
  ` highest_decision_id: ${options.highestDecisionId ?? 'null'}`,
997
1855
  '',
998
1856
  ].join('\n');
999
1857
  }
1000
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
+
1001
1876
  function renderActiveSessionIndex(current, lanes) {
1002
1877
  const lines = [
1003
1878
  `current: ${current ?? 'null'}`,