@vibecompass/vibecompass 0.13.0 → 0.14.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/CHANGELOG.md CHANGED
@@ -1,6 +1,47 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.14.0 - 2026-09-23
4
+
5
+ - Add `continue-session [<lane-id>]` (D-353): resume an already-open lane
6
+ through the D-277 resolver (explicit id → worktree marker → single lane).
7
+ The lane keeps its opening date and session number; the command records
8
+ `resumed_at` / `resume_count` in `session.yaml`, appends a dated `Resumed`
9
+ line to `wip.md ## Log`, moves the index `current` hint and the Current
10
+ session block to the lane (showing the resume date), optionally refreshes
11
+ `--working-on`, prints the wip/handoff/latest-note paths plus runtime and
12
+ git-binding reminders, re-emits the docs-update staleness set as
13
+ `Resume staleness` warnings, and supports `--json`. With two or more active
14
+ lanes and no selection, or with an unknown explicit id, it fails closed and
15
+ prints the lane inventory. All inputs are read and outputs prepared before
16
+ the first write, so a refusal (missing CLAUDE.md, unparseable scratch or
17
+ metadata) changes no bytes; `--working-on` text is written literally,
18
+ hand-spaced or CRLF metadata keys are replaced rather than duplicated, and
19
+ Current-session fields carry over only for the exact same lane id.
20
+ - `list-sessions` renders the shared lane inventory (opened date/session
21
+ number, resume count, branch/worktrees, port, last log line, current hint)
22
+ and accepts `--json`; the header now says `current hint`.
23
+ - `start-session --id <existing-lane>` fails closed before any preflight and
24
+ names `continue-session`; starting a lane while others are active warns with
25
+ the resume alternative.
26
+ - Generated `context.md` gains an "Open, resume, or choose" protocol and a
27
+ `continue session` prompt command; the managed agent block gains the
28
+ matching hard rule (resume instead of re-start; with 2+ lanes inventory and
29
+ ask, never adopt the current hint silently).
30
+ - Report byte-identical managed agent instruction files as `unchanged` from
31
+ `sync-agents`, `refresh-workflow`, and session lifecycle syncs instead of
32
+ reporting a no-op as `update` / `dry-run-update`; missing or incomplete
33
+ managed markers remain warning-only and are never overwritten.
34
+
35
+ ## 0.13.1 - 2026-09-11
36
+
37
+ - Add strict backward compatibility for intentional legacy recovery checkpoints
38
+ at root `sessions/YYYY-MM-DD.md` paths with an exact
39
+ `# Recovery checkpoint — YYYY-MM-DD — <title>` H1; project them with their
40
+ matching `session_date`, title, and
41
+ `session_number: null` without inventing numbered-session chronology.
42
+ - Preserve fail-closed behavior for malformed, mismatched, untitled, or nested
43
+ recovery checkpoints and all existing numbered-session behavior, with focused
44
+ parser, manifest, read-model, and session-lifecycle regression coverage.
4
45
 
5
46
  ## 0.13.0 - 2026-07-11
6
47
 
package/README.md CHANGED
@@ -128,11 +128,21 @@ npx -y @vibecompass/vibecompass@latest status --root .compass
128
128
 
129
129
  # Session workflow
130
130
  npx -y @vibecompass/vibecompass@latest start-session --root .compass --id auth-flow --working-on "Auth flow"
131
- npx -y @vibecompass/vibecompass@latest list-sessions --root .compass
131
+ npx -y @vibecompass/vibecompass@latest list-sessions --root .compass # --json for the machine-readable lane inventory
132
+ npx -y @vibecompass/vibecompass@latest continue-session --root .compass # resume the open lane (D-353); name it with 2+ lanes: continue-session auth-flow
132
133
  npx -y @vibecompass/vibecompass@latest switch-session auth-flow --root .compass
133
134
  npx -y @vibecompass/vibecompass@latest docs-update --root .compass --session auth-flow
134
135
  npx -y @vibecompass/vibecompass@latest close-session --root .compass --session auth-flow --title "Auth Flow" --completed "Built auth" --architecture-docs updated --decision-log not-needed --session-maintenance updated --next-step "Review"
135
136
 
137
+ # Resuming (D-353): an active lane is an open session, not a crash. continue-session
138
+ # keeps the lane's opening date and session number, records resumed_at/resume_count
139
+ # in session.yaml, appends a dated "Resumed" line to wip.md, moves the index current
140
+ # hint and the Current session block to the lane, and prints the files to read next
141
+ # plus resume-staleness warnings. With two or more active lanes and no worktree
142
+ # marker it fails closed and prints the lane inventory instead of guessing;
143
+ # start-session --id <existing-lane> fails closed and points at continue-session.
144
+ npx -y @vibecompass/vibecompass@latest continue-session auth-flow --root .compass --working-on "Auth flow, phase 2"
145
+
136
146
  # Optional git binding for a lane (D-281): create/reuse a branch in each bound
137
147
  # repo; --worktree provisions per-repo worktrees under
138
148
  # <workspace>/worktrees/<lane-id>/<repo-id> with the lane marker in the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibecompass/vibecompass",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Local-first project-memory core for VibeCompass",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -8,7 +8,7 @@ import { planDocsUpdate, renderDocsUpdatePlan } from './docs-update.js';
8
8
  import { resolveConnectHostedCliOptions, resolveInitCliOptions } from './setup.js';
9
9
  import { readFile } from 'node:fs/promises';
10
10
  import path from 'node:path';
11
- import { closeProjectSession, listProjectSessions, readLaneEnvironment, rebuildActiveSessionIndex, startProjectSession, switchProjectSession, writeLaneMarkerForSession } from './session.js';
11
+ import { closeProjectSession, continueProjectSession, listProjectSessions, readLaneEnvironment, rebuildActiveSessionIndex, renderLaneInventoryLines, startProjectSession, switchProjectSession, writeLaneMarkerForSession } from './session.js';
12
12
  import { appendDecisionEntry, formatDecisionId, readNextDecisionId } from './decisions.js';
13
13
  import { checkGroupedDecisionIndex, refreshGroupedDecisionIndex, resolveDefaultIndexGroupLabel } from './decision-index.js';
14
14
  import { resolveLaneMarkerContext, resolveLaneSelection } from './lane-marker.js';
@@ -298,20 +298,66 @@ export async function runCli(argv, io = createDefaultIo(), runtime = {}) {
298
298
  return 0;
299
299
  }
300
300
 
