@vibecompass/vibecompass 0.1.0 → 0.2.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/README.md CHANGED
@@ -14,7 +14,9 @@ Shipped today:
14
14
  - interactive guided init for placement, workflow bootstrap, and first-session setup
15
15
  - generated `context.md` plus opt-in workflow guide files
16
16
  - opt-in starter `CLAUDE.md` / `AGENTS.md` templates that are created once and never overwritten
17
- - `vibecompass start-session` / `vibecompass close-session` for the local builder workflow
17
+ - `vibecompass start-session` / `vibecompass close-session` for lane-aware local builder workflow
18
+ - `vibecompass list-sessions` / `vibecompass switch-session` for multiple active session lanes
19
+ - `vibecompass end-session` as a discoverable alias for `close-session`
18
20
  - `vibecompass sync-agents` for generated `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, and `.github/copilot-instructions.md` views
19
21
  - canonical file scanning and validation
20
22
  - `state/manifest.json` generation
@@ -26,6 +28,8 @@ Not shipped yet:
26
28
 
27
29
  The package is publishable now as the file-contract and read-model core. The
28
30
  broader local-primary sync workflow is still being built on top of it.
31
+ The lane-aware session workflow is intended to publish as `0.2.0`; the local
32
+ package may remain at `0.1.0` until the publish pass.
29
33
 
30
34
  ## Requirements
31
35
 
@@ -39,6 +43,9 @@ broader local-primary sync workflow is still being built on top of it.
39
43
  npx -y @vibecompass/vibecompass --help
40
44
  ```
41
45
 
46
+ The npm package is scoped under the VibeCompass org, but the installed CLI
47
+ command is intentionally unscoped: `vibecompass`.
48
+
42
49
  ### Library
43
50
 
44
51
  ```bash
@@ -98,6 +105,7 @@ If you do not chain straight into a first session from `init`, open one later:
98
105
 
99
106
  ```bash
100
107
  npx -y @vibecompass/vibecompass start-session \
108
+ --id mcp-dogfood \
101
109
  --working-on "Validate the MCP dogfood workflow against the docs repo"
102
110
  ```
103
111
 
@@ -105,29 +113,49 @@ Close the active builder session:
105
113
 
106
114
  ```bash
107
115
  npx -y @vibecompass/vibecompass close-session \
116
+ --session mcp-dogfood \
108
117
  --title "Workflow Parity Commands" \
109
118
  --completed "Added package-owned start-session and close-session commands" \
110
119
  --next-step "Run the package publish dry-run"
111
120
  ```
112
121
 
122
+ `end-session` is accepted as an alias for the same lifecycle step and flags:
123
+ `npx -y @vibecompass/vibecompass end-session ...`.
124
+
113
125
  The package CLI owns filesystem lifecycle only. The default scaffolded
114
126
  workflow also uses prompt commands inside your coding tool:
115
127
 
116
128
  ```text
117
129
  start session # builder role trigger
118
130
  join as reviewer # reviewer role trigger
119
- review handoff # reviewer reads the latest note, wip.md, handoff.md, and diffs
120
- address review # builder reads reviewer feedback, responds inline, and continues
131
+ review handoff # reviewer reads selected-lane wip.md, handoff.md, and diffs
132
+ address review # builder reads selected-lane feedback, responds inline, and continues
121
133
  ```
122
134
 
123
135
  Those prompt commands are documented in the generated `context.md`,
124
136
  `CLAUDE.md`, and `AGENTS.md` templates. They are not additional
125
137
  `vibecompass` CLI subcommands.
126
138
  Reviewer handback is explicit in the package-managed workflow: the reviewer
127
- ends the pass by updating `wip.md` + `handoff.md`, and the builder closes
128
- the session with `vibecompass close-session` using the defaults stored in
139
+ ends the pass by updating the selected lane's `wip.md` + `handoff.md`, and
140
+ the builder closes the session with `vibecompass close-session --session
141
+ <lane-id>` using the defaults stored in
129
142
  `project.yaml.metadata.workflow.close_session`.
130
143
 
144
+ Active builder sessions are named lanes under
145
+ `sessions/active/<lane-id>/`. Use one lane per active feature or workstream.
146
+ `start-session` without `--id` opens the compatibility `default` lane only
147
+ when no other lanes are active; concurrent work should pass `--id`.
148
+ Finalized sessions are append-only notes named
149
+ `sessions/YYYY-MM-DD-N-title.md`, so multiple sessions on the same day
150
+ increment `N`. Decisions remain append-only in `decisions/`; a session note
151
+ can reference a decision, but the decision file is the durable source of truth.
152
+
153
+ For higher-risk work, use planning mode as a prompt-level step inside the
154
+ active session. Planning mode should read the same context, propose scope and
155
+ tradeoffs, and record the agreed plan in the selected lane's `wip.md`; it
156
+ should not create a separate lifecycle artifact or finalize decisions until
157
+ you approve implementation.
158
+
131
159
  Generate agent-instruction files from project memory:
132
160
 
133
161
  ```bash
@@ -227,10 +255,31 @@ Options:
227
255
  - `--root <path>`: project-memory root; defaults to `.compass`
228
256
  - `--tooling-root <path>`: workspace root that contains `CLAUDE.md`; defaults to cwd
229
257
  - `--working-on <text>`: required active-session summary
258
+ - `--id <lane-id>`: optional lane ID; defaults to `default` only when no lanes are active
259
+ - `--feature <slug>`: repeatable feature slug for the lane
260
+ - `--repo <id>`: repeatable repo ID for the lane
261
+ - `--claim <path>`: repeatable path claim for overlap warnings
230
262
  - `--date <YYYY-MM-DD>`: optional explicit session date
231
263
 
232
264
  This command updates the `Current session` block in `CLAUDE.md` and creates
233
- `sessions/wip.md` plus `sessions/handoff.md` for the next session number.
265
+ `sessions/active/<lane-id>/session.yaml`, `wip.md`, and `handoff.md` for the
266
+ next session number. It also updates `sessions/active/index.yaml`.
267
+
268
+ ### `vibecompass list-sessions`
269
+
270
+ ```text
271
+ vibecompass list-sessions [options]
272
+ ```
273
+
274
+ Lists active session lanes and marks the current lane.
275
+
276
+ ### `vibecompass switch-session`
277
+
278
+ ```text
279
+ vibecompass switch-session <lane-id> [options]
280
+ ```
281
+
282
+ Changes the current lane in `sessions/active/index.yaml`.
234
283
 
235
284
  ### `vibecompass close-session`
236
285
 
@@ -238,12 +287,17 @@ This command updates the `Current session` block in `CLAUDE.md` and creates
238
287
  vibecompass close-session --title <text> --completed <text> --next-step <text> [options]
