@vibecompass/vibecompass 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/session.js CHANGED
@@ -7,6 +7,25 @@ import { syncAgentInstructionFiles } from './generators/agent-files/index.js';
7
7
  const CURRENT_SESSION_REQUIRED_FIELDS = ['Date:', 'Working on:', 'Last thing completed:', 'Blockers:', 'Next session should:'];
8
8
  const WIP_HEADER_PATTERN = /^# WIP — (\d{4}-\d{2}-\d{2}) \(session (\d+)\)$/m;
9
9
  const SESSION_FILENAME_PATTERN = /^(\d{4}-\d{2}-\d{2})-(\d+)-([a-z0-9-]+)\.md$/i;
10
+ const LANE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/;
11
+ const RESERVED_LANE_IDS = new Set([
12
+ 'active',
13
+ 'current',
14
+ 'default',
15
+ 'false',
16
+ 'handoff',
17
+ 'index',
18
+ 'new',
19
+ 'no',
20
+ 'null',
21
+ 'off',
22
+ 'on',
23
+ 'sessions',
24
+ 'state',
25
+ 'true',
26
+ 'wip',
27
+ 'yes',
28
+ ]);
10
29
 
11
30
  export async function startProjectSession(options) {
12
31
  const normalized = normalizeSessionPaths(options);
@@ -15,18 +34,14 @@ export async function startProjectSession(options) {
15
34
 
16
35
  await ensureInitializedProjectMemory(normalized.rootDir);
17
36
  const claude = await readClaudeFile(normalized.claudePath);
18
-
19
- if ((await fileExists(normalized.wipFilePath)) || (await fileExists(normalized.handoffFilePath))) {
20
- throw new Error(
21
- `An active session scratch file already exists under ${normalized.sessionsDir}. Close or recover the existing session before starting a new one.`,
22
- );
23
- }
37
+ const sessionId = await resolveStartSessionId(normalized, options);
38
+ const lanePaths = getLanePaths(normalized, sessionId);
24
39
 
25
40
  const nextSessionNumber = await getNextSessionNumber(normalized.sessionsDir, sessionDate);
26
41
  const currentSession = parseCurrentSessionBlock(claude.content);
27
42
  const updatedClaude = replaceCurrentSessionBlock(claude.content, {
28
- date: `${sessionDate} (session ${nextSessionNumber})`,
29
- workingOn,
43
+ date: `${sessionDate} (session ${nextSessionNumber}, lane ${sessionId})`,
44
+ workingOn: `${workingOn} [${sessionId}]`,
30
45
  lastThingCompleted:
31
46
  normalizeOptionalString(options?.lastThingCompleted) ??
32
47
  currentSession.lastThingCompleted ??
@@ -37,41 +52,75 @@ export async function startProjectSession(options) {
37
52
  'Finish the active session and close it with a finalized session note.',
38
53
  });
39
54
 
40
- await mkdir(normalized.sessionsDir, { recursive: true });
55
+ await mkdir(normalized.activeSessionsDir, { recursive: true });
56
+ await mkdir(lanePaths.laneDir, { recursive: false }).catch((error) => {
57
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'EEXIST') {
58
+ throw new Error(`Active session lane "${sessionId}" already exists. Choose a different --id or close that lane first.`);
59
+ }
60
+
61
+ throw error;
62
+ });
41
63
  await writeFile(normalized.claudePath, updatedClaude, 'utf8');
42
64
  await writeFile(
43
- normalized.wipFilePath,
65
+ lanePaths.sessionFilePath,
66
+ renderLaneMetadata({
67
+ sessionId,
68
+ sessionDate,
69
+ sessionNumber: nextSessionNumber,
70
+ workingOn,
71
+ features: normalizeStringArray(options?.features),
72
+ repos: normalizeStringArray(options?.repos),
73
+ claims: normalizeStringArray(options?.claims),
74
+ }),
75
+ 'utf8',
76
+ );
77
+ await writeFile(
78
+ lanePaths.wipFilePath,
44
79
  renderWipTemplate({
45
80
  sessionDate,
46
81
  sessionNumber: nextSessionNumber,
47
82
  workingOn,
83
+ sessionId,
48
84
  }),
49
85
  'utf8',
50
86
  );
51
87
  await writeFile(
52
- normalized.handoffFilePath,
88
+ lanePaths.handoffFilePath,
53
89
  renderHandoffTemplate({
54
90
  sessionDate,
55
91
  sessionNumber: nextSessionNumber,
56
92
  workingOn,
93
+ sessionId,
57
94
  }),
58
95
  'utf8',
59
96
  );
97
+ await upsertActiveSessionIndex(normalized, {
98
+ id: sessionId,
99
+ status: 'active',
100
+ workingOn,
101
+ }, { current: sessionId });
102
+ const agentFileSync = await syncAgentInstructionFilesSafely({
103
+ rootDir: normalized.rootDir,
104
+ toolingRootDir: normalized.toolingRootDir,
105
+ });
60
106
 
61
107
  return {
62
108
  rootDir: normalized.rootDir,
63
109
  toolingRootDir: normalized.toolingRootDir,
64
110
  claudePath: normalized.claudePath,
65
- wipFilePath: normalized.wipFilePath,
66
- handoffFilePath: normalized.handoffFilePath,
111
+ sessionId,
112
+ sessionFilePath: lanePaths.sessionFilePath,
113
+ wipFilePath: lanePaths.wipFilePath,
114
+ handoffFilePath: lanePaths.handoffFilePath,
67
115
  sessionDate,
68
116
  sessionNumber: nextSessionNumber,
117
+ agentFileSync,
69
118
  };
70
119
  }
71
120
 
72
121
  export async function closeProjectSession(options) {
73
122
  const normalized = normalizeSessionPaths(options);
74
- const title = requireNonEmptyString(options?.title, 'close-session requires a non-empty title.');
123
+ const title = requireNonEmptyString(options?.title, 'session close requires a non-empty title.');
75
124
  const completed = normalizeStringArray(options?.completed);
76
125
  const models = normalizeStringArray(options?.models);
77
126
  const nextSteps = normalizeStringArray(options?.nextSteps);
@@ -79,25 +128,27 @@ export async function closeProjectSession(options) {
79
128
  const blockers = normalizeStringArray(options?.blockers);
80
129
 
81
130
  if (completed.length === 0) {
82
- throw new Error('close-session requires at least one completed item.');
131
+ throw new Error('session close requires at least one completed item.');
83
132
  }
84
133
 
85
134
  if (nextSteps.length === 0) {
86
- throw new Error('close-session requires at least one next-step entry.');
135
+ throw new Error('session close requires at least one next-step entry.');
87
136
  }
88
137
 
89
138
  await ensureInitializedProjectMemory(normalized.rootDir, { allowMissingProjectFile: true });
90
139
  const claude = await readClaudeFile(normalized.claudePath);
91
140
  const workflowSettings = await readWorkflowSettings(normalized.projectFilePath);
141
+ const sessionId = await resolveExistingSessionId(normalized, options);
142
+ const lanePaths = sessionId ? getLanePaths(normalized, sessionId) : normalized;
92
143
 
93
- if (!(await fileExists(normalized.wipFilePath))) {
144
+ if (!(await fileExists(lanePaths.wipFilePath))) {
94
145
  throw new Error(
95
- `No active session scratchpad exists at ${normalized.wipFilePath}. Start a session before trying to close it.`,
146
+ `No active session scratchpad exists at ${lanePaths.wipFilePath}. Start a session before trying to close it.`,
96
147
  );
97
148
  }
98
149
 
99
- const wipContent = await readFile(normalized.wipFilePath, 'utf8');
100
- const activeSession = parseActiveSession(wipContent);
150
+ const wipContent = await readFile(lanePaths.wipFilePath, 'utf8');
151
+ const activeSession = parseActiveSession(wipContent, lanePaths.wipFilePath);
101
152
  const currentSession = parseCurrentSessionBlock(claude.content);
102
153
  const workedOn =
103
154
  normalizeOptionalString(options?.workedOn) ??
@@ -105,12 +156,12 @@ export async function closeProjectSession(options) {
105
156
  currentSession.workingOn;
106
157
 
107
158
  if (!workedOn) {
108
- throw new Error('close-session could not determine what the session worked on. Pass workedOn explicitly.');
159
+ throw new Error('session close could not determine what the session worked on. Pass workedOn explicitly.');
109
160
  }
110
161
 
111
162
  const sessionSlug = slugifyTitle(title);
112
163
  if (!sessionSlug) {
113
- throw new Error('close-session title must contain at least one letter or number.');
164
+ throw new Error('session close title must contain at least one letter or number.');
114
165
  }
115
166
 
116
167
  const sessionRelativePath = toPosix(
@@ -138,22 +189,34 @@ export async function closeProjectSession(options) {
138
189
  'utf8',
139
190
  );
140
191
 
141
- const hadHandoff = await fileExists(normalized.handoffFilePath);
142
- await rm(normalized.wipFilePath, { force: true });
143
- if (hadHandoff) {
144
- await rm(normalized.handoffFilePath, { force: true });
192
+ const hadHandoff = await fileExists(lanePaths.handoffFilePath);
193
+ let activeSessionIndex = null;
194
+ if (sessionId) {
195
+ await rm(lanePaths.laneDir, { recursive: true, force: true });
196
+ activeSessionIndex = await removeActiveSessionFromIndex(normalized, sessionId);
197
+ } else {
198
+ await rm(lanePaths.wipFilePath, { force: true });
199
+ if (hadHandoff) {
200
+ await rm(lanePaths.handoffFilePath, { force: true });
201
+ }
145
202
  }
146
203
 
147
- const updatedClaude = replaceCurrentSessionBlock(claude.content, {
148
- date: `${activeSession.sessionDate} (session ${activeSession.sessionNumber})`,
149
- workingOn: 'Session closed. Ready for the next builder session.',
150
- lastThingCompleted:
151
- normalizeOptionalString(options?.lastThingCompleted) ??
152
- `Closed session ${activeSession.sessionNumber} and wrote \`${sessionRelativePath}\`.`,
153
- blockers: blockers.length > 0 ? summarizeList(blockers) : 'No blocker remains.',
154
- nextSessionShould:
155
- normalizeOptionalString(options?.nextSessionShould) ?? summarizeOrderedList(nextSteps),
156
- });
204
+ const closedSummary = `Closed session ${activeSession.sessionNumber} and wrote \`${sessionRelativePath}\`.`;
205
+ const updatedClaude = replaceCurrentSessionBlock(
206
+ claude.content,
207
+ activeSessionIndex?.current
208
+ ? buildCurrentSessionFieldsForLane(activeSessionIndex.lanes.find((lane) => lane.id === activeSessionIndex.current), {
209
+ lastThingCompleted: normalizeOptionalString(options?.lastThingCompleted) ?? closedSummary,
210
+ })
211
+ : {
212
+ date: `${activeSession.sessionDate} (session ${activeSession.sessionNumber})`,
213
+ workingOn: 'Session closed. Ready for the next builder session.',
214
+ lastThingCompleted: normalizeOptionalString(options?.lastThingCompleted) ?? closedSummary,
215
+ blockers: blockers.length > 0 ? summarizeList(blockers) : 'No blocker remains.',
216
+ nextSessionShould:
217
+ normalizeOptionalString(options?.nextSessionShould) ?? summarizeOrderedList(nextSteps),
218
+ },
219
+ );
157
220
 
158
221
  await writeFile(normalized.claudePath, updatedClaude, 'utf8');
159
222
  const agentFileSync = await syncAgentInstructionFilesSafely({
@@ -166,6 +229,7 @@ export async function closeProjectSession(options) {
166
229
  toolingRootDir: normalized.toolingRootDir,
167
230
  claudePath: normalized.claudePath,
168
231
  projectFilePath: normalized.projectFilePath,
232
+ sessionId,
169
233
  sessionFilePath,
170
234
  sessionRelativePath,
171
235
  sessionDate: activeSession.sessionDate,
@@ -173,12 +237,61 @@ export async function closeProjectSession(options) {
173
237
  workflowGuidance: buildCloseSessionGuidance(workflowSettings),
174
238
  agentFileSync,
175
239
  removedScratchFiles: [
176
- normalized.wipFilePath,
177
- ...(hadHandoff ? [normalized.handoffFilePath] : []),
240
+ lanePaths.wipFilePath,
241
+ ...(hadHandoff ? [lanePaths.handoffFilePath] : []),
178
242
  ],
179
243
  };
180
244
  }
181
245
 
246
+ export async function listProjectSessions(options = {}) {
247
+ const normalized = normalizeSessionPaths(options);
248
+ await ensureInitializedProjectMemory(normalized.rootDir, { allowMissingProjectFile: true });
249
+ const index = await readActiveSessionIndex(normalized);
250
+ const lanes = await listActiveSessionLanes(normalized);
251
+
252
+ return {
253
+ rootDir: normalized.rootDir,
254
+ current: index.current ?? lanes[0]?.id ?? null,
255
+ lanes,
256
+ };
257
+ }
258
+
259
+ export async function switchProjectSession(options = {}) {
260
+ const normalized = normalizeSessionPaths(options);
261
+ const sessionId = validateLaneId(requireNonEmptyString(options?.sessionId, 'switch-session requires a session ID.'));
262
+ const lanes = await listActiveSessionLanes(normalized);
263
+ const lane = lanes.find((item) => item.id === sessionId);
264
+
265
+ if (!lane) {
266
+ throw new Error(`Active session lane "${sessionId}" does not exist.`);
267
+ }
268
+
269
+ await upsertActiveSessionIndex(normalized, null, { current: sessionId });
270
+ const claude = await readClaudeFile(normalized.claudePath);
271
+ await writeFile(
272
+ normalized.claudePath,
273
+ replaceCurrentSessionBlock(
274
+ claude.content,
275
+ buildCurrentSessionFieldsForLane(lane, {
276
+ lastThingCompleted: `Switched current lane to ${sessionId}.`,
277
+ }),
278
+ ),
279
+ 'utf8',
280
+ );
281
+ const agentFileSync = await syncAgentInstructionFilesSafely({
282
+ rootDir: normalized.rootDir,
283
+ toolingRootDir: normalized.toolingRootDir,
284
+ });
285
+
286
+ return {
287
+ rootDir: normalized.rootDir,
288
+ claudePath: normalized.claudePath,
289
+ current: sessionId,
290
+ lanes,
291
+ agentFileSync,
292
+ };
293
+ }
294
+
182
295
  async function syncAgentInstructionFilesSafely(options) {
183
296
  try {
184
297
  return await syncAgentInstructionFiles(options);
@@ -203,20 +316,32 @@ async function syncAgentInstructionFilesSafely(options) {
203
316
 
204
317
  function normalizeSessionPaths(options) {
205
318
  const cwd = options?.cwd ? path.resolve(options.cwd) : process.cwd();
319
+ const rootDir = path.resolve(cwd, options?.rootDir ?? '.compass');
320
+ const toolingRootDir = options?.toolingRootDir
321
+ ? path.resolve(cwd, options.toolingRootDir)
322
+ : cwd;
206
323
 
207
324
  return {
208
- rootDir: path.resolve(cwd, options?.rootDir ?? '.compass'),
209
- toolingRootDir: options?.toolingRootDir
210
- ? path.resolve(cwd, options.toolingRootDir)
211
- : cwd,
212
- projectFilePath: path.resolve(path.resolve(cwd, options?.rootDir ?? '.compass'), 'project.yaml'),
213
- claudePath: path.resolve(
214
- options?.toolingRootDir ? path.resolve(cwd, options.toolingRootDir) : cwd,
215
- 'CLAUDE.md',
216
- ),
217
- sessionsDir: path.resolve(path.resolve(cwd, options?.rootDir ?? '.compass'), 'sessions'),
218
- wipFilePath: path.resolve(path.resolve(cwd, options?.rootDir ?? '.compass'), 'sessions/wip.md'),
219
- handoffFilePath: path.resolve(path.resolve(cwd, options?.rootDir ?? '.compass'), 'sessions/handoff.md'),
325
+ rootDir,
326
+ toolingRootDir,
327
+ projectFilePath: path.resolve(rootDir, 'project.yaml'),
328
+ claudePath: path.resolve(toolingRootDir, 'CLAUDE.md'),
329
+ sessionsDir: path.resolve(rootDir, 'sessions'),
330
+ activeSessionsDir: path.resolve(rootDir, 'sessions/active'),
331
+ activeSessionsIndexPath: path.resolve(rootDir, 'sessions/active/index.yaml'),
332
+ wipFilePath: path.resolve(rootDir, 'sessions/wip.md'),
333
+ handoffFilePath: path.resolve(rootDir, 'sessions/handoff.md'),
334
+ };
335
+ }
336
+
337
+ function getLanePaths(normalized, sessionId) {
338
+ const laneDir = path.resolve(normalized.activeSessionsDir, sessionId);
339
+
340
+ return {
341
+ laneDir,
342
+ sessionFilePath: path.resolve(laneDir, 'session.yaml'),
343
+ wipFilePath: path.resolve(laneDir, 'wip.md'),
344
+ handoffFilePath: path.resolve(laneDir, 'handoff.md'),
220
345
  };
221
346
  }
222
347
 
@@ -302,13 +427,195 @@ function replaceCurrentSessionBlock(content, fields) {
302
427
  return `${content.slice(0, sessionFence.bodyStart)}${blockBody}${content.slice(sessionFence.bodyEnd)}`;
303
428
  }
304
429
 
430
+ function buildCurrentSessionFieldsForLane(lane, overrides = {}) {
431
+ if (!lane) {
432
+ throw new Error('Cannot update the Current session block without active lane metadata.');
433
+ }
434
+
435
+ const sessionDate = lane.sessionDate ?? normalizeSessionDate();
436
+ const sessionNumber = lane.sessionNumber || 0;
437
+ const workingOn = lane.workingOn ?? 'No summary recorded.';
438
+
439
+ return {
440
+ date: `${sessionDate} (session ${sessionNumber}, lane ${lane.id})`,
441
+ workingOn: `${workingOn} [${lane.id}]`,
442
+ lastThingCompleted:
443
+ overrides.lastThingCompleted ?? 'Current lane selected from `sessions/active/index.yaml`.',
444
+ blockers: overrides.blockers ?? 'No blocker recorded for the selected lane.',
445
+ nextSessionShould:
446
+ overrides.nextSessionShould ?? 'Continue the selected active lane from `sessions/active/index.yaml`.',
447
+ };
448
+ }
449
+
305
450
  async function getNextSessionNumber(sessionsDir, sessionDate) {
306
451
  const sessions = await listFinalizedSessions(sessionsDir);
307
- const todaysSessions = sessions.filter((session) => session.sessionDate === sessionDate);
452
+ const activeSessions = await listActiveSessionLanes({ sessionsDir, activeSessionsDir: path.join(sessionsDir, 'active') });
453
+ const todaysSessions = [
454
+ ...sessions,
455
+ ...activeSessions,
456
+ ].filter((session) => session.sessionDate === sessionDate);
308
457
  const highestNumber = todaysSessions.reduce((max, session) => Math.max(max, session.sessionNumber), 0);
309
458
  return highestNumber + 1;
310
459
  }
311
460
 
461
+ async function resolveStartSessionId(normalized, options) {
462
+ const explicitId = normalizeOptionalString(options?.sessionId);
463
+ if (explicitId) {
464
+ return validateLaneId(explicitId);
465
+ }
466
+
467
+ if ((await fileExists(normalized.wipFilePath)) || (await fileExists(normalized.handoffFilePath))) {
468
+ throw new Error(
469
+ `Legacy active session scratch files already exist under ${normalized.sessionsDir}. Close or recover that session before starting a lane.`,
470
+ );
471
+ }
472
+
473
+ throw new Error('start-session requires --id <lane-id> so each active lane has a meaningful name.');
474
+ }
475
+
476
+ async function resolveExistingSessionId(normalized, options) {
477
+ const explicitId = normalizeOptionalString(options?.sessionId);
478
+ if (explicitId) {
479
+ return validateLaneId(explicitId);
480
+ }
481
+
482
+ const lanes = await listActiveSessionLanes(normalized);
483
+ if (lanes.length === 1) {
484
+ return lanes[0].id;
485
+ }
486
+
487
+ if (lanes.length > 1) {
488
+ throw new Error('Multiple active session lanes exist. Pass --session to choose which lane to close.');
489
+ }
490
+
491
+ return null;
492
+ }
493
+
494
+ async function listActiveSessionLanes(normalized) {
495
+ try {
496
+ const entries = await readdir(normalized.activeSessionsDir, { withFileTypes: true });
497
+ const lanes = [];
498
+ for (const entry of entries) {
499
+ if (!entry.isDirectory()) {
500
+ continue;
501
+ }
502
+
503
+ const sessionId = entry.name;
504
+ const lanePaths = getLanePaths(normalized, sessionId);
505
+ const metadata = await readLaneMetadata(lanePaths.sessionFilePath);
506
+ const wipSession = await readLaneWipSession(lanePaths.wipFilePath);
507
+ lanes.push({
508
+ id: sessionId,
509
+ status: metadata.status ?? 'active',
510
+ workingOn: metadata.workingOn ?? null,
511
+ features: metadata.features,
512
+ repos: metadata.repos,
513
+ claims: metadata.claims,
514
+ sessionDate: metadata.sessionDate ?? wipSession?.sessionDate ?? null,
515
+ sessionNumber: metadata.sessionNumber ?? wipSession?.sessionNumber ?? 0,
516
+ });
517
+ }
518
+
519
+ return lanes.sort((left, right) => left.id.localeCompare(right.id));
520
+ } catch (error) {
521
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
522
+ return [];
523
+ }
524
+
525
+ throw error;
526
+ }
527
+ }
528
+
529
+ async function readLaneWipSession(wipFilePath) {
530
+ try {
531
+ return parseActiveSession(await readFile(wipFilePath, 'utf8'), wipFilePath);
532
+ } catch {
533
+ return null;
534
+ }
535
+ }
536
+
537
+ async function readLaneMetadata(sessionFilePath) {
538
+ try {
539
+ const content = await readFile(sessionFilePath, 'utf8');
540
+ const data = parseSimpleYaml(content, { sourceName: sessionFilePath });
541
+ return {
542
+ status: normalizeOptionalString(data.status),
543
+ workingOn: normalizeOptionalString(data.working_on),
544
+ features: normalizeStringArray(data.feature_slugs),
545
+ repos: normalizeStringArray(data.repos),
546
+ claims: normalizeStringArray(data.claimed_paths),
547
+ sessionDate: normalizeOptionalString(data.session_date),
548
+ sessionNumber: typeof data.session_number === 'number' ? data.session_number : Number(data.session_number) || null,
549
+ };
550
+ } catch {
551
+ return {
552
+ status: null,
553
+ workingOn: null,
554
+ features: [],
555
+ repos: [],
556
+ claims: [],
557
+ sessionDate: null,
558
+ sessionNumber: null,
559
+ };
560
+ }
561
+ }
562
+
563
+ async function readActiveSessionIndex(normalized) {
564
+ try {
565
+ const data = parseSimpleYaml(await readFile(normalized.activeSessionsIndexPath, 'utf8'), {
566
+ sourceName: normalized.activeSessionsIndexPath,
567
+ });
568
+ return {
569
+ current: normalizeOptionalString(data.current),
570
+ };
571
+ } catch {
572
+ return {
573
+ current: null,
574
+ };
575
+ }
576
+ }
577
+
578
+ async function upsertActiveSessionIndex(normalized, lane, options = {}) {
579
+ const currentIndex = await readActiveSessionIndex(normalized);
580
+ const lanes = await listActiveSessionLanes(normalized);
581
+ const laneMap = new Map(lanes.map((item) => [item.id, item]));
582
+ if (lane) {
583
+ laneMap.set(lane.id, {
584
+ id: lane.id,
585
+ status: lane.status,
586
+ workingOn: lane.workingOn,
587
+ });
588
+ }
589
+
590
+ const current = options.current ?? currentIndex.current ?? lane?.id ?? null;
591
+ await mkdir(normalized.activeSessionsDir, { recursive: true });
592
+ await writeFile(normalized.activeSessionsIndexPath, renderActiveSessionIndex(current, [...laneMap.values()]), 'utf8');
593
+ }
594
+
595
+ /**
596
+ * Removes a closed lane from the active index and returns the surviving lane
597
+ * state used to refresh the Current session block. `lanes` contains the full
598
+ * lane metadata read from each sibling lane's `session.yaml` / `wip.md`.
599
+ */
600
+ async function removeActiveSessionFromIndex(normalized, sessionId) {
601
+ const index = await readActiveSessionIndex(normalized);
602
+ const lanes = (await listActiveSessionLanes(normalized)).filter((lane) => lane.id !== sessionId);
603
+ const current = index.current === sessionId ? lanes[0]?.id ?? null : index.current;
604
+ if (lanes.length === 0) {
605
+ await rm(normalized.activeSessionsIndexPath, { force: true });
606
+ return {
607
+ current: null,
608
+ lanes,
609
+ };
610
+ }
611
+
612
+ await writeFile(normalized.activeSessionsIndexPath, renderActiveSessionIndex(current, lanes), 'utf8');
613
+ return {
614
+ current,
615
+ lanes,
616
+ };
617
+ }
618
+
312
619
  async function listFinalizedSessions(sessionsDir) {
313
620
  try {
314
621
  const entries = await readdir(sessionsDir, { withFileTypes: true });
@@ -337,10 +644,10 @@ async function listFinalizedSessions(sessionsDir) {
337
644
  }
338
645
  }
339
646
 
340
- function parseActiveSession(wipContent) {
647
+ function parseActiveSession(wipContent, wipFilePath = 'sessions/active/<lane-id>/wip.md') {
341
648
  const match = wipContent.match(WIP_HEADER_PATTERN);
342
649
  if (!match) {
343
- throw new Error('sessions/wip.md is missing the expected "# WIP — YYYY-MM-DD (session N)" header.');
650
+ throw new Error(`${wipFilePath} is missing the expected "# WIP — YYYY-MM-DD (session N)" header.`);
344
651
  }
345
652
 
346
653
  return {
@@ -349,6 +656,19 @@ function parseActiveSession(wipContent) {
349
656
  };
350
657
  }
351
658
 
659
+ function validateLaneId(value) {
660
+ const normalized = value.trim();
661
+ if (!LANE_ID_PATTERN.test(normalized)) {
662
+ throw new Error('Session lane ID must be a lowercase slug 3-64 characters long using letters, numbers, and hyphens.');
663
+ }
664
+
665
+ if (RESERVED_LANE_IDS.has(normalized)) {
666
+ throw new Error(`Session lane ID "${normalized}" is reserved.`);
667
+ }
668
+
669
+ return normalized;
670
+ }
671
+
352
672
  function extractSectionBody(content, sectionTitle) {
353
673
  const heading = `## ${sectionTitle}`.toLowerCase();
354
674
  const lines = content.split('\n');
@@ -407,6 +727,8 @@ function findCurrentSessionFence(content) {
407
727
  function renderWipTemplate(options) {
408
728
  return `# WIP — ${options.sessionDate} (session ${options.sessionNumber})
409
729
 
730
+ Session lane: ${options.sessionId}
731
+
410
732
  ## Working on
411
733
  ${options.workingOn}
412
734
 
@@ -423,6 +745,8 @@ ${options.workingOn}
423
745
  function renderHandoffTemplate(options) {
424
746
  return `# Handoff — ${options.sessionDate} (session ${options.sessionNumber})
425
747
 
748
+ Session lane: ${options.sessionId}
749
+
426
750
  ## Builder → Reviewer
427
751
 
428
752
  ### What changed
@@ -444,6 +768,53 @@ function renderHandoffTemplate(options) {
444
768
  `;
445
769
  }
446
770
 
771
+ function renderLaneMetadata(options) {
772
+ return [
773
+ `id: ${options.sessionId}`,
774
+ 'status: active',
775
+ `session_date: ${options.sessionDate}`,
776
+ `session_number: ${options.sessionNumber}`,
777
+ `working_on: ${quoteYamlString(options.workingOn)}`,
778
+ renderYamlArray('feature_slugs', options.features),
779
+ renderYamlArray('repos', options.repos),
780
+ renderYamlArray('claimed_paths', options.claims),
781
+ `started_at: ${new Date().toISOString()}`,
782
+ 'decision_snapshot:',
783
+ ' highest_decision_id: null',
784
+ '',
785
+ ].join('\n');
786
+ }
787
+
788
+ function renderActiveSessionIndex(current, lanes) {
789
+ const lines = [
790
+ `current: ${current ?? 'null'}`,
791
+ 'lanes:',
792
+ ];
793
+
794
+ for (const lane of lanes.sort((left, right) => left.id.localeCompare(right.id))) {
795
+ lines.push(` - id: ${lane.id}`);
796
+ lines.push(` status: ${lane.status ?? 'active'}`);
797
+ lines.push(` working_on: ${quoteYamlString(lane.workingOn ?? '')}`);
798
+ }
799
+
800
+ return `${lines.join('\n')}\n`;
801
+ }
802
+
803
+ function renderYamlArray(key, values) {
804
+ if (!values || values.length === 0) {
805
+ return `${key}: []`;
806
+ }
807
+
808
+ return [
809
+ `${key}:`,
810
+ ...values.map((value) => ` - ${quoteYamlString(value)}`),
811
+ ].join('\n');
812
+ }
813
+
814
+ function quoteYamlString(value) {
815
+ return JSON.stringify(value);
816
+ }
817
+
447
818
  function renderSessionNote(options) {
448
819
  return `# Session — ${options.sessionDate}-${options.sessionNumber} — ${options.title}
449
820