301
+ if (parsed.command === 'continue-session') {
302
+ await writeCompatibilityPreflightWarnings(io, parsed.options, runtime);
303
+ const result = await continueProjectSession({
304
+ ...parsed.options,
305
+ ...(runtime.cwd ? { cwd: runtime.cwd } : {}),
306
+ });
307
+ if (parsed.options.json) {
308
+ const { manifest: _manifest, docsUpdatePlan: _plan, agentFileSync, ...json } = result;
309
+ io.stdout.write(`${JSON.stringify({ ...json, agentFileSync: agentFileSync?.results ?? [] }, null, 2)}\n`);
310
+ writeWarnings(io, result.warnings);
311
+ return 0;
312
+ }
313
+ {
314
+ // Text mode.
315
+ io.stdout.write(`Continued lane ${result.sessionId} (session ${result.sessionDate}-${result.sessionNumber}, resume #${result.resumeCount}${result.laneAgeDays != null ? `, opened ${result.laneAgeDays} day${result.laneAgeDays === 1 ? '' : 's'} ago` : ''}; selected via ${result.selectionSource})\n`);
316
+ io.stdout.write(`Working on: ${result.workingOn}${result.workingOnUpdated ? ' (updated)' : ''}\n`);
317
+ io.stdout.write(`Updated ${result.claudePath}\n`);
318
+ io.stdout.write('Read next:\n');
319
+ io.stdout.write(`- ${result.wipFilePath}\n`);
320
+ io.stdout.write(`- ${result.handoffFilePath}\n`);
321
+ if (result.latestFinalizedSessionPath) {
322
+ io.stdout.write(`- ${result.latestFinalizedSessionPath} (latest finalized note)\n`);
323
+ }
324
+ if (result.gitBinding) {
325
+ const worktreeRepos = Object.keys(result.gitBinding.worktrees);
326
+ io.stdout.write(`Git binding: branch "${result.gitBinding.branch}"${worktreeRepos.length > 0 ? ` with worktrees for ${worktreeRepos.join(', ')} under ${result.gitBinding.worktreeContainer}` : ' (no worktrees)'}\n`);
327
+ }
328
+ if (result.runtime) {
329
+ io.stdout.write(`Runtime: port ${result.runtime.port}, temp dir ${result.runtime.tmpDir}\n`);
330
+ io.stdout.write(`Export into a shell with: eval "$(vibecompass lane-env${result.otherLanes.length > 0 ? ` --session ${result.sessionId}` : ''})" (D-282).\n`);
331
+ }
332
+ if (result.otherLanes.length > 0) {
333
+ io.stdout.write(`Other active lanes (${result.otherLanes.length}) — pass --session ${result.sessionId} to lane-scoped commands, or run them from this lane's worktree:\n`);
334
+ for (const lane of result.otherLanes) {
335
+ io.stdout.write(`- ${lane.id}: ${lane.workingOn ?? 'No summary recorded'}\n`);
336
+ }
337
+ }
338
+ }
339
+ writeWarnings(io, result.warnings);
340
+ writeAgentFileSyncResult(io, result.agentFileSync);
341
+ return 0;
342
+ }
343
+
301
344
  if (parsed.command === 'list-sessions') {
302
345
  await writeCompatibilityPreflightWarnings(io, parsed.options, runtime);
303
346
  const result = await listProjectSessions({
304
347
  ...parsed.options,
305
348
  ...(runtime.cwd ? { cwd: runtime.cwd } : {}),
306
349
  });
307
- io.stdout.write(`Active session lanes${result.current ? ` (current: ${result.current})` : ''}:\n`);
308
- if (result.lanes.length === 0) {
309
- io.stdout.write('- None\n');
310
- } else {
311
- for (const lane of result.lanes) {
312
- const marker = lane.id === result.current ? '*' : '-';
313
- io.stdout.write(`${marker} ${lane.id}: ${lane.workingOn ?? 'No summary recorded'}\n`);
314
- }
350
+ if (parsed.options.json) {
351
+ io.stdout.write(`${JSON.stringify({ root_dir: result.rootDir, ...result.inventory }, null, 2)}\n`);
352
+ return 0;
353
+ }
354
+ for (const line of renderLaneInventoryLines(result.inventory)) {
355
+ io.stdout.write(`${line}\n`);
356
+ }
357
+ if (result.inventory.lanes.length > 1) {
358
+ io.stdout.write('Multiple lanes are active: resume one with `vibecompass continue-session <lane-id>`; the current hint is not a selection (D-277/D-353).\n');
359
+ } else if (result.inventory.lanes.length === 1) {
360
+ io.stdout.write(`Resume with \`vibecompass continue-session\` (single active lane resolves implicitly; D-353).\n`);
315
361
  }
316
362
  return 0;
317
363
  }
@@ -736,6 +782,10 @@ export function parseCliArgs(argv) {
736
782
  return parseListSessionsArgs(rest);
737
783
  }
738
784
 
785
+ if (command === 'continue-session') {
786
+ return parseContinueSessionArgs(rest);
787
+ }
788
+
739
789
  if (command === 'switch-session') {
740
790
  return parseSwitchSessionArgs(rest);
741
791
  }
@@ -1393,6 +1443,11 @@ function parseListSessionsArgs(argv) {
1393
1443
  throw new Error(`Unexpected argument "${token}".`);
1394
1444
  }
1395
1445
 
1446
+ if (token === '--json') {
1447
+ parsed.json = true;
1448
+ continue;
1449
+ }
1450
+
1396
1451
  const value = argv[index + 1];
1397
1452
  if (value === undefined) {
1398
1453
  throw new Error(`Flag "${token}" requires a value.`);
@@ -1636,6 +1691,77 @@ function parseNextDecisionIdArgs(argv) {
1636
1691
  return { command: 'next-decision-id', options: parsed };
1637
1692
  }
1638
1693
 
1694
+ function parseContinueSessionArgs(argv) {
1695
+ const parsed = {};
1696
+ const positional = [];
1697
+
1698
+ for (let index = 0; index < argv.length; index += 1) {
1699
+ const token = argv[index];
1700
+
1701
+ if (!token.startsWith('--')) {
1702
+ positional.push(token);
1703
+ continue;
1704
+ }
1705
+
1706
+ if (token === '--json') {
1707
+ parsed.json = true;
1708
+ continue;
1709
+ }
1710
+
1711
+ const value = argv[index + 1];
1712
+ if (value === undefined) {
1713
+ throw new Error(`Flag "${token}" requires a value.`);
1714
+ }
1715
+ index += 1;
1716
+
1717
+ switch (token) {
1718
+ case '--root':
1719
+ parsed.rootDir = value;
1720
+ break;
1721
+ case '--tooling-root':
1722
+ parsed.toolingRootDir = value;
1723
+ break;
1724
+ case '--session':
1725
+ parsed.sessionId = value;
1726
+ break;
1727
+ case '--working-on':
1728
+ parsed.workingOn = value;
1729
+ break;
1730
+ case '--date':
1731
+ parsed.date = value;
1732
+ break;
1733
+ case '--last-thing-completed':
1734
+ parsed.lastThingCompleted = value;
1735
+ break;
1736
+ case '--blockers':
1737
+ parsed.blockers = value;
1738
+ break;
1739
+ case '--next-session-should':
1740
+ parsed.nextSessionShould = value;
1741
+ break;
1742
+ default:
1743
+ throw new Error(`Unknown flag "${token}".`);
1744
+ }
1745
+ }
1746
+
1747
+ if (positional.length > 1) {
1748
+ throw new Error(`Unexpected argument "${positional[1]}".`);
1749
+ }
1750
+
1751
+ if (parsed.sessionId && positional[0] && parsed.sessionId !== positional[0]) {
1752
+ throw new Error(`Conflicting lane IDs: positional "${positional[0]}" and --session "${parsed.sessionId}".`);
1753
+ }
1754
+
1755
+ if (!parsed.sessionId && positional[0]) {
1756
+ parsed.sessionId = positional[0];
1757
+ }
1758
+
1759
+ return {
1760
+ command: 'continue-session',
1761
+ options: parsed,
1762
+ };
1763
+ }
1764
+
1639
1765
  function parseSwitchSessionArgs(argv) {
1640
1766
  const parsed = {};
1641
1767
  const positional = [];
@@ -2369,7 +2495,8 @@ function usageText() {
2369
2495
  ' vibecompass start-session --id <lane-id> --working-on <text> [--branch <name> [--worktree]] [options]',
2370
2496
  ' vibecompass close-session --title <text> --completed <text> --architecture-docs <status> --decision-log <status> --session-maintenance <status> [options]',
2371
2497
  ' vibecompass end-session --title <text> --completed <text> --architecture-docs <status> --decision-log <status> --session-maintenance <status> [options] # alias',
2372
- ' vibecompass list-sessions [options]',
2498
+ ' vibecompass continue-session [<lane-id>] [--session <lane-id>] [--working-on <text>] [--json] [options]',
2499
+ ' vibecompass list-sessions [--json] [options]',
2373
2500
  ' vibecompass switch-session <id> [options]',
2374
2501
  ' vibecompass rebuild-active-index [--current <lane-id>] [options]',
2375
2502
  ' vibecompass write-lane-marker --session <lane-id> [--dir <path>] [options]',
@@ -2481,9 +2608,21 @@ function usageText() {
2481
2608
  ' --last-thing-completed <text> Optional override for the CLAUDE.md completed summary',
2482
2609
  ' --next-session-should <text> Optional override for the CLAUDE.md next-session summary',
2483
2610
  '',
2611
+ 'Continue-session options (D-353):',
2612
+ ' --root <path> Project-memory root. Explicit --root wins; otherwise the nearest worktree lane marker supplies it, else .compass',
2613
+ ' --tooling-root <path> Tooling root that contains CLAUDE.md. Defaults to cwd; follows the memory root placement when a marker supplies the root',
2614
+ ' --session <lane-id> Lane to resume; positional ID is also accepted. Omitted: nearest worktree lane marker, else the single active lane; 2+ active lanes fail closed and print the lane inventory (D-277)',
2615
+ ' --working-on <text> Optional refreshed lane scope; rewrites working_on in session.yaml, the index, wip.md, and CLAUDE.md',
2616
+ ' --date <YYYY-MM-DD> Optional explicit resume date (the lane keeps its opening date and session number)',
2617
+ ' --last-thing-completed <text> Optional override for the CLAUDE.md current-session block',
2618
+ ' --blockers <text> Optional current blockers summary',
2619
+ ' --next-session-should <text> Optional current-session handoff summary',
2620
+ ' --json Print the resume result (lane, paths, runtime, other lanes, warnings) as JSON',
2621
+ '',
2484
2622
  'List/switch-session options:',
2485
2623
  ' --root <path> Project-memory root. Defaults to .compass',
2486
2624
  ' --session <lane-id> Lane ID for switch-session; positional ID is also accepted',
2625
+ ' --json list-sessions: print the lane inventory as JSON',
2487
2626
  '',
2488
2627
  'Rebuild-active-index options:',
2489
2628
  ' --root <path> Project-memory root. Defaults to .compass',
@@ -86,11 +86,19 @@ async function syncAgentInstructionFilesLocked(options, cwd, rootDir) {
86
86
  const applied = applyManagedBlock(existingContent, generatedContent, {
87
87
  adoptExisting,
88
88
  });
89
+ const changed = applied.warning === null && applied.content !== existingContent;
89
90
  const conflicts = applied.status === 'adopt'
90
91
  ? scanPotentialWorkflowConflicts(existingContent)
91
92
  : [];
93
+ let status = applied.status;
92
94
 
93
- if (!dryRun && applied.warning === null && applied.content !== existingContent) {
95
+ if (applied.warning === null) {
96
+ status = changed
97
+ ? (dryRun ? `dry-run-${applied.status}` : applied.status)
98
+ : 'unchanged';
99
+ }
100
+
101
+ if (!dryRun && changed) {
94
102
  await mkdir(path.dirname(outputPath), { recursive: true });
95
103
  await writeFile(outputPath, applied.content, 'utf8');
96
104
  }
@@ -99,9 +107,9 @@ async function syncAgentInstructionFilesLocked(options, cwd, rootDir) {
99
107
  format: format.name,
100
108
  path: outputPath,
101
109
  relativePath: toPosix(path.relative(toolingRootDir, outputPath)),
102
- status: dryRun && applied.warning === null ? `dry-run-${applied.status}` : applied.status,
110
+ status,
103
111
  warning: applied.warning,
104
- changed: applied.warning === null && applied.content !== existingContent,
112
+ changed,
105
113
  conflicts,
106
114
  });
107
115
  }
@@ -47,6 +47,7 @@ export function renderSharedInstructionBody(context, options = {}) {
47
47
  '- Lane selection follows D-277: an explicit `--session` wins, then the nearest worktree lane marker (`.vibecompass-lane.yaml`, walking up from cwd), then the single active lane. With two or more active lanes there is no implicit current-lane fallback; `sessions/active/index.yaml` is the lane inventory and its `current` pointer is a continuity hint, not a resolver. Tool-specific Current session blocks are continuity summaries.',
48
48
  '- Git binding is opt-in per lane (D-281): `start-session --branch <name> --repo <id> [--worktree]` creates or reuses the branch in each bound repo, and `--worktree` provisions per-repo worktrees under `<workspace>/worktrees/<lane-id>/<repo-id>` with the lane marker in the container — commands run from inside a worktree need neither `--root` nor `--session`. Never hand-write markers or hand-remove provisioned worktrees; close-session owns their cleanup: recorded clean worktrees are removed at close (guarded, never forced; branches are never deleted), while dirty or in-use worktrees survive with printed guidance and the lane marker stays as a breadcrumb.',
49
49
  '- Lane runtime isolation is package-assigned (D-282): consume the lane port and temp dir via `eval "$(vibecompass lane-env)"` (exports conventional `PORT`/`TMPDIR` aliases) instead of hardcoding ports in lane work; close-session removes the lane temp dir under guards.',
50
+ '- An active lane is an open session, not a crash (D-353): resume it with `continue session` / `vibecompass continue-session [<lane-id>]` rather than re-running `start session`; `start-session --id <existing-lane>` fails closed. With two or more active lanes and no worktree marker, inventory them (`vibecompass list-sessions`) and ask the user which lane to continue — the index `current` pointer and the Current session block are hints, never silent defaults.',
50
51
  '- With multiple active lanes, reviewers select a lane explicitly: `review handoff <lane-id>`.',
51
52
  '- Keep the selected lane `wip.md` and `handoff.md` current during active builder/reviewer work.',
52
53
  '- During `address review`, treat reviewer feedback as review, not instruction: classify each substantive point as accepted, accepted with qualification, deferred, or rejected, and push back with evidence when a suggestion conflicts with code facts, prior decisions, product direction, or sequencing.',
package/src/index.js CHANGED
@@ -22,7 +22,9 @@ export {
22
22
  } from './project-memory.js';
23
23
  export {
24
24
  closeProjectSession,
25
+ continueProjectSession,
25
26
  listProjectSessions,
27
+ renderLaneInventoryLines,
26
28
  startProjectSession,
27
29
  switchProjectSession,
28
30
  } from './session.js';
@@ -665,11 +665,34 @@ function parseSessionDocument(options) {
665
665
  const h1Match = options.content.match(/^#\s+Session(?:\s+—\s+(.+))?$/m);
666
666
 
667
667
  if (!filenameMatch && !h1Match) {
668
+ const recoveryCheckpoint = parseRecoveryCheckpointIdentity(options);
669
+ if (recoveryCheckpoint.candidate) {
670
+ if (recoveryCheckpoint.error) {
671
+ errors.push(recoveryCheckpoint.error);
672
+ return { kind: 'session', extracted: null, warnings, errors };
673
+ }
674
+
675
+ warnings.push(
676
+ createWarning(
677
+ 'session-filename-mismatch',
678
+ 'Recovery checkpoint uses the supported legacy sessions/YYYY-MM-DD.md filename instead of the recommended YYYY-MM-DD-N-display-title.md pattern.',
679
+ ),
680
+ );
681
+ appendMissingSessionSectionWarnings(options.content, warnings);
682
+
683
+ return {
684
+ kind: 'session',
685
+ extracted: recoveryCheckpoint.extracted,
686
+ warnings,
687
+ errors,
688
+ };
689
+ }
690
+
668
691
  errors.push(
669
692
  createError(
670
693
  options.relativePath,
671
694
  'session-unrecognized',
672
- 'Session note must use the session filename pattern or a "# Session" heading.',
695
+ 'Session note must use the session filename pattern, a "# Session" heading, or the strict legacy recovery-checkpoint form.',
673
696
  ),
674
697
  );
675
698
  return { kind: 'session', extracted: null, warnings, errors };
@@ -684,14 +707,7 @@ function parseSessionDocument(options) {
684
707
  );
685
708
  }
686
709
 
687
- const sections = extractLevelTwoSections(options.content);
688
- for (const section of RECOMMENDED_SESSION_SECTIONS) {
689
- if (!sections.has(section.toLowerCase())) {
690
- warnings.push(
691
- createWarning('session-missing-section', `Session note is missing recommended section "## ${section}".`),
692
- );
693
- }
694
- }
710
+ appendMissingSessionSectionWarnings(options.content, warnings);
695
711
 
696
712
  const title = extractSessionTitle(options.content, filenameMatch);
697
713
 
@@ -707,6 +723,98 @@ function parseSessionDocument(options) {
707
723
  };
708
724
  }
709
725
 
726
+ function parseRecoveryCheckpointIdentity(options) {
727
+ const filenameMatch = options.relativePath.match(/^sessions\/(\d{4}-\d{2}-\d{2})\.md$/);
728
+ const firstLine = options.content.split(/\r?\n/, 1)[0];
729
+ const headingCandidate = /^# Recovery checkpoint(?:\s|$)/.test(firstLine);
730
+ const candidate = Boolean(filenameMatch) || headingCandidate;
731
+
732
+ if (!candidate) {
733
+ return { candidate: false, extracted: null, error: null };
734
+ }
735
+
736
+ if (!filenameMatch) {
737
+ return {
738
+ candidate: true,
739
+ extracted: null,
740
+ error: createError(
741
+ options.relativePath,
742
+ 'session-recovery-checkpoint-path-invalid',
743
+ 'Legacy recovery checkpoints must be root session files named sessions/YYYY-MM-DD.md.',
744
+ ),
745
+ };
746
+ }
747
+
748
+ const headingMatch = firstLine.match(
749
+ /^# Recovery checkpoint — (\d{4}-\d{2}-\d{2}) — (\S(?:.*\S)?)$/,
750
+ );
751
+ if (!headingMatch) {
752
+ return {
753
+ candidate: true,
754
+ extracted: null,
755
+ error: createError(
756
+ options.relativePath,
757
+ 'session-recovery-checkpoint-heading-invalid',
758
+ 'Legacy recovery checkpoints require "# Recovery checkpoint — YYYY-MM-DD — <nonempty title>".',
759
+ ),
760
+ };
761
+ }
762
+
763
+ const filenameDate = filenameMatch[1];
764
+ const headingDate = headingMatch[1];
765
+ if (!isValidIsoCalendarDate(filenameDate) || !isValidIsoCalendarDate(headingDate)) {
766
+ return {
767
+ candidate: true,
768
+ extracted: null,
769
+ error: createError(
770
+ options.relativePath,
771
+ 'session-recovery-checkpoint-date-invalid',
772
+ 'Legacy recovery checkpoint dates must be valid ISO calendar dates.',
773
+ ),
774
+ };
775
+ }
776
+
777
+ if (filenameDate !== headingDate) {
778
+ return {
779
+ candidate: true,
780
+ extracted: null,
781
+ error: createError(
782
+ options.relativePath,
783
+ 'session-recovery-checkpoint-date-mismatch',
784
+ `Legacy recovery checkpoint filename date ${filenameDate} does not match heading date ${headingDate}.`,
785
+ ),
786
+ };
787
+ }
788
+
789
+ return {
790
+ candidate: true,
791
+ extracted: {
792
+ title: headingMatch[2].trim(),
793
+ session_date: filenameDate,
794
+ // A checkpoint records continuity inside an open lane; assigning a
795
+ // number would invent a close-session chronology that never occurred.
796
+ session_number: null,
797
+ },
798
+ error: null,
799
+ };
800
+ }
801
+
802
+ function appendMissingSessionSectionWarnings(content, warnings) {
803
+ const sections = extractLevelTwoSections(content);
804
+ for (const section of RECOMMENDED_SESSION_SECTIONS) {
805
+ if (!sections.has(section.toLowerCase())) {
806
+ warnings.push(
807
+ createWarning('session-missing-section', `Session note is missing recommended section "## ${section}".`),
808
+ );
809
+ }
810
+ }
811
+ }
812
+
813
+ function isValidIsoCalendarDate(value) {
814
+ const parsed = new Date(`${value}T00:00:00.000Z`);
815
+ return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
816
+ }
817
+
710
818
  function extractDecisionEntries(content) {
711
819
  const matches = [...content.matchAll(/^###\s+D-(\d{3,})\b.*$/gm)];
712
820
 
package/src/scaffold.js CHANGED
@@ -235,6 +235,7 @@ This workspace uses VibeCompass project memory rooted at \`${rootRelativePath}\`
235
235
  - The active lane scratch files live under \`${rootRelativePath}/sessions/active/<lane-id>/\`.
236
236
  - Lane selection follows D-277: an explicit \`--session\` wins, then the nearest worktree lane marker (\`.vibecompass-lane.yaml\`, walking up from cwd), then the single active lane. With two or more active lanes there is no implicit current-lane fallback.
237
237
  - \`${rootRelativePath}/sessions/active/index.yaml\` is the lane inventory; its \`current\` pointer and the tool-specific Current session block are human-readable continuity summaries, not the lane-selection source of truth.
238
+ - An active lane directory is an open session, not an unclosed crash (D-353). Resume it with \`vibecompass continue-session [<lane-id>]\`: the lane keeps its opening date and session number; the command records \`resumed_at\`/\`resume_count\` in \`session.yaml\`, appends a dated \`Resumed\` line to \`wip.md\`, moves the index \`current\` hint and the Current session block to the lane, and re-emits runtime, git-binding, and staleness warnings. \`start-session --id <existing-lane>\` fails closed and names \`continue-session\`.
238
239
  - Optional git binding (D-281): \`start-session --branch <name> --repo <id> [--worktree]\` creates or reuses the branch in every bound repo; \`--worktree\` additionally provisions per-repo worktrees under \`<workspace>/worktrees/<lane-id>/<repo-id>\` with the lane marker written into the container, so commands run from inside a worktree need neither \`--root\` nor \`--session\`. Binding is opt-in and git is never required for lanes.
239
240
  - At close, \`close-session\` removes a bound lane's recorded clean worktrees (guarded and never forced: dirty, in-use, or unverifiable worktrees survive with guidance, the lane marker is kept while any worktree survives, and branches are never deleted). Do not hand-remove provisioned worktrees; follow the printed guidance instead.
240
241
  - Every lane gets a per-lane runtime assignment at start (D-282): a lane port and temp dir recorded in \`session.yaml\` and exported with \`eval "$(vibecompass lane-env)"\` (includes conventional \`PORT\`/\`TMPDIR\` aliases), so parallel lanes never fight over dev-server ports or temp paths. Defaults are configurable under \`project.yaml\` \`runtime:\`; \`close-session\` removes the lane temp dir under guards.
@@ -248,7 +249,8 @@ ${repos}
248
249
  ## Session roles
249
250
  | Entry trigger | Role | Owns session lifecycle? |
250
251
  |---|---|---|
251
- | "start session" | Builder | Yes — opens the session, keeps scratch files current, and writes the final session note |
252
+ | "start session" | Builder | Yes — opens a new lane, keeps scratch files current, and writes the final session note |
253
+ | "continue session" | Builder | Yes — resumes an already-open lane and owns its lifecycle from there |
252
254
  | "join as reviewer" | Reviewer | No — reviews the builder's work, appends findings, and updates handoff guidance |
253
255
 
254
256
  ## Session prompt commands
@@ -271,11 +273,20 @@ ${renderWorkflowDefaults(workflow)}
271
273
  ## Session startup
272
274
  1. Read \`${rootRelativePath}/project.yaml\`.
273
275
  2. Read the latest finalized session note in \`${rootRelativePath}/sessions/\`.
274
- 3. If present, read \`${rootRelativePath}/sessions/active/index.yaml\` for the lane inventory; select the lane from an explicit \`--session\`, the nearest worktree lane marker, or the single active lane (D-277).
276
+ 3. Inventory active lanes with \`vibecompass list-sessions\` (or read \`${rootRelativePath}/sessions/active/index.yaml\`), then follow the "Open, resume, or choose" protocol below to decide between opening a new lane and resuming one; select a lane from an explicit \`--session\`, the nearest worktree lane marker, or the single active lane (D-277).
275
277
  4. If present, read \`${rootRelativePath}/sessions/active/<lane-id>/wip.md\`.
276
278
  5. If present, read \`${rootRelativePath}/sessions/active/<lane-id>/handoff.md\`.
277
279
  6. Read the relevant docs under \`${rootRelativePath}/architecture/\` and \`${rootRelativePath}/decisions/\`.
278
280
 
281
+ ## Open, resume, or choose (D-353)
282
+ Both \`start session\` and \`continue session\` begin with the active-lane inventory. Then:
283
+ - **No active lane:** open one with \`vibecompass start-session --id <lane-id> --working-on "..."\`. \`continue session\` with no lane reports that there is nothing to resume and hands off to \`start session\`.
284
+ - **Exactly one active lane, or a cwd bound to a lane by a worktree marker:** resume it with \`vibecompass continue-session\` without asking. Open a second lane only when the user is clearly asking for separate new work; never re-open a lane that already exists.
285
+ - **Two or more active lanes, no marker, no lane named by the user:** do not guess. Present the inventory (lane id, working-on, opened date, resume count, branch/worktree, last log line) and ask which lane to continue or whether to open a new one. The index \`current\` pointer and the Current session block are continuity hints to mention, never defaults to adopt silently. Resume with \`vibecompass continue-session <lane-id>\` once the user answers (\`continue session <lane-id>\`).
286
+ - **Lane named by the user:** \`continue session <lane-id>\` resumes that lane; an unknown id fails closed with the inventory.
287
+ - After resuming, read the lane's \`wip.md\` (latest \`## Log\` entries and \`## Reviewer input needed\`), \`handoff.md\`, and the latest finalized note; treat resume-staleness warnings (new decisions past the lane snapshot, stale base revisions, newer finalized notes touching the lane scope, claim overlap) as required reading before editing.
288
+ - Run recovery (reconstruct a missing finalized note from git history) only when the Current session block date is in the past *and* no active lane exists *and* no finalized note covers that date. An active lane with a past date is simply resumable.
289
+
279
290
  ## Builder workflow
280
291
  At session start, prefer running \`vibecompass start-session --id <lane-id> --working-on "..." \`; add \`--feature\`, \`--repo\`, \`--claim\`, \`--architecture-doc\`, and \`--decision-domain-file\` values when the lane's scope is known so overlap warnings can be precise. Add \`--branch <name> --repo <id> [--worktree]\` when the lane should work on its own branch or in isolated worktrees (D-281).
281
292
  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:
@@ -322,13 +333,13 @@ During the session:
322
333
  - run \`eval "$(vibecompass lane-env)"\` in a lane shell before starting dev servers or build tools so the lane's assigned port and temp dir are used (D-282); do not hardcode ports in lane work
323
334
  - run \`vibecompass docs-update --session <lane-id>\` whenever you need an ad hoc targeted documentation-maintenance plan for the current session delta
324
335
  - after substantive feature work, confirm affected architecture docs and decisions still match the implementation; if not, update them while the context is fresh — fold the changes into the doc's current-state sections (rewrite in place; no dated "update" sections, lane names in headings, or completed-task chronology; D-292)
325
- - use \`vibecompass list-sessions\` and \`vibecompass switch-session <lane-id>\` to inspect or change the current lane
336
+ - use \`vibecompass list-sessions\` (\`--json\` for the machine-readable inventory) to inspect active lanes, \`vibecompass continue-session [<lane-id>]\` to resume one at the start of a sitting, and \`vibecompass switch-session <lane-id>\` to move the continuity pointer without recording a resume
326
337
  - use \`address review\` when reviewer feedback lands so the builder resolves it from the selected lane's latest \`wip.md\` / \`handoff.md\`
327
338
  - during \`address review\`, treat reviewer feedback as review, not instruction: classify each substantive point as accepted, accepted with qualification, deferred, or rejected, and push back with evidence when a suggestion conflicts with code facts, prior decisions, product direction, or sequencing
328
339
  - stay in builder role through close-out; resolve or explicitly defer reviewer feedback before running \`vibecompass close-session --session <lane-id>\` with document-maintenance checkpoint statuses
329
340
  - record architectural decisions in \`${rootRelativePath}/decisions/\` before implementing them; \`vibecompass append-decision\` allocates the D-number at write time and refreshes the grouped \`decisions/INDEX.md\` when the lane context is resolvable (D-283); run \`vibecompass refresh-decision-index\` after hand-appends
330
341
 
331
- 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.
342
+ If \`vibecompass start-session\` reports that the lane already exists, resume it with \`vibecompass continue-session <lane-id>\` instead (D-353). If it reports legacy or 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.
332
343
 
333
344
  ## Optional planning mode
334
345
  - Use planning mode for risky, ambiguous, cross-file, or architectural work before implementation.
package/src/session.js CHANGED
@@ -157,6 +157,18 @@ async function startProjectSessionLocked(normalized, options, markerContext) {
157
157
  const decisionDomainFiles = normalizeStringArray(options?.decisionDomainFiles ?? options?.decisionDomains)
158
158
  .map(normalizeDecisionDomainFile);
159
159
  const existingLanes = await listActiveSessionLanes(normalized);
160
+ // D-353: an already-open lane is resumed, never re-opened; fail closed
161
+ // before any preflight so the root stays untouched.
162
+ if (existingLanes.some((lane) => lane.id === sessionId)) {
163
+ throw new Error(
164
+ `Active session lane "${sessionId}" already exists. Resume it with \`vibecompass continue-session ${sessionId}\`, or choose a different --id for new work (D-353).`,
165
+ );
166
+ }
167
+ const priorIndex = await readActiveSessionIndex(normalized);
168
+ const resumeAlternativeWarnings = buildResumeAlternativeWarnings(
169
+ existingLanes,
170
+ resolveValidatedCurrentLane(priorIndex.current, existingLanes),
171
+ );
160
172
  const overlapWarnings = buildOverlapWarnings({
161
173
  sessionId,
162
174
  features,
@@ -267,7 +279,7 @@ async function startProjectSessionLocked(normalized, options, markerContext) {
267
279
  await mkdir(normalized.activeSessionsDir, { recursive: true });
268
280
  await mkdir(lanePaths.laneDir, { recursive: false }).catch((error) => {
269
281
  if (error && typeof error === 'object' && 'code' in error && error.code === 'EEXIST') {
270
- throw new Error(`Active session lane "${sessionId}" already exists. Choose a different --id or close that lane first.`);
282
+ throw new Error(`Active session lane "${sessionId}" already exists. Resume it with \`vibecompass continue-session ${sessionId}\`, or choose a different --id for new work (D-353).`);
271
283
  }
272
284
 
273
285
  throw error;
@@ -401,6 +413,7 @@ async function startProjectSessionLocked(normalized, options, markerContext) {
401
413
  warnings: [
402
414
  ...markerContext.warnings,
403
415
  ...metadataWarnings,
416
+ ...resumeAlternativeWarnings,
404
417
  ...overlapWarnings,
405
418
  ...baseRevisionCapture.warnings,
406
419
  ...runtimeSettings.warnings,
@@ -767,13 +780,13 @@ async function canonicalizePathBestEffort(value) {
767
780
  }
768
781
 
769
782
  /** Flattens the docs-update pre-close staleness set into close warnings (A:192 re-emission). */
770
- function collectStalenessWarnings(plan) {
783
+ function collectStalenessWarnings(plan, label = 'Pre-close staleness') {
771
784
  const staleness = plan?.staleness;
772
785
  if (!staleness) {
773
786
  return [];
774
787
  }
775
788
 
776
- return (staleness.entries ?? []).map((entry) => `Pre-close staleness: ${entry}`);
789
+ return (staleness.entries ?? []).map((entry) => `${label}: ${entry}`);
777
790
  }
778
791
 
779
792
  function normalizeDocumentMaintenanceCheckpoint(value) {
@@ -893,10 +906,12 @@ export async function listProjectSessions(options = {}) {
893
906
  const index = await readActiveSessionIndex(normalized);
894
907
  const lanes = await listActiveSessionLanes(normalized);
895
908
 
909
+ const current = resolveValidatedCurrentLane(index.current, lanes);
896
910
  return {
897
911
  rootDir: normalized.rootDir,
898
- current: resolveValidatedCurrentLane(index.current, lanes),
912
+ current,
899
913
  lanes,
914
+ inventory: buildLaneInventory(lanes, current),
900
915
  };
901
916
  }
902
917
 
@@ -970,6 +985,429 @@ async function switchProjectSessionLocked(normalized, options, markerContext) {
970
985
  };
971
986
  }
972
987
 
988
+ /**
989
+ * D-353: resumes an already-open lane. Lane selection is the D-277 resolver
990
+ * (explicit id → worktree marker → single active lane); with two or more
991
+ * active lanes and no selection the command fails closed, but the error
992
+ * carries the rendered lane inventory so the caller can choose without a
993
+ * second command. Resuming never renumbers or re-dates the lane — the wip
994
+ * header, session number, and eventual finalized-note name stay anchored to
995
+ * the opening date; only the continuity surfaces (index pointer, Current
996
+ * session block, wip log, session.yaml resume bookkeeping) move.
997
+ */
998
+ export async function continueProjectSession(options = {}) {
999
+ const { normalized, markerContext } = await resolveSessionCommandContext(options, { needMarkerForLane: true });
1000
+ return withMemoryRootLock(normalized.rootDir, 'continue-session', () =>
1001
+ continueProjectSessionLocked(normalized, options, markerContext));
1002
+ }
1003
+
1004
+ async function continueProjectSessionLocked(normalized, options, markerContext) {
1005
+ await assertLaneMarkerSnapshotCurrent(markerContext);
1006
+ await ensureInitializedProjectMemory(normalized.rootDir, { allowMissingProjectFile: true });
1007
+ const lanes = await listActiveSessionLanes(normalized);
1008
+ const index = await readActiveSessionIndex(normalized);
1009
+ const inventory = buildLaneInventory(lanes, resolveValidatedCurrentLane(index.current, lanes));
1010
+
1011
+ if (lanes.length === 0) {
1012
+ throw new Error(
1013
+ 'No active session lane exists to continue. Open one with `vibecompass start-session --id <lane-id> --working-on "..."` (D-353).',
1014
+ );
1015
+ }
1016
+
1017
+ const explicitId = normalizeOptionalString(options?.sessionId);
1018
+ if (!explicitId && !markerContext.marker && lanes.length > 1) {
1019
+ throw new Error(
1020
+ [
1021
+ `Multiple active session lanes exist; continue-session will not guess which one to resume (D-277/D-353).`,
1022
+ ...renderLaneInventoryLines(inventory),
1023
+ 'Choose explicitly: `vibecompass continue-session <lane-id>`, or run the command from a directory bound to the intended lane (worktree lane marker).',
1024
+ 'The `current` pointer above is a continuity hint, not a selection.',
1025
+ ].join('\n'),
1026
+ );
1027
+ }
1028
+ // An unknown explicit id fails closed with the same inventory the multi-lane
1029
+ // refusal prints, so the caller can correct the id without a second command.
1030
+ if (explicitId && !lanes.some((item) => item.id === validateLaneId(explicitId))) {
1031
+ throw new Error(
1032
+ [
1033
+ `Session lane "${explicitId}" is not an active lane in ${normalized.rootDir}.`,
1034
+ ...renderLaneInventoryLines(inventory),
1035
+ 'Resume one of the lanes above with `vibecompass continue-session <lane-id>`.',
1036
+ ].join('\n'),
1037
+ );
1038
+ }
1039
+
1040
+ const selection = resolveLaneSelection({
1041
+ explicitSessionId: explicitId ? validateLaneId(explicitId) : null,
1042
+ marker: markerContext.marker,
1043
+ laneIds: lanes.map((lane) => lane.id),
1044
+ rootDir: normalized.rootDir,
1045
+ purpose: 'continue',
1046
+ });
1047
+ const lane = lanes.find((item) => item.id === selection.sessionId);
1048
+ if (lane.warnings.length > 0) {
1049
+ throw new Error(
1050
+ `Lane "${lane.id}"'s session.yaml could not be parsed (${lane.warnings[0]}); repair the lane metadata before continuing it, or close the lane.`,
1051
+ );
1052
+ }
1053
+
1054
+ const lanePaths = getLanePaths(normalized, lane.id);
1055
+ const missingScratch = [];
1056
+ if (!(await fileExists(lanePaths.wipFilePath))) {
1057
+ missingScratch.push(lanePaths.wipFilePath);
1058
+ }
1059
+ if (!(await fileExists(lanePaths.handoffFilePath))) {
1060
+ missingScratch.push(lanePaths.handoffFilePath);
1061
+ }
1062
+ if (missingScratch.length > 0) {
1063
+ throw new Error(
1064
+ `Lane "${lane.id}" is missing ${missingScratch.join(' and ')}. Recreate the scratch file(s) from the templates in context.md (or close the lane) before continuing it.`,
1065
+ );
1066
+ }
1067
+
1068
+ // Every required input is read and every output is prepared before the
1069
+ // first write, so a deterministic refusal (missing CLAUDE.md, unparseable
1070
+ // scratch, metadata that would not round-trip) leaves all lane, index, and
1071
+ // tooling bytes untouched.
1072
+ const wipContent = await readFile(lanePaths.wipFilePath, 'utf8');
1073
+ const activeSession = parseActiveSession(wipContent, lanePaths.wipFilePath);
1074
+ const sessionContent = await readFile(lanePaths.sessionFilePath, 'utf8');
1075
+ const claude = await readClaudeFile(normalized.claudePath);
1076
+ const currentSession = parseCurrentSessionBlock(claude.content, { optional: true });
1077
+
1078
+ const sessionDate = lane.sessionDate ?? activeSession.sessionDate;
1079
+ const sessionNumber = lane.sessionNumber || activeSession.sessionNumber;
1080
+ const resumeDate = normalizeSessionDate(options?.date);
1081
+ const resumedAt = formatLocalDateTime(new Date());
1082
+ const resumeCount = (lane.resumeCount ?? 0) + 1;
1083
+ const workingOnUpdate = normalizeOptionalString(options?.workingOn);
1084
+ const workingOn = workingOnUpdate ?? lane.workingOn ?? 'No summary recorded';
1085
+ const otherLanes = lanes.filter((item) => item.id !== lane.id);
1086
+
1087
+ const updatedSessionContent = upsertTopLevelYamlScalars(
1088
+ sessionContent,
1089
+ {
1090
+ ...(workingOnUpdate ? { working_on: quoteYamlString(workingOnUpdate) } : {}),
1091
+ resumed_at: resumedAt,
1092
+ resume_count: String(resumeCount),
1093
+ },
1094
+ lanePaths.sessionFilePath,
1095
+ );
1096
+ const otherLaneNote = otherLanes.length > 0
1097
+ ? `; ${otherLanes.length} other active lane${otherLanes.length === 1 ? '' : 's'}: ${otherLanes.map((item) => item.id).join(', ')}`
1098
+ : '';
1099
+ const logLine = `- Resumed lane on ${resumeDate} (continue-session #${resumeCount}${otherLaneNote})${workingOnUpdate ? `. Working on updated: ${workingOnUpdate}` : ''}`;
1100
+ const updatedWipContent = appendWipLogLine(
1101
+ workingOnUpdate ? replaceWipSectionBody(wipContent, 'Working on', workingOnUpdate) : wipContent,
1102
+ logLine,
1103
+ );
1104
+ // Continuity fields carry over only when the block already described this
1105
+ // exact lane (a prefix match such as `billing` vs `billing-tax` must not).
1106
+ const blockNamesThisLane = parseCurrentSessionLaneId(currentSession?.date) === lane.id;
1107
+ const updatedClaudeContent = replaceCurrentSessionBlock(
1108
+ claude.content,
1109
+ {
1110
+ date: `${sessionDate} (session ${sessionNumber}, lane ${lane.id}; resumed ${resumeDate})`,
1111
+ workingOn: `${workingOn} [${lane.id}]`,
1112
+ lastThingCompleted:
1113
+ normalizeOptionalString(options?.lastThingCompleted) ??
1114
+ (blockNamesThisLane ? currentSession.lastThingCompleted : null) ??
1115
+ `Resumed lane ${lane.id} (continue-session #${resumeCount}).`,
1116
+ blockers:
1117
+ normalizeOptionalString(options?.blockers) ??
1118
+ (blockNamesThisLane ? currentSession.blockers : null) ??
1119
+ 'No blocker recorded for the selected lane.',
1120
+ nextSessionShould:
1121
+ normalizeOptionalString(options?.nextSessionShould) ??
1122
+ (blockNamesThisLane ? currentSession.nextSessionShould : null) ??
1123
+ 'Continue this lane from its wip.md/handoff.md and close it with a finalized session note.',
1124
+ },
1125
+ {
1126
+ lanes: lanes.map((item) => ({ id: item.id, workingOn: item.id === lane.id ? workingOn : item.workingOn })),
1127
+ selectedId: lane.id,
1128
+ },
1129
+ );
1130
+
1131
+ await writeFile(lanePaths.sessionFilePath, updatedSessionContent, 'utf8');
1132
+ await writeFile(lanePaths.wipFilePath, updatedWipContent, 'utf8');
1133
+ await upsertActiveSessionIndex(normalized, { id: lane.id, status: 'active', workingOn }, { current: lane.id });
1134
+ await writeFile(normalized.claudePath, updatedClaudeContent, 'utf8');
1135
+
1136
+ const docsUpdate = await planDocsUpdateSafely({
1137
+ rootDir: normalized.rootDir,
1138
+ cwd: normalized.cwd,
1139
+ sessionId: lane.id,
1140
+ suppressLaneWarnings: true,
1141
+ planner: options?.docsUpdatePlanner,
1142
+ });
1143
+ const manifestRefresh = await refreshStateManifestSafely(normalized.rootDir);
1144
+ const agentFileSync = await syncAgentInstructionFilesSafely({
1145
+ rootDir: normalized.rootDir,
1146
+ toolingRootDir: normalized.toolingRootDir,
1147
+ });
1148
+ const finalizedSessions = await listFinalizedSessions(normalized.sessionsDir);
1149
+ const latestFinalized = finalizedSessions.length > 0 ? finalizedSessions[finalizedSessions.length - 1] : null;
1150
+ // The returned inventory is the post-resume state (what list-sessions shows
1151
+ // now), not the pre-mutation snapshot used for the refusal messages.
1152
+ const inventoryAfter = buildLaneInventory(await listActiveSessionLanes(normalized), lane.id);
1153
+
1154
+ return {
1155
+ rootDir: normalized.rootDir,
1156
+ toolingRootDir: normalized.toolingRootDir,
1157
+ claudePath: normalized.claudePath,
1158
+ sessionId: lane.id,
1159
+ selectionSource: selection.source,
1160
+ sessionFilePath: lanePaths.sessionFilePath,
1161
+ wipFilePath: lanePaths.wipFilePath,
1162
+ handoffFilePath: lanePaths.handoffFilePath,
1163
+ latestFinalizedSessionPath: latestFinalized ? path.join(normalized.sessionsDir, latestFinalized.fileName) : null,
1164
+ sessionDate,
1165
+ sessionNumber,
1166
+ resumeDate,
1167
+ resumedAt,
1168
+ resumeCount,
1169
+ workingOn,
1170
+ workingOnUpdated: Boolean(workingOnUpdate),
1171
+ startedAt: lane.startedAt ?? null,
1172
+ laneAgeDays: computeLaneAgeDays(sessionDate, resumeDate),
1173
+ runtime: lane.runtime ?? null,
1174
+ gitBinding: lane.branch
1175
+ ? {
1176
+ branch: lane.branch,
1177
+ worktreeContainer: lane.worktreeContainer ?? null,
1178
+ worktrees: lane.worktrees ?? {},
1179
+ }
1180
+ : null,
1181
+ otherLanes: inventoryAfter.lanes.filter((item) => item.id !== lane.id),
1182
+ inventory: inventoryAfter,
1183
+ docsUpdatePlan: docsUpdate.plan,
1184
+ warnings: [
1185
+ ...markerContext.warnings,
1186
+ ...selection.warnings,
1187
+ ...collectLaneMetadataWarnings(otherLanes),
1188
+ ...collectStalenessWarnings(docsUpdate.plan, 'Resume staleness'),
1189
+ ...buildSharedCheckoutWarnings({
1190
+ sessionId: lane.id,
1191
+ repos: lane.repos ?? [],
1192
+ claims: lane.claims ?? [],
1193
+ existingLanes: otherLanes,
1194
+ hasWorktree: Boolean(lane.worktreeContainer),
1195
+ context: 'continue-session',
1196
+ }),
1197
+ ...docsUpdate.warnings,
1198
+ ...manifestRefresh.warnings,
1199
+ ],
1200
+ manifest: manifestRefresh.manifest,
1201
+ agentFileSync,
1202
+ };
1203
+ }
1204
+
1205
+ /**
1206
+ * Lane inventory (D-353): the one summary shape shared by `list-sessions`,
1207
+ * the continue-session multi-lane failure, and the start-session resume
1208
+ * hint, so every surface an agent might read describes lanes the same way.
1209
+ */
1210
+ function buildLaneInventory(lanes, current) {
1211
+ return {
1212
+ current: current ?? null,
1213
+ lanes: [...lanes]
1214
+ .sort((left, right) => left.id.localeCompare(right.id))
1215
+ .map((lane) => ({
1216
+ id: lane.id,
1217
+ workingOn: lane.workingOn ?? null,
1218
+ sessionDate: lane.sessionDate ?? null,
1219
+ sessionNumber: lane.sessionNumber || null,
1220
+ startedAt: lane.startedAt ?? null,
1221
+ resumedAt: lane.resumedAt ?? null,
1222
+ resumeCount: lane.resumeCount ?? 0,
1223
+ branch: lane.branch ?? null,
1224
+ worktreeContainer: lane.worktreeContainer ?? null,
1225
+ worktreeRepos: Object.keys(lane.worktrees ?? {}),
1226
+ runtimePort: lane.runtime?.port ?? null,
1227
+ lastLogEntry: lane.lastLogEntry ?? null,
1228
+ isCurrentHint: current != null && lane.id === current,
1229
+ warnings: lane.warnings ?? [],
1230
+ })),
1231
+ };
1232
+ }
1233
+
1234
+ export function renderLaneInventoryLines(inventory) {
1235
+ const lines = [`Active session lanes${inventory.current ? ` (current hint: ${inventory.current})` : ''}:`];
1236
+ if (inventory.lanes.length === 0) {
1237
+ lines.push('- None');
1238
+ return lines;
1239
+ }
1240
+
1241
+ for (const lane of inventory.lanes) {
1242
+ const marker = lane.isCurrentHint ? '*' : '-';
1243
+ lines.push(`${marker} ${lane.id}: ${lane.workingOn ?? 'No summary recorded'}${lane.isCurrentHint ? ' [current hint]' : ''}`);
1244
+ const facts = [];
1245
+ if (lane.sessionDate) {
1246
+ facts.push(`opened ${lane.sessionDate}${lane.sessionNumber ? ` (session ${lane.sessionNumber})` : ''}`);
1247
+ }
1248
+ if (lane.resumeCount > 0) {
1249
+ facts.push(`resumed ${lane.resumeCount}×${lane.resumedAt ? ` (last ${lane.resumedAt})` : ''}`);
1250
+ }
1251
+ if (lane.branch) {
1252
+ facts.push(`branch ${lane.branch}${lane.worktreeRepos.length > 0 ? ` (worktrees: ${lane.worktreeRepos.join(', ')})` : ''}`);
1253
+ }
1254
+ if (lane.runtimePort != null) {
1255
+ facts.push(`port ${lane.runtimePort}`);
1256
+ }
1257
+ if (facts.length > 0) {
1258
+ lines.push(` ${facts.join('; ')}`);
1259
+ }
1260
+ if (lane.lastLogEntry) {
1261
+ lines.push(` last log: ${lane.lastLogEntry}`);
1262
+ }
1263
+ for (const warning of lane.warnings) {
1264
+ lines.push(` warning: ${warning}`);
1265
+ }
1266
+ }
1267
+
1268
+ return lines;
1269
+ }
1270
+
1271
+ function buildResumeAlternativeWarnings(existingLanes, currentHint = null) {
1272
+ if (existingLanes.length === 0) {
1273
+ return [];
1274
+ }
1275
+
1276
+ const ids = existingLanes.map((lane) => lane.id).sort((left, right) => left.localeCompare(right));
1277
+ const inventoryLines = renderLaneInventoryLines(buildLaneInventory(existingLanes, currentHint)).map((line) => ` ${line}`);
1278
+ return [
1279
+ [
1280
+ `${ids.length} lane${ids.length === 1 ? ' is' : 's are'} already active (${ids.join(', ')}). Opening a second lane is fine for new work; to resume existing work instead, run \`vibecompass continue-session <lane-id>\` (D-353).`,
1281
+ ...inventoryLines,
1282
+ ].join('\n'),
1283
+ ];
1284
+ }
1285
+
1286
+ /**
1287
+ * Replaces or appends top-level scalar lines in a lane session.yaml without
1288
+ * re-rendering the file, so keys this writer does not know about survive.
1289
+ * Values are written verbatim through a function replacer (callers quote
1290
+ * where needed; `$&`-style replacement tokens in user text are never
1291
+ * interpreted). Key matching accepts the horizontal spacing parseSimpleYaml
1292
+ * accepts (`key :`) and is CRLF-tolerant, so an existing spaced key is
1293
+ * replaced rather than duplicated. The result is parsed before it is returned
1294
+ * and an unparseable result is refused, so the caller never writes metadata
1295
+ * that the next reader would null-degrade.
1296
+ */
1297
+ function upsertTopLevelYamlScalars(content, scalars, sourceName = 'session.yaml') {
1298
+ let next = content;
1299
+ for (const [key, value] of Object.entries(scalars)) {
1300
+ const pattern = new RegExp(`^${key}[ \\t]*:[^\\r\\n]*(\\r?\\n|$)`, 'm');
1301
+ const line = `${key}: ${value}\n`;
1302
+ if (pattern.test(next)) {
1303
+ next = next.replace(pattern, () => line);
1304
+ } else {
1305
+ next = `${next.endsWith('\n') || next.length === 0 ? next : `${next}\n`}${line}`;
1306
+ }
1307
+ }
1308
+
1309
+ try {
1310
+ parseSimpleYaml(next, { sourceName });
1311
+ } catch (error) {
1312
+ throw new Error(
1313
+ `continue-session refused to write ${sourceName}: the updated lane metadata would not parse (${error instanceof Error ? error.message : String(error)}). Nothing was written.`,
1314
+ );
1315
+ }
1316
+
1317
+ return next;
1318
+ }
1319
+
1320
+ /**
1321
+ * Extracts the lane id recorded in a Current session block Date field
1322
+ * (`YYYY-MM-DD (session N, lane <id>[; resumed YYYY-MM-DD])`). Returns null
1323
+ * when the field has no lane clause, so callers compare full ids for
1324
+ * equality instead of substring-matching.
1325
+ */
1326
+ function parseCurrentSessionLaneId(dateField) {
1327
+ if (typeof dateField !== 'string') {
1328
+ return null;
1329
+ }
1330
+
1331
+ const match = dateField.match(/\blane ([a-z0-9][a-z0-9-]*)(?=[;)\s]|$)/);
1332
+ return match ? match[1] : null;
1333
+ }
1334
+
1335
+ function findWipSection(content, sectionTitle) {
1336
+ const lines = content.split('\n');
1337
+ const heading = `## ${sectionTitle}`.toLowerCase();
1338
+ const start = lines.findIndex((line) => line.trim().toLowerCase() === heading);
1339
+ if (start < 0) {
1340
+ return null;
1341
+ }
1342
+
1343
+ let end = lines.length;
1344
+ for (let index = start + 1; index < lines.length; index += 1) {
1345
+ if (/^##\s+/.test(lines[index])) {
1346
+ end = index;
1347
+ break;
1348
+ }
1349
+ }
1350
+
1351
+ return { lines, start, end };
1352
+ }
1353
+
1354
+ function appendWipLogLine(content, logLine) {
1355
+ const section = findWipSection(content, 'Log');
1356
+ if (!section) {
1357
+ const base = content.endsWith('\n') ? content : `${content}\n`;
1358
+ return `${base}\n## Log\n${logLine}\n`;
1359
+ }
1360
+
1361
+ const { lines, start, end } = section;
1362
+ let bodyEnd = end;
1363
+ while (bodyEnd > start + 1 && lines[bodyEnd - 1].trim() === '') {
1364
+ bodyEnd -= 1;
1365
+ }
1366
+ const body = lines.slice(start + 1, bodyEnd);
1367
+ // A template placeholder ("- None yet.") is replaced by the first real entry.
1368
+ const kept = body.length === 1 && /^-\s*none yet\.?$/i.test(body[0].trim()) ? [] : body;
1369
+ const trailer = end < lines.length ? [''] : [];
1370
+ return [...lines.slice(0, start + 1), ...kept, logLine, ...trailer, ...lines.slice(end)].join('\n');
1371
+ }
1372
+
1373
+ function replaceWipSectionBody(content, sectionTitle, bodyText) {
1374
+ const section = findWipSection(content, sectionTitle);
1375
+ if (!section) {
1376
+ return content;
1377
+ }
1378
+
1379
+ const { lines, start, end } = section;
1380
+ const trailer = end < lines.length ? [''] : [];
1381
+ return [...lines.slice(0, start + 1), bodyText, ...trailer, ...lines.slice(end)].join('\n');
1382
+ }
1383
+
1384
+ function extractLastLogEntry(content) {
1385
+ const section = findWipSection(content, 'Log');
1386
+ if (!section) {
1387
+ return null;
1388
+ }
1389
+
1390
+ const { lines, start, end } = section;
1391
+ for (let index = end - 1; index > start; index -= 1) {
1392
+ const trimmed = lines[index].trim();
1393
+ if (trimmed.length > 0) {
1394
+ return trimmed.length > 200 ? `${trimmed.slice(0, 197)}...` : trimmed;
1395
+ }
1396
+ }
1397
+
1398
+ return null;
1399
+ }
1400
+
1401
+ function computeLaneAgeDays(openedDate, resumeDate) {
1402
+ const opened = Date.parse(`${openedDate}T00:00:00Z`);
1403
+ const resumed = Date.parse(`${resumeDate}T00:00:00Z`);
1404
+ if (!Number.isFinite(opened) || !Number.isFinite(resumed)) {
1405
+ return null;
1406
+ }
1407
+
1408
+ return Math.max(0, Math.round((resumed - opened) / 86_400_000));
1409
+ }
1410
+
973
1411
  /**
974
1412
  * Rebuilds `sessions/active/index.yaml` from the active lane directories plus
975
1413
  * an explicit current-lane selection (D-266/D-277 recovery path). The current
@@ -1532,6 +1970,9 @@ async function listActiveSessionLanes(normalized) {
1532
1970
  architectureDocs: metadata.architectureDocs,
1533
1971
  decisionDomainFiles: metadata.decisionDomainFiles,
1534
1972
  startedAt: metadata.startedAt,
1973
+ resumedAt: metadata.resumedAt ?? null,
1974
+ resumeCount: metadata.resumeCount ?? 0,
1975
+ lastLogEntry: wipSession?.lastLogEntry ?? null,
1535
1976
  decisionSnapshot: metadata.decisionSnapshot,
1536
1977
  branch: metadata.branch ?? null,
1537
1978
  worktreeContainer: metadata.worktreeContainer ?? null,
@@ -1557,7 +1998,11 @@ async function listActiveSessionLanes(normalized) {
1557
1998
 
1558
1999
  async function readLaneWipSession(wipFilePath) {
1559
2000
  try {
1560
- return parseActiveSession(await readFile(wipFilePath, 'utf8'), wipFilePath);
2001
+ const content = await readFile(wipFilePath, 'utf8');
2002
+ return {
2003
+ ...parseActiveSession(content, wipFilePath),
2004
+ lastLogEntry: extractLastLogEntry(content),
2005
+ };
1561
2006
  } catch {
1562
2007
  return null;
1563
2008
  }
@@ -1576,6 +2021,9 @@ async function readLaneMetadata(sessionFilePath) {
1576
2021
  architectureDocs: normalizeStringArray(data.architecture_docs).map(normalizeArchitectureDocPath),
1577
2022
  decisionDomainFiles: normalizeStringArray(data.decision_domain_files).map(normalizeDecisionDomainFile),
1578
2023
  startedAt: normalizeOptionalString(data.started_at),
2024
+ // D-353 resume bookkeeping; absent on lanes opened before continue-session existed.
2025
+ resumedAt: normalizeOptionalString(data.resumed_at),
2026
+ resumeCount: Math.max(0, Math.trunc(parseNullableNumber(data.resume_count) ?? 0)),
1579
2027
  decisionSnapshot: {
1580
2028
  highestDecisionId: parseNullableNumber(data.decision_snapshot?.highest_decision_id),
1581
2029
  },
@@ -1611,6 +2059,8 @@ async function readLaneMetadata(sessionFilePath) {
1611
2059
  architectureDocs: [],
1612
2060
  decisionDomainFiles: [],
1613
2061
  startedAt: null,
2062
+ resumedAt: null,
2063
+ resumeCount: 0,
1614
2064
  decisionSnapshot: {
1615
2065
  highestDecisionId: null,
1616
2066
  },
@@ -1727,6 +2177,7 @@ async function listFinalizedSessions(sessionsDir) {
1727
2177
  .map((entry) => entry.name.match(SESSION_FILENAME_PATTERN))
1728
2178
  .filter(Boolean)
1729
2179
  .map((match) => ({
2180
+ fileName: match[0],
1730
2181
  sessionDate: match[1],
1731
2182
  sessionNumber: Number(match[2]),
1732
2183
  slug: match[3],
@@ -7,7 +7,8 @@ export const WORKFLOW_REGISTRY = [
7
7
  detailPath: 'context.md',
8
8
  summary: 'Builder/reviewer lane lifecycle, handoffs, planning mode, and close-out.',
9
9
  promptCommands: [
10
- { trigger: 'start session', summary: 'builder role trigger' },
10
+ { trigger: 'start session', summary: 'builder role trigger; opens a new named lane — if a lane is already active, resume it with `continue session` instead of opening a duplicate' },
11
+ { trigger: 'continue session', summary: 'builder resumes an already-open lane with `vibecompass continue-session [<lane-id>]` (D-353); with multiple active lanes name the lane explicitly: `continue session <lane-id>` — the agent presents the lane inventory and asks rather than guessing' },
11
12
  { trigger: 'join as reviewer', summary: 'reviewer role trigger' },
12
13
  { trigger: 'planning mode', summary: 'optional prompt-level mode for scoping work before implementation' },
13
14
  { trigger: 'review handoff', summary: 'reviewer reads the selected lane handoff and appends findings; with multiple active lanes name the lane explicitly: `review handoff <lane-id>`' },