239
288
  ```
240
289
 
290
+ `vibecompass end-session` accepts the same options and runs the same close
291
+ path. `close-session` remains the canonical command name in generated workflow
292
+ metadata and docs.
293
+
241
294
  Options:
242
295
 
243
296
  - `--root <path>`: project-memory root; defaults to `.compass`
244
297
  - `--tooling-root <path>`: workspace root that contains `CLAUDE.md`; defaults to cwd
245
298
  - `--title <text>`: required finalized session-note title
246
299
  - `--worked-on <text>`: optional override for the "What we worked on" section
300
+ - `--session <lane-id>`: active lane to close; required when multiple lanes are active
247
301
  - `--completed <text>`: repeatable completed item
248
302
  - `--decision <text>`: repeatable decision reference or summary
249
303
  - `--model <text>`: optional repeatable model contribution entry
@@ -251,7 +305,8 @@ Options:
251
305
  - `--next-step <text>`: repeatable next-session instruction
252
306
 
253
307
  This command finalizes the active scratchpad into
254
- `sessions/YYYY-MM-DD-N-display-title.md`, deletes `wip.md` / `handoff.md`, and
308
+ `sessions/YYYY-MM-DD-N-display-title.md`, deletes the closed lane directory
309
+ under `sessions/active/<lane-id>/`, updates `sessions/active/index.yaml`, and
255
310
  updates the `Current session` block in `CLAUDE.md`. If no `--model` flags are
256
311
  provided, the session note records `Not recorded.` under `Models used`.
257
312
  This is the final builder lifecycle step after the last `address review`
@@ -283,15 +338,15 @@ These are the default prompt commands used by the scaffolded workflow files:
283
338
 
284
339
  - `start session`: builder role trigger
285
340
  - `join as reviewer`: reviewer role trigger
286
- - `review handoff`: reviewer reads the latest finalized note, `wip.md`, `handoff.md`, and relevant diffs before appending findings
287
- - `address review`: builder reads the latest reviewer feedback in `wip.md` / `handoff.md`, responds inline, applies accepted changes, and refreshes the builder handoff
341
+ - `review handoff`: reviewer reads the selected lane's latest `wip.md`, `handoff.md`, latest finalized note, and relevant diffs before appending findings
342
+ - `address review`: builder reads the latest reviewer feedback in the selected lane's `wip.md` / `handoff.md`, responds inline, applies accepted changes, and refreshes the builder handoff
288
343
 
289
344
  They are prompt commands for AI-tool behavior, not package CLI commands.
290
- Reviewer handback ends when the reviewer has updated `wip.md` and
291
- `handoff.md`; there is no extra reviewer-exit step. The builder remains
292
- in builder role, uses `address review` until findings are resolved or
293
- explicitly deferred, and then runs `vibecompass close-session` using the
294
- workflow defaults recorded in `project.yaml`.
345
+ Reviewer handback ends when the reviewer has updated the selected lane's
346
+ `wip.md` and `handoff.md`; there is no extra reviewer-exit step. The builder
347
+ remains in builder role, uses `address review` until findings are resolved or
348
+ explicitly deferred, and then runs `vibecompass close-session --session
349
+ <lane-id>` using the workflow defaults recorded in `project.yaml`.
295
350
 
296
351
  ## JavaScript API
297
352
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibecompass/vibecompass",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Local-first project-memory core for VibeCompass",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -4,7 +4,7 @@ import { realpathSync } from 'node:fs';
4
4
  import { pathToFileURL } from 'node:url';
5
5
  import { initializeProjectMemory } from './init.js';
6
6
  import { resolveInitCliOptions } from './setup.js';
7
- import { closeProjectSession, startProjectSession } from './session.js';
7
+ import { closeProjectSession, listProjectSessions, startProjectSession, switchProjectSession } from './session.js';
8
8
  import { syncAgentInstructionFiles } from './generators/agent-files/index.js';
9
9
 
10
10
  export async function runCli(argv, io = createDefaultIo(), runtime = {}) {
@@ -86,7 +86,7 @@ export async function runCli(argv, io = createDefaultIo(), runtime = {}) {
86
86
  return 0;
87
87
  }
88
88
 
89
- if (parsed.command === 'close-session') {
89
+ if (parsed.command === 'close-session' || parsed.command === 'end-session') {
90
90
  const result = await closeProjectSession({
91
91
  ...parsed.options,
92
92
  ...(runtime.cwd ? { cwd: runtime.cwd } : {}),
@@ -104,6 +104,32 @@ export async function runCli(argv, io = createDefaultIo(), runtime = {}) {
104
104
  return 0;
105
105
  }
106
106
 
107
+ if (parsed.command === 'list-sessions') {
108
+ const result = await listProjectSessions({
109
+ ...parsed.options,
110
+ ...(runtime.cwd ? { cwd: runtime.cwd } : {}),
111
+ });
112
+ io.stdout.write(`Active session lanes${result.current ? ` (current: ${result.current})` : ''}:\n`);
113
+ if (result.lanes.length === 0) {
114
+ io.stdout.write('- None\n');
115
+ } else {
116
+ for (const lane of result.lanes) {
117
+ const marker = lane.id === result.current ? '*' : '-';
118
+ io.stdout.write(`${marker} ${lane.id}: ${lane.workingOn ?? 'No summary recorded'}\n`);
119
+ }
120
+ }
121
+ return 0;
122
+ }
123
+
124
+ if (parsed.command === 'switch-session') {
125
+ const result = await switchProjectSession({
126
+ ...parsed.options,
127
+ ...(runtime.cwd ? { cwd: runtime.cwd } : {}),
128
+ });
129
+ io.stdout.write(`Current session lane: ${result.current}\n`);
130
+ return 0;
131
+ }
132
+
107
133
  if (parsed.command === 'sync-agents') {
108
134
  const result = await syncAgentInstructionFiles({
109
135
  ...parsed.options,
@@ -128,10 +154,18 @@ export function parseCliArgs(argv) {
128
154
  return parseStartSessionArgs(rest);
129
155
  }
130
156
 
131
- if (command === 'close-session') {
157
+ if (command === 'close-session' || command === 'end-session') {
132
158
  return parseCloseSessionArgs(rest);
133
159
  }
134
160
 
161
+ if (command === 'list-sessions') {
162
+ return parseListSessionsArgs(rest);
163
+ }
164
+
165
+ if (command === 'switch-session') {
166
+ return parseSwitchSessionArgs(rest);
167
+ }
168
+
135
169
  if (command === 'sync-agents') {
136
170
  return parseSyncAgentsArgs(rest);
137
171
  }
@@ -322,6 +356,18 @@ function parseStartSessionArgs(argv) {
322
356
  case '--working-on':
323
357
  parsed.workingOn = value;
324
358
  break;
359
+ case '--id':
360
+ parsed.sessionId = value;
361
+ break;
362
+ case '--feature':
363
+ parsed.features = [...(parsed.features ?? []), value];
364
+ break;
365
+ case '--repo':
366
+ parsed.repos = [...(parsed.repos ?? []), value];
367
+ break;
368
+ case '--claim':
369
+ parsed.claims = [...(parsed.claims ?? []), value];
370
+ break;
325
371
  case '--date':
326
372
  parsed.date = value;
327
373
  break;
@@ -381,6 +427,9 @@ function parseCloseSessionArgs(argv) {
381
427
  case '--worked-on':
382
428
  parsed.workedOn = value;
383
429
  break;
430
+ case '--session':
431
+ parsed.sessionId = value;
432
+ break;
384
433
  case '--completed':
385
434
  parsed.completed.push(value);
386
435
  break;
@@ -413,6 +462,80 @@ function parseCloseSessionArgs(argv) {
413
462
  };
414
463
  }
415
464
 
465
+ function parseListSessionsArgs(argv) {
466
+ const parsed = {};
467
+
468
+ for (let index = 0; index < argv.length; index += 1) {
469
+ const token = argv[index];
470
+ if (!token.startsWith('--')) {
471
+ throw new Error(`Unexpected argument "${token}".`);
472
+ }
473
+
474
+ const value = argv[index + 1];
475
+ if (value === undefined) {
476
+ throw new Error(`Flag "${token}" requires a value.`);
477
+ }
478
+ index += 1;
479
+
480
+ switch (token) {
481
+ case '--root':
482
+ parsed.rootDir = value;
483
+ break;
484
+ default:
485
+ throw new Error(`Unknown flag "${token}".`);
486
+ }
487
+ }
488
+
489
+ return {
490
+ command: 'list-sessions',
491
+ options: parsed,
492
+ };
493
+ }
494
+
495
+ function parseSwitchSessionArgs(argv) {
496
+ const parsed = {};
497
+ const positional = [];
498
+
499
+ for (let index = 0; index < argv.length; index += 1) {
500
+ const token = argv[index];
501
+
502
+ if (!token.startsWith('--')) {
503
+ positional.push(token);
504
+ continue;
505
+ }
506
+
507
+ const value = argv[index + 1];
508
+ if (value === undefined) {
509
+ throw new Error(`Flag "${token}" requires a value.`);
510
+ }
511
+ index += 1;
512
+
513
+ switch (token) {
514
+ case '--root':
515
+ parsed.rootDir = value;
516
+ break;
517
+ case '--session':
518
+ parsed.sessionId = value;
519
+ break;
520
+ default:
521
+ throw new Error(`Unknown flag "${token}".`);
522
+ }
523
+ }
524
+
525
+ if (positional.length > 1) {
526
+ throw new Error(`Unexpected argument "${positional[1]}".`);
527
+ }
528
+
529
+ if (!parsed.sessionId && positional[0]) {
530
+ parsed.sessionId = positional[0];
531
+ }
532
+
533
+ return {
534
+ command: 'switch-session',
535
+ options: parsed,
536
+ };
537
+ }
538
+
416
539
  function parseSyncAgentsArgs(argv) {
417
540
  const parsed = {};
418
541
 
@@ -483,6 +606,9 @@ function usageText() {
483
606
  ' vibecompass init --name <project-name> --mode <local-only|local-primary|hosted-only> --repo <id=remote> [options]',
484
607
  ' vibecompass start-session --working-on <text> [options]',
485
608
  ' vibecompass close-session --title <text> --completed <text> --next-step <text> [options]',
609
+ ' vibecompass end-session --title <text> --completed <text> --next-step <text> [options] # alias',
610
+ ' vibecompass list-sessions [options]',
611
+ ' vibecompass switch-session <id> [options]',
486
612
  ' vibecompass sync-agents [options]',
487
613
  '',
488
614
  'Init options:',
@@ -511,16 +637,21 @@ function usageText() {
511
637
  ' --root <path> Project-memory root. Defaults to .compass',
512
638
  ' --tooling-root <path> Tooling root that contains CLAUDE.md. Defaults to cwd',
513
639
  ' --working-on <text> Required active-session summary',
640
+ ' --id <lane-id> Optional active session lane ID. Defaults to default when no lanes exist',
641
+ ' --feature <slug> Repeatable feature slug for the lane',
642
+ ' --repo <id> Repeatable repo ID for the lane',
643
+ ' --claim <path> Repeatable path claim for overlap warnings',
514
644
  ' --date <YYYY-MM-DD> Optional explicit session date',
515
645
  ' --last-thing-completed <text> Optional override for the CLAUDE.md current-session block',
516
646
  ' --blockers <text> Optional current blockers summary',
517
647
  ' --next-session-should <text> Optional current-session handoff summary',
518
648
  '',
519
- 'Close-session options:',
649
+ 'Close-session options (also accepted by end-session):',
520
650
  ' --root <path> Project-memory root. Defaults to .compass',
521
651
  ' --tooling-root <path> Tooling root that contains CLAUDE.md. Defaults to cwd',
522
652
  ' --title <text> Required display title for the finalized session note',
523
653
  ' --worked-on <text> Optional override for "What we worked on"',
654
+ ' --session <lane-id> Active session lane to close',
524
655
  ' --completed <text> Repeatable completed item',
525
656
  ' --decision <text> Repeatable decision reference or summary',
526
657
  ' --model <text> Optional repeatable model contribution entry',
@@ -529,6 +660,10 @@ function usageText() {
529
660
  ' --last-thing-completed <text> Optional override for the CLAUDE.md completed summary',
530
661
  ' --next-session-should <text> Optional override for the CLAUDE.md next-session summary',
531
662
  '',
663
+ 'List/switch-session options:',
664
+ ' --root <path> Project-memory root. Defaults to .compass',
665
+ ' --session <lane-id> Lane ID for switch-session; positional ID is also accepted',
666
+ '',
532
667
  'Sync-agents options:',
533
668
  ' --root <path> Project-memory root. Defaults to .compass',
534
669
  ' --tooling-root <path> Directory where agent files are written. Defaults to cwd',
@@ -26,16 +26,25 @@ export function renderSharedInstructionBody(context, options = {}) {
26
26
  `- Project memory root: \`${context.rootDir}\``,
27
27
  '- Read `project.yaml` for project identity, repos, mode, and workflow defaults.',
28
28
  '- Read the latest finalized session note under `sessions/` before resuming work.',
29
- '- Read `sessions/wip.md` and `sessions/handoff.md` when they exist; they are the active builder/reviewer scratch files.',
29
+ '- Read `sessions/active/index.yaml` when it exists, then read the selected lane under `sessions/active/<lane-id>/`.',
30
+ '- Read the selected lane `wip.md` and `handoff.md` when they exist; they are the active builder/reviewer scratch files.',
30
31
  '- Read relevant `architecture/` and `decisions/` files before changing implementation.',
31
32
  '',
32
33
  '## Project Shape',
33
34
  ...renderProjectShape(context),
34
35
  '',
35
36
  '## Workflow Rules',
37
+ '- VibeCompass active builder sessions are named lanes. Use one lane per active feature or workstream; finalized session notes are append-only history under `sessions/YYYY-MM-DD-N-title.md`.',
36
38
  '- Use `start session` for builder work and `join as reviewer` for review work.',
39
+ '- Use `vibecompass start-session --id <lane-id>` for concurrent lanes. Omitting `--id` opens the compatibility `default` lane only when no other lanes are active.',
40
+ '- Use `vibecompass list-sessions` and `vibecompass switch-session <lane-id>` to inspect or change the current lane.',
41
+ '- Treat `sessions/active/index.yaml` as the current lane source of truth; tool-specific Current session blocks are continuity summaries.',
42
+ '- Use optional planning mode for risky or ambiguous work: read the same context, propose scope, and record the agreed plan in the selected lane `wip.md` before implementation.',
37
43
  '- Use `review handoff` to ask a reviewer to inspect the active handoff.',
38
- '- Use `address review` to process reviewer feedback from `sessions/wip.md` and `sessions/handoff.md`.',
44
+ '- Use `address review` to process reviewer feedback from the selected lane `wip.md` and `handoff.md`.',
45
+ '- End the active builder session with `vibecompass close-session --session <lane-id>`; `vibecompass end-session` is an accepted alias.',
46
+ '- If stale scratch files block `start-session`, read them first, then close, recover, move, or delete them intentionally before retrying.',
47
+ '- Keep decisions append-only in `decisions/`. Session notes may reference decisions, but decisions are the durable source of truth.',
39
48
  '- Treat generated agent files as views. Update canonical VibeCompass project memory instead of editing managed regions.',
40
49
  '',
41
50
  '## Recent Decisions',
package/src/index.js CHANGED
@@ -1,7 +1,12 @@
1
1
  export { STATE_VERSION, generateStateManifest, writeStateManifest } from './manifest.js';
2
2
  export { initializeProjectMemory } from './init.js';
3
3
  export { scanProjectMemory } from './project-memory.js';
4
- export { startProjectSession, closeProjectSession } from './session.js';
4
+ export {
5
+ closeProjectSession,
6
+ listProjectSessions,
7
+ startProjectSession,
8
+ switchProjectSession,
9
+ } from './session.js';
5
10
  export {
6
11
  syncAgentInstructionFiles,
7
12
  getSupportedAgentFormats,
package/src/scaffold.js CHANGED
@@ -118,8 +118,18 @@ This workspace uses VibeCompass project memory rooted at \`${rootRelativePath}\`
118
118
  ## Derived and scratch files
119
119
  - \`${rootRelativePath}/context.md\` — generated AI-facing workflow context
120
120
  - \`${rootRelativePath}/state/manifest.json\` — machine-owned local state; do not hand-edit
121
- - \`${rootRelativePath}/sessions/wip.md\` — active builder scratchpad during a session
122
- - \`${rootRelativePath}/sessions/handoff.md\` — builder/reviewer relay during a session
121
+ - \`${rootRelativePath}/sessions/active/index.yaml\` — active session lane index and current lane pointer
122
+ - \`${rootRelativePath}/sessions/active/<lane-id>/session.yaml\` — lane metadata
123
+ - \`${rootRelativePath}/sessions/active/<lane-id>/wip.md\` — lane-local builder scratchpad
124
+ - \`${rootRelativePath}/sessions/active/<lane-id>/handoff.md\` — lane-local builder/reviewer relay
125
+
126
+ ## Session model
127
+ - VibeCompass active builder sessions are named lanes. Use one lane per active feature or workstream.
128
+ - \`vibecompass start-session\` without \`--id\` opens the compatibility \`default\` lane only when no other lanes are active; use \`--id <lane-id>\` for concurrent work.
129
+ - The active lane scratch files live under \`${rootRelativePath}/sessions/active/<lane-id>/\`.
130
+ - \`${rootRelativePath}/sessions/active/index.yaml\` is the authoritative current lane pointer; the tool-specific Current session block is a human-readable continuity summary, not the lane-selection source of truth.
131
+ - Finalized sessions are append-only notes named \`${rootRelativePath}/sessions/YYYY-MM-DD-N-title.md\`; multiple sessions on the same day increment \`N\`.
132
+ - Decisions remain append-only and independent from session notes. A session note may reference decisions, but the decision entry in \`${rootRelativePath}/decisions/\` is the durable source of truth.
123
133
 
124
134
  ## Repos in scope
125
135
  ${repos}
@@ -133,11 +143,13 @@ ${repos}
133
143
  ## Session prompt commands
134
144
  - \`start session\` — builder role trigger
135
145
  - \`join as reviewer\` — reviewer role trigger
136
- - \`review handoff\` — reviewer reads the latest finalized note, \`wip.md\`, \`handoff.md\`, and relevant diffs before appending findings
137
- - \`address review\` — builder reads reviewer feedback in \`wip.md\` / \`handoff.md\`, responds inline, applies accepted changes, and refreshes the builder handoff
146
+ - \`planning mode\` — optional prompt-level mode for scoping work before implementation
147
+ - \`review handoff\` — reviewer reads the selected lane's \`wip.md\`, \`handoff.md\`, latest finalized note, and relevant diffs before appending findings
148
+ - \`address review\` — builder reads reviewer feedback in the selected lane's \`wip.md\` / \`handoff.md\`, responds inline, applies accepted changes, and refreshes the builder handoff
138
149
 
139
150
  These are prompt commands for agent behavior, not \`vibecompass\` CLI subcommands.
140
- Reviewer handback is explicit: the reviewer ends the pass by updating \`wip.md\` + \`handoff.md\` and then stopping. Builder close-out uses \`vibecompass close-session\`, which follows the workflow defaults recorded in \`${rootRelativePath}/project.yaml\`.
151
+ Reviewer handback is explicit: the reviewer ends the pass by updating the selected lane's \`wip.md\` + \`handoff.md\` and then stopping. Builder close-out uses \`vibecompass close-session --session <lane-id>\`, which follows the workflow defaults recorded in \`${rootRelativePath}/project.yaml\`.
152
+ \`vibecompass end-session\` is also accepted as an alias for \`vibecompass close-session\`; the canonical command name remains \`close-session\`.
141
153
 
142
154
  ## Workflow defaults
143
155
  ${renderWorkflowDefaults(workflow)}
@@ -145,17 +157,20 @@ ${renderWorkflowDefaults(workflow)}
145
157
  ## Session startup
146
158
  1. Read \`${rootRelativePath}/project.yaml\`.
147
159
  2. Read the latest finalized session note in \`${rootRelativePath}/sessions/\`.
148
- 3. If present, read \`${rootRelativePath}/sessions/wip.md\`.
149
- 4. If present, read \`${rootRelativePath}/sessions/handoff.md\`.
150
- 5. Read the relevant docs under \`${rootRelativePath}/architecture/\` and \`${rootRelativePath}/decisions/\`.
160
+ 3. If present, read \`${rootRelativePath}/sessions/active/index.yaml\` and choose the selected or current lane.
161
+ 4. If present, read \`${rootRelativePath}/sessions/active/<lane-id>/wip.md\`.
162
+ 5. If present, read \`${rootRelativePath}/sessions/active/<lane-id>/handoff.md\`.
163
+ 6. Read the relevant docs under \`${rootRelativePath}/architecture/\` and \`${rootRelativePath}/decisions/\`.
151
164
 
152
165
  ## Builder workflow
153
- At session start, prefer running \`vibecompass start-session --working-on "..." \`.
154
- If you manage files manually, create \`${rootRelativePath}/sessions/wip.md\` if it does not exist:
166
+ At session start, prefer running \`vibecompass start-session --id <lane-id> --working-on "..." \`. Omit \`--id\` only for the first/default lane in a project with no other active lanes.
167
+ If you manage files manually, create \`${rootRelativePath}/sessions/active/<lane-id>/session.yaml\` and \`${rootRelativePath}/sessions/active/index.yaml\`, then create \`${rootRelativePath}/sessions/active/<lane-id>/wip.md\` if it does not exist:
155
168
 
156
169
  \`\`\`md
157
170
  # WIP — YYYY-MM-DD (session N)
158
171
 
172
+ Session lane: <lane-id>
173
+
159
174
  ## Working on
160
175
 
161
176
  ## Log
@@ -165,11 +180,13 @@ If you manage files manually, create \`${rootRelativePath}/sessions/wip.md\` if
165
180
  ## Review log
166
181
  \`\`\`
167
182
 
168
- Also create \`${rootRelativePath}/sessions/handoff.md\` if it does not exist:
183
+ Also create \`${rootRelativePath}/sessions/active/<lane-id>/handoff.md\` if it does not exist:
169
184
 
170
185
  \`\`\`md
171
186
  # Handoff — YYYY-MM-DD (session N)
172
187
 
188
+ Session lane: <lane-id>
189
+
173
190
  ## Builder → Reviewer
174
191
 
175
192
  ### What changed
@@ -188,20 +205,29 @@ Also create \`${rootRelativePath}/sessions/handoff.md\` if it does not exist:
188
205
  During the session:
189
206
  - append short summaries to \`wip.md\` after meaningful exchanges
190
207
  - keep \`handoff.md\` current after substantive work blocks
191
- - use \`address review\` when reviewer feedback lands so the builder resolves it from the latest \`wip.md\` / \`handoff.md\`
192
- - stay in builder role through close-out; resolve or explicitly defer reviewer feedback before running \`vibecompass close-session\`
208
+ - use \`vibecompass list-sessions\` and \`vibecompass switch-session <lane-id>\` to inspect or change the current lane
209
+ - use \`address review\` when reviewer feedback lands so the builder resolves it from the selected lane's latest \`wip.md\` / \`handoff.md\`
210
+ - stay in builder role through close-out; resolve or explicitly defer reviewer feedback before running \`vibecompass close-session --session <lane-id>\`
193
211
  - record architectural decisions in \`${rootRelativePath}/decisions/\` before implementing them
194
212
 
213
+ If \`vibecompass start-session\` reports stale scratch files, read the existing lane-local \`wip.md\` and \`handoff.md\` first. Either close that session normally, recover its useful notes into a finalized session note, or intentionally move/delete the stale scratch files before starting a new session.
214
+
215
+ ## Optional planning mode
216
+ - Use planning mode for risky, ambiguous, cross-file, or architectural work before implementation.
217
+ - Planning mode reads the same startup context as builder mode and may update the selected lane's \`wip.md\` with agreed scope, constraints, and open questions.
218
+ - Planning mode should not finalize session notes, mutate decisions, or make broad code changes until the user approves the plan.
219
+ - If planning produces a real architectural decision, append it to \`${rootRelativePath}/decisions/\` before implementing.
220
+
195
221
  At session close:
196
- - prefer running \`vibecompass close-session --title "..." --completed "..." --model "..." --next-step "..."\`
222
+ - prefer running \`vibecompass close-session --session <lane-id> --title "..." --completed "..." --model "..." --next-step "..."\`; \`vibecompass end-session\` is a supported alias
197
223
  - follow the stored close-session defaults from \`${rootRelativePath}/project.yaml\`
198
- - finalize \`wip.md\` into \`${rootRelativePath}/sessions/YYYY-MM-DD-N-title.md\`
199
- - delete \`wip.md\` and \`handoff.md\`
224
+ - finalize the lane-local \`wip.md\` into \`${rootRelativePath}/sessions/YYYY-MM-DD-N-title.md\`
225
+ - delete the closed lane directory under \`${rootRelativePath}/sessions/active/<lane-id>/\`
200
226
  - refresh any affected architecture/decision docs
201
227
 
202
228
  ## Reviewer workflow
203
229
  - use \`review handoff\` when you want the reviewer to run the next review pass
204
- - read the latest finalized session note, then \`wip.md\`, then \`handoff.md\`
230
+ - read the latest finalized session note, then the selected lane's \`wip.md\`, then \`handoff.md\`
205
231
  - inspect the relevant code/docs diffs
206
232
  - append findings under \`## Review log\` in \`wip.md\`
207
233
  - write a concise baton-pass summary into \`handoff.md\`
@@ -284,8 +310,10 @@ Finalized session notes for ${projectConfig.name} live here.
284
310
  - \`## Next session should start with\`
285
311
 
286
312
  ## Active-session scratch files
287
- - \`wip.md\` — builder scratchpad during an active session
288
- - \`handoff.md\` — reviewer/builder baton-pass during an active session
313
+ - \`active/index.yaml\` — active lane index and current lane pointer
314
+ - \`active/<lane-id>/session.yaml\` — lane metadata
315
+ - \`active/<lane-id>/wip.md\` — builder scratchpad during an active lane
316
+ - \`active/<lane-id>/handoff.md\` — reviewer/builder baton-pass during an active lane
289
317
 
290
318
  Those scratch files are session-scoped working artifacts, not finalized history.
291
319
  `;
@@ -304,16 +332,19 @@ Read \`${options.contextRelativeToToolingRoot}\` before doing substantive work.
304
332
  |---|---|---|
305
333
  | "start session" | Builder | Yes |
306
334
  | "join as reviewer" | Reviewer | No |
335
+ | "planning mode" | Planner | No — scopes work inside the selected active lane before implementation |
307
336
 
308
337
  ## Prompt commands
309
338
 
310
339
  - \`start session\` — builder role trigger
311
340
  - \`join as reviewer\` — reviewer role trigger
312
- - \`review handoff\` — reviewer reads the latest finalized note, \`wip.md\`, \`handoff.md\`, and relevant diffs before appending findings
313
- - \`address review\` — builder reads reviewer feedback in \`wip.md\` / \`handoff.md\`, responds inline, applies accepted changes, and refreshes the builder handoff
341
+ - \`planning mode\` — optional prompt-level mode for scoping risky or ambiguous work before implementation
342
+ - \`review handoff\` — reviewer reads the selected lane's \`wip.md\`, \`handoff.md\`, latest finalized note, and relevant diffs before appending findings
343
+ - \`address review\` — builder reads reviewer feedback in the selected lane's \`wip.md\` / \`handoff.md\`, responds inline, applies accepted changes, and refreshes the builder handoff
314
344
 
315
345
  These are prompt commands for agent behavior, not \`vibecompass\` CLI subcommands.
316
- Reviewer handback is explicit: the reviewer ends the pass by updating \`${options.rootRelativePath}/sessions/wip.md\` + \`${options.rootRelativePath}/sessions/handoff.md\` and then stopping. Builder close-out uses \`vibecompass close-session\`, which follows the workflow defaults recorded in \`${options.rootRelativePath}/project.yaml\`.
346
+ Reviewer handback is explicit: the reviewer ends the pass by updating the selected lane's \`wip.md\` + \`handoff.md\` and then stopping. Builder close-out uses \`vibecompass close-session --session <lane-id>\`, which follows the workflow defaults recorded in \`${options.rootRelativePath}/project.yaml\`.
347
+ \`vibecompass end-session\` is accepted as an alias for \`vibecompass close-session\`; \`close-session\` remains the canonical command name.
317
348
 
318
349
  ## Current session
319
350
 
@@ -330,8 +361,11 @@ Next session should: Read \`${options.contextRelativeToToolingRoot}\`, then open
330
361
  ## Startup
331
362
  - Read \`${options.contextRelativeToToolingRoot}\`
332
363
  - Follow the builder/reviewer protocol defined there
333
- - Use \`vibecompass start-session\` and \`vibecompass close-session\` to manage the active-session files when possible
334
- - Keep \`${options.rootRelativePath}/sessions/wip.md\` and \`${options.rootRelativePath}/sessions/handoff.md\` current during an active session
364
+ - Use \`vibecompass start-session --id <lane-id>\`, \`vibecompass list-sessions\`, \`vibecompass switch-session <lane-id>\`, and \`vibecompass close-session --session <lane-id>\` to manage active lanes when possible
365
+ - Treat \`${options.rootRelativePath}/sessions/active/index.yaml\` as the current lane source of truth; this Current session block is only a continuity summary
366
+ - Keep the selected lane's \`${options.rootRelativePath}/sessions/active/<lane-id>/wip.md\` and \`${options.rootRelativePath}/sessions/active/<lane-id>/handoff.md\` current during an active session
367
+ - Use planning mode when scope is unclear; record the agreed plan in the selected lane's \`wip.md\` before implementing
368
+ - If stale scratch files block \`start-session\`, read them first, then close, recover, move, or delete them intentionally before retrying
335
369
  - Workflow defaults:
336
370
  ${renderWorkflowDefaults(workflow)}
337
371
  `;
@@ -350,27 +384,33 @@ This workspace uses VibeCompass project memory rooted at \`${options.rootRelativ
350
384
  |---|---|---|
351
385
  | "start session" | Builder | Yes — opens and closes the session lifecycle |
352
386
  | "join as reviewer" | Reviewer | No — reviews the builder's work and updates the handoff |
387
+ | "planning mode" | Planner | No — scopes work inside the selected active lane before implementation |
353
388
 
354
389
  ## Prompt commands
355
390
 
356
391
  - \`start session\` — builder role trigger
357
392
  - \`join as reviewer\` — reviewer role trigger
358
- - \`review handoff\` — reviewer reads the latest finalized note, \`wip.md\`, \`handoff.md\`, and relevant diffs before appending findings
359
- - \`address review\` — builder reads reviewer feedback in \`wip.md\` / \`handoff.md\`, responds inline, applies accepted changes, and refreshes the builder handoff
393
+ - \`planning mode\` — optional prompt-level mode for scoping risky or ambiguous work before implementation
394
+ - \`review handoff\` — reviewer reads the selected lane's \`wip.md\`, \`handoff.md\`, latest finalized note, and relevant diffs before appending findings
395
+ - \`address review\` — builder reads reviewer feedback in the selected lane's \`wip.md\` / \`handoff.md\`, responds inline, applies accepted changes, and refreshes the builder handoff
360
396
 
361
397
  These are prompt commands for agent behavior, not \`vibecompass\` CLI subcommands.
362
- Reviewer handback is explicit: the reviewer ends the pass by updating \`${options.rootRelativePath}/sessions/wip.md\` + \`${options.rootRelativePath}/sessions/handoff.md\` and then stopping. Builder close-out uses \`vibecompass close-session\`, which follows the workflow defaults recorded in \`${options.rootRelativePath}/project.yaml\`.
398
+ Reviewer handback is explicit: the reviewer ends the pass by updating the selected lane's \`wip.md\` + \`handoff.md\` and then stopping. Builder close-out uses \`vibecompass close-session --session <lane-id>\`, which follows the workflow defaults recorded in \`${options.rootRelativePath}/project.yaml\`.
399
+ \`vibecompass end-session\` is accepted as an alias for \`vibecompass close-session\`; \`close-session\` remains the canonical command name.
363
400
 
364
401
  ## Startup
365
402
  Before doing substantive work:
366
403
  1. Read \`${options.contextRelativeToToolingRoot}\`.
367
404
  2. Read the latest finalized session note under \`${options.rootRelativePath}/sessions/\`.
368
- 3. If present, read \`${options.rootRelativePath}/sessions/wip.md\`.
369
- 4. If present, read \`${options.rootRelativePath}/sessions/handoff.md\`.
370
- 5. Read the relevant docs under \`${options.rootRelativePath}/architecture/\` and \`${options.rootRelativePath}/decisions/\`.
405
+ 3. If present, read \`${options.rootRelativePath}/sessions/active/index.yaml\` and choose the selected or current lane.
406
+ 4. If present, read \`${options.rootRelativePath}/sessions/active/<lane-id>/wip.md\`.
407
+ 5. If present, read \`${options.rootRelativePath}/sessions/active/<lane-id>/handoff.md\`.
408
+ 6. Read the relevant docs under \`${options.rootRelativePath}/architecture/\` and \`${options.rootRelativePath}/decisions/\`.
371
409
 
372
410
  ## Working rule
373
411
  Treat \`${options.contextRelativeToToolingRoot}\` as the local workflow source of truth for the builder/reviewer protocol and session scratch-file structure. When the package provides \`start-session\` / \`close-session\`, prefer those commands over hand-editing boilerplate.
412
+ Use planning mode when scope is unclear; record the agreed plan in the selected lane's \`wip.md\` before implementing.
413
+ If stale scratch files block \`start-session\`, read them first, then close, recover, move, or delete them intentionally before retrying.
374
414
  - Workflow defaults:
375
415
  ${renderWorkflowDefaults(workflow)}
376
416
  `;
@@ -397,7 +437,7 @@ function renderWorkflowDefaults(workflow) {
397
437
 
398
438
  function describeReviewerHandback(workflow) {
399
439
  if (workflow.reviewerHandback === 'handoff-file') {
400
- return 'update sessions/wip.md and sessions/handoff.md, then stop the review pass';
440
+ return 'update sessions/active/<lane-id>/wip.md and sessions/active/<lane-id>/handoff.md, then stop the review pass';
401
441
  }
402
442
 
403
443
  return workflow.reviewerHandback;
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,33 +52,62 @@ 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 });
60
102
 
61
103
  return {
62
104
  rootDir: normalized.rootDir,
63
105
  toolingRootDir: normalized.toolingRootDir,
64
106
  claudePath: normalized.claudePath,
65
- wipFilePath: normalized.wipFilePath,
66
- handoffFilePath: normalized.handoffFilePath,
107
+ sessionId,
108
+ sessionFilePath: lanePaths.sessionFilePath,
109
+ wipFilePath: lanePaths.wipFilePath,
110
+ handoffFilePath: lanePaths.handoffFilePath,
67
111
  sessionDate,
68
112
  sessionNumber: nextSessionNumber,
69
113
  };
@@ -71,7 +115,7 @@ export async function startProjectSession(options) {
71
115
 
72
116
  export async function closeProjectSession(options) {
73
117
  const normalized = normalizeSessionPaths(options);
74
- const title = requireNonEmptyString(options?.title, 'close-session requires a non-empty title.');
118
+ const title = requireNonEmptyString(options?.title, 'session close requires a non-empty title.');
75
119
  const completed = normalizeStringArray(options?.completed);
76
120
  const models = normalizeStringArray(options?.models);
77
121
  const nextSteps = normalizeStringArray(options?.nextSteps);
@@ -79,25 +123,27 @@ export async function closeProjectSession(options) {
79
123
  const blockers = normalizeStringArray(options?.blockers);
80
124
 
81
125
  if (completed.length === 0) {
82
- throw new Error('close-session requires at least one completed item.');
126
+ throw new Error('session close requires at least one completed item.');
83
127
  }
84
128
 
85
129
  if (nextSteps.length === 0) {
86
- throw new Error('close-session requires at least one next-step entry.');
130
+ throw new Error('session close requires at least one next-step entry.');
87
131
  }
88
132
 
89
133
  await ensureInitializedProjectMemory(normalized.rootDir, { allowMissingProjectFile: true });
90
134
  const claude = await readClaudeFile(normalized.claudePath);
91
135
  const workflowSettings = await readWorkflowSettings(normalized.projectFilePath);
136
+ const sessionId = await resolveExistingSessionId(normalized, options);
137
+ const lanePaths = sessionId ? getLanePaths(normalized, sessionId) : normalized;
92
138
 
93
- if (!(await fileExists(normalized.wipFilePath))) {
139
+ if (!(await fileExists(lanePaths.wipFilePath))) {
94
140
  throw new Error(
95
- `No active session scratchpad exists at ${normalized.wipFilePath}. Start a session before trying to close it.`,
141
+ `No active session scratchpad exists at ${lanePaths.wipFilePath}. Start a session before trying to close it.`,
96
142
  );
97
143
  }
98
144
 
99
- const wipContent = await readFile(normalized.wipFilePath, 'utf8');
100
- const activeSession = parseActiveSession(wipContent);
145
+ const wipContent = await readFile(lanePaths.wipFilePath, 'utf8');
146
+ const activeSession = parseActiveSession(wipContent, lanePaths.wipFilePath);
101
147
  const currentSession = parseCurrentSessionBlock(claude.content);
102
148
  const workedOn =
103
149
  normalizeOptionalString(options?.workedOn) ??
@@ -105,12 +151,12 @@ export async function closeProjectSession(options) {
105
151
  currentSession.workingOn;
106
152
 
107
153
  if (!workedOn) {
108
- throw new Error('close-session could not determine what the session worked on. Pass workedOn explicitly.');
154
+ throw new Error('session close could not determine what the session worked on. Pass workedOn explicitly.');
109
155
  }
110
156
 
111
157
  const sessionSlug = slugifyTitle(title);
112
158
  if (!sessionSlug) {
113
- throw new Error('close-session title must contain at least one letter or number.');
159
+ throw new Error('session close title must contain at least one letter or number.');
114
160
  }
115
161
 
116
162
  const sessionRelativePath = toPosix(
@@ -138,10 +184,15 @@ export async function closeProjectSession(options) {
138
184
  'utf8',
139
185
  );
140
186
 
141
- const hadHandoff = await fileExists(normalized.handoffFilePath);
142
- await rm(normalized.wipFilePath, { force: true });
143
- if (hadHandoff) {
144
- await rm(normalized.handoffFilePath, { force: true });
187
+ const hadHandoff = await fileExists(lanePaths.handoffFilePath);
188
+ if (sessionId) {
189
+ await rm(lanePaths.laneDir, { recursive: true, force: true });
190
+ await removeActiveSessionFromIndex(normalized, sessionId);
191
+ } else {
192
+ await rm(lanePaths.wipFilePath, { force: true });
193
+ if (hadHandoff) {
194
+ await rm(lanePaths.handoffFilePath, { force: true });
195
+ }
145
196
  }
146
197
 
147
198
  const updatedClaude = replaceCurrentSessionBlock(claude.content, {
@@ -166,6 +217,7 @@ export async function closeProjectSession(options) {
166
217
  toolingRootDir: normalized.toolingRootDir,
167
218
  claudePath: normalized.claudePath,
168
219
  projectFilePath: normalized.projectFilePath,
220
+ sessionId,
169
221
  sessionFilePath,
170
222
  sessionRelativePath,
171
223
  sessionDate: activeSession.sessionDate,
@@ -173,12 +225,43 @@ export async function closeProjectSession(options) {
173
225
  workflowGuidance: buildCloseSessionGuidance(workflowSettings),
174
226
  agentFileSync,
175
227
  removedScratchFiles: [
176
- normalized.wipFilePath,
177
- ...(hadHandoff ? [normalized.handoffFilePath] : []),
228
+ lanePaths.wipFilePath,
229
+ ...(hadHandoff ? [lanePaths.handoffFilePath] : []),
178
230
  ],
179
231
  };
180
232
  }
181
233
 
234
+ export async function listProjectSessions(options = {}) {
235
+ const normalized = normalizeSessionPaths(options);
236
+ await ensureInitializedProjectMemory(normalized.rootDir, { allowMissingProjectFile: true });
237
+ const index = await readActiveSessionIndex(normalized);
238
+ const lanes = await listActiveSessionLanes(normalized);
239
+
240
+ return {
241
+ rootDir: normalized.rootDir,
242
+ current: index.current ?? lanes[0]?.id ?? null,
243
+ lanes,
244
+ };
245
+ }
246
+
247
+ export async function switchProjectSession(options = {}) {
248
+ const normalized = normalizeSessionPaths(options);
249
+ const sessionId = validateLaneId(requireNonEmptyString(options?.sessionId, 'switch-session requires a session ID.'));
250
+ const lanes = await listActiveSessionLanes(normalized);
251
+
252
+ if (!lanes.some((lane) => lane.id === sessionId)) {
253
+ throw new Error(`Active session lane "${sessionId}" does not exist.`);
254
+ }
255
+
256
+ await upsertActiveSessionIndex(normalized, null, { current: sessionId });
257
+
258
+ return {
259
+ rootDir: normalized.rootDir,
260
+ current: sessionId,
261
+ lanes,
262
+ };
263
+ }
264
+
182
265
  async function syncAgentInstructionFilesSafely(options) {
183
266
  try {
184
267
  return await syncAgentInstructionFiles(options);
@@ -203,20 +286,32 @@ async function syncAgentInstructionFilesSafely(options) {
203
286
 
204
287
  function normalizeSessionPaths(options) {
205
288
  const cwd = options?.cwd ? path.resolve(options.cwd) : process.cwd();
289
+ const rootDir = path.resolve(cwd, options?.rootDir ?? '.compass');
290
+ const toolingRootDir = options?.toolingRootDir
291
+ ? path.resolve(cwd, options.toolingRootDir)
292
+ : cwd;
206
293
 
207
294
  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'),
295
+ rootDir,
296
+ toolingRootDir,
297
+ projectFilePath: path.resolve(rootDir, 'project.yaml'),
298
+ claudePath: path.resolve(toolingRootDir, 'CLAUDE.md'),
299
+ sessionsDir: path.resolve(rootDir, 'sessions'),
300
+ activeSessionsDir: path.resolve(rootDir, 'sessions/active'),
301
+ activeSessionsIndexPath: path.resolve(rootDir, 'sessions/active/index.yaml'),
302
+ wipFilePath: path.resolve(rootDir, 'sessions/wip.md'),
303
+ handoffFilePath: path.resolve(rootDir, 'sessions/handoff.md'),
304
+ };
305
+ }
306
+
307
+ function getLanePaths(normalized, sessionId) {
308
+ const laneDir = path.resolve(normalized.activeSessionsDir, sessionId);
309
+
310
+ return {
311
+ laneDir,
312
+ sessionFilePath: path.resolve(laneDir, 'session.yaml'),
313
+ wipFilePath: path.resolve(laneDir, 'wip.md'),
314
+ handoffFilePath: path.resolve(laneDir, 'handoff.md'),
220
315
  };
221
316
  }
222
317
 
@@ -304,11 +399,166 @@ function replaceCurrentSessionBlock(content, fields) {
304
399
 
305
400
  async function getNextSessionNumber(sessionsDir, sessionDate) {
306
401
  const sessions = await listFinalizedSessions(sessionsDir);
307
- const todaysSessions = sessions.filter((session) => session.sessionDate === sessionDate);
402
+ const activeSessions = await listActiveSessionLanes({ sessionsDir, activeSessionsDir: path.join(sessionsDir, 'active') });
403
+ const todaysSessions = [
404
+ ...sessions,
405
+ ...activeSessions,
406
+ ].filter((session) => session.sessionDate === sessionDate);
308
407
  const highestNumber = todaysSessions.reduce((max, session) => Math.max(max, session.sessionNumber), 0);
309
408
  return highestNumber + 1;
310
409
  }
311
410
 
411
+ async function resolveStartSessionId(normalized, options) {
412
+ const explicitId = normalizeOptionalString(options?.sessionId);
413
+ if (explicitId) {
414
+ return validateLaneId(explicitId);
415
+ }
416
+
417
+ const lanes = await listActiveSessionLanes(normalized);
418
+ if (lanes.length > 0) {
419
+ throw new Error('Active session lanes already exist. Pass --id to start another lane explicitly.');
420
+ }
421
+
422
+ if ((await fileExists(normalized.wipFilePath)) || (await fileExists(normalized.handoffFilePath))) {
423
+ throw new Error(
424
+ `Legacy active session scratch files already exist under ${normalized.sessionsDir}. Close or recover that session before starting a lane.`,
425
+ );
426
+ }
427
+
428
+ return 'default';
429
+ }
430
+
431
+ async function resolveExistingSessionId(normalized, options) {
432
+ const explicitId = normalizeOptionalString(options?.sessionId);
433
+ if (explicitId) {
434
+ return validateLaneId(explicitId);
435
+ }
436
+
437
+ const lanes = await listActiveSessionLanes(normalized);
438
+ if (lanes.length === 1) {
439
+ return lanes[0].id;
440
+ }
441
+
442
+ if (lanes.length > 1) {
443
+ throw new Error('Multiple active session lanes exist. Pass --session to choose which lane to close.');
444
+ }
445
+
446
+ return null;
447
+ }
448
+
449
+ async function listActiveSessionLanes(normalized) {
450
+ try {
451
+ const entries = await readdir(normalized.activeSessionsDir, { withFileTypes: true });
452
+ const lanes = [];
453
+ for (const entry of entries) {
454
+ if (!entry.isDirectory()) {
455
+ continue;
456
+ }
457
+
458
+ const sessionId = entry.name;
459
+ const lanePaths = getLanePaths(normalized, sessionId);
460
+ const metadata = await readLaneMetadata(lanePaths.sessionFilePath);
461
+ const wipSession = await readLaneWipSession(lanePaths.wipFilePath);
462
+ lanes.push({
463
+ id: sessionId,
464
+ status: metadata.status ?? 'active',
465
+ workingOn: metadata.workingOn ?? null,
466
+ features: metadata.features,
467
+ repos: metadata.repos,
468
+ claims: metadata.claims,
469
+ sessionDate: metadata.sessionDate ?? wipSession?.sessionDate ?? null,
470
+ sessionNumber: metadata.sessionNumber ?? wipSession?.sessionNumber ?? 0,
471
+ });
472
+ }
473
+
474
+ return lanes.sort((left, right) => left.id.localeCompare(right.id));
475
+ } catch (error) {
476
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
477
+ return [];
478
+ }
479
+
480
+ throw error;
481
+ }
482
+ }
483
+
484
+ async function readLaneWipSession(wipFilePath) {
485
+ try {
486
+ return parseActiveSession(await readFile(wipFilePath, 'utf8'), wipFilePath);
487
+ } catch {
488
+ return null;
489
+ }
490
+ }
491
+
492
+ async function readLaneMetadata(sessionFilePath) {
493
+ try {
494
+ const content = await readFile(sessionFilePath, 'utf8');
495
+ const data = parseSimpleYaml(content, { sourceName: sessionFilePath });
496
+ return {
497
+ status: normalizeOptionalString(data.status),
498
+ workingOn: normalizeOptionalString(data.working_on),
499
+ features: normalizeStringArray(data.feature_slugs),
500
+ repos: normalizeStringArray(data.repos),
501
+ claims: normalizeStringArray(data.claimed_paths),
502
+ sessionDate: normalizeOptionalString(data.session_date),
503
+ sessionNumber: typeof data.session_number === 'number' ? data.session_number : Number(data.session_number) || null,
504
+ };
505
+ } catch {
506
+ return {
507
+ status: null,
508
+ workingOn: null,
509
+ features: [],
510
+ repos: [],
511
+ claims: [],
512
+ sessionDate: null,
513
+ sessionNumber: null,
514
+ };
515
+ }
516
+ }
517
+
518
+ async function readActiveSessionIndex(normalized) {
519
+ try {
520
+ const data = parseSimpleYaml(await readFile(normalized.activeSessionsIndexPath, 'utf8'), {
521
+ sourceName: normalized.activeSessionsIndexPath,
522
+ });
523
+ return {
524
+ current: normalizeOptionalString(data.current),
525
+ };
526
+ } catch {
527
+ return {
528
+ current: null,
529
+ };
530
+ }
531
+ }
532
+
533
+ async function upsertActiveSessionIndex(normalized, lane, options = {}) {
534
+ const currentIndex = await readActiveSessionIndex(normalized);
535
+ const lanes = await listActiveSessionLanes(normalized);
536
+ const laneMap = new Map(lanes.map((item) => [item.id, item]));
537
+ if (lane) {
538
+ laneMap.set(lane.id, {
539
+ id: lane.id,
540
+ status: lane.status,
541
+ workingOn: lane.workingOn,
542
+ });
543
+ }
544
+
545
+ const current = options.current ?? currentIndex.current ?? lane?.id ?? null;
546
+ await mkdir(normalized.activeSessionsDir, { recursive: true });
547
+ await writeFile(normalized.activeSessionsIndexPath, renderActiveSessionIndex(current, [...laneMap.values()]), 'utf8');
548
+ }
549
+
550
+ async function removeActiveSessionFromIndex(normalized, sessionId) {
551
+ const index = await readActiveSessionIndex(normalized);
552
+ const lanes = (await listActiveSessionLanes(normalized)).filter((lane) => lane.id !== sessionId);
553
+ const current = index.current === sessionId ? lanes[0]?.id ?? null : index.current;
554
+ if (lanes.length === 0) {
555
+ await rm(normalized.activeSessionsIndexPath, { force: true });
556
+ return;
557
+ }
558
+
559
+ await writeFile(normalized.activeSessionsIndexPath, renderActiveSessionIndex(current, lanes), 'utf8');
560
+ }
561
+
312
562
  async function listFinalizedSessions(sessionsDir) {
313
563
  try {
314
564
  const entries = await readdir(sessionsDir, { withFileTypes: true });
@@ -337,10 +587,10 @@ async function listFinalizedSessions(sessionsDir) {
337
587
  }
338
588
  }
339
589
 
340
- function parseActiveSession(wipContent) {
590
+ function parseActiveSession(wipContent, wipFilePath = 'sessions/active/<lane-id>/wip.md') {
341
591
  const match = wipContent.match(WIP_HEADER_PATTERN);
342
592
  if (!match) {
343
- throw new Error('sessions/wip.md is missing the expected "# WIP — YYYY-MM-DD (session N)" header.');
593
+ throw new Error(`${wipFilePath} is missing the expected "# WIP — YYYY-MM-DD (session N)" header.`);
344
594
  }
345
595
 
346
596
  return {
@@ -349,6 +599,19 @@ function parseActiveSession(wipContent) {
349
599
  };
350
600
  }
351
601
 
602
+ function validateLaneId(value) {
603
+ const normalized = value.trim();
604
+ if (!LANE_ID_PATTERN.test(normalized)) {
605
+ throw new Error('Session lane ID must be a lowercase slug 3-64 characters long using letters, numbers, and hyphens.');
606
+ }
607
+
608
+ if (RESERVED_LANE_IDS.has(normalized)) {
609
+ throw new Error(`Session lane ID "${normalized}" is reserved.`);
610
+ }
611
+
612
+ return normalized;
613
+ }
614
+
352
615
  function extractSectionBody(content, sectionTitle) {
353
616
  const heading = `## ${sectionTitle}`.toLowerCase();
354
617
  const lines = content.split('\n');
@@ -407,6 +670,8 @@ function findCurrentSessionFence(content) {
407
670
  function renderWipTemplate(options) {
408
671
  return `# WIP — ${options.sessionDate} (session ${options.sessionNumber})
409
672
 
673
+ Session lane: ${options.sessionId}
674
+
410
675
  ## Working on
411
676
  ${options.workingOn}
412
677
 
@@ -423,6 +688,8 @@ ${options.workingOn}
423
688
  function renderHandoffTemplate(options) {
424
689
  return `# Handoff — ${options.sessionDate} (session ${options.sessionNumber})
425
690
 
691
+ Session lane: ${options.sessionId}
692
+
426
693
  ## Builder → Reviewer
427
694
 
428
695
  ### What changed
@@ -444,6 +711,53 @@ function renderHandoffTemplate(options) {
444
711
  `;
445
712
  }
446
713
 
714
+ function renderLaneMetadata(options) {
715
+ return [
716
+ `id: ${options.sessionId}`,
717
+ 'status: active',
718
+ `session_date: ${options.sessionDate}`,
719
+ `session_number: ${options.sessionNumber}`,
720
+ `working_on: ${quoteYamlString(options.workingOn)}`,
721
+ renderYamlArray('feature_slugs', options.features),
722
+ renderYamlArray('repos', options.repos),
723
+ renderYamlArray('claimed_paths', options.claims),
724
+ `started_at: ${new Date().toISOString()}`,
725
+ 'decision_snapshot:',
726
+ ' highest_decision_id: null',
727
+ '',
728
+ ].join('\n');
729
+ }
730
+
731
+ function renderActiveSessionIndex(current, lanes) {
732
+ const lines = [
733
+ `current: ${current ?? 'null'}`,
734
+ 'lanes:',
735
+ ];
736
+
737
+ for (const lane of lanes.sort((left, right) => left.id.localeCompare(right.id))) {
738
+ lines.push(` - id: ${lane.id}`);
739
+ lines.push(` status: ${lane.status ?? 'active'}`);
740
+ lines.push(` working_on: ${quoteYamlString(lane.workingOn ?? '')}`);
741
+ }
742
+
743
+ return `${lines.join('\n')}\n`;
744
+ }
745
+
746
+ function renderYamlArray(key, values) {
747
+ if (!values || values.length === 0) {
748
+ return `${key}: []`;
749
+ }
750
+
751
+ return [
752
+ `${key}:`,
753
+ ...values.map((value) => ` - ${quoteYamlString(value)}`),
754
+ ].join('\n');
755
+ }
756
+
757
+ function quoteYamlString(value) {
758
+ return JSON.stringify(value);
759
+ }
760
+
447
761
  function renderSessionNote(options) {
448
762
  return `# Session — ${options.sessionDate}-${options.sessionNumber} — ${options.title}
449
763
 
package/src/workflow.js CHANGED
@@ -77,7 +77,7 @@ export function buildWorkflowMetadata(existingMetadata, overrides = {}) {
77
77
  export function buildCloseSessionGuidance(workflowSettings) {
78
78
  const guidance = [];
79
79
 
80
- guidance.push('Reviewer handback stays in sessions/wip.md and sessions/handoff.md before close-session runs.');
80
+ guidance.push('Reviewer handback stays in the selected sessions/active/<lane-id>/wip.md and handoff.md before close-session runs.');
81
81
 
82
82
  if (workflowSettings.closeSession.refreshArchitectureDocs) {
83
83
  guidance.push('Refresh any relevant architecture docs before finalizing the session.');