@vibecompass/vibecompass 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/session.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { access, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import { scanProjectMemory } from './project-memory.js';
3
4
  import { parseSimpleYaml } from './simple-yaml.js';
4
5
  import { buildCloseSessionGuidance, resolveWorkflowSettings } from './workflow.js';
5
6
  import { syncAgentInstructionFiles } from './generators/agent-files/index.js';
@@ -36,6 +37,18 @@ export async function startProjectSession(options) {
36
37
  const claude = await readClaudeFile(normalized.claudePath);
37
38
  const sessionId = await resolveStartSessionId(normalized, options);
38
39
  const lanePaths = getLanePaths(normalized, sessionId);
40
+ const features = normalizeStringArray(options?.features);
41
+ const repos = normalizeStringArray(options?.repos);
42
+ const claims = normalizeStringArray(options?.claims);
43
+ const existingLanes = await listActiveSessionLanes(normalized);
44
+ const overlapWarnings = buildOverlapWarnings({
45
+ sessionId,
46
+ features,
47
+ repos,
48
+ claims,
49
+ existingLanes,
50
+ });
51
+ const highestDecisionId = await readHighestDecisionId(normalized.rootDir);
39
52
 
40
53
  const nextSessionNumber = await getNextSessionNumber(normalized.sessionsDir, sessionDate);
41
54
  const currentSession = parseCurrentSessionBlock(claude.content);
@@ -68,9 +81,10 @@ export async function startProjectSession(options) {
68
81
  sessionDate,
69
82
  sessionNumber: nextSessionNumber,
70
83
  workingOn,
71
- features: normalizeStringArray(options?.features),
72
- repos: normalizeStringArray(options?.repos),
73
- claims: normalizeStringArray(options?.claims),
84
+ features,
85
+ repos,
86
+ claims,
87
+ highestDecisionId,
74
88
  }),
75
89
  'utf8',
76
90
  );
@@ -99,6 +113,12 @@ export async function startProjectSession(options) {
99
113
  status: 'active',
100
114
  workingOn,
101
115
  }, { current: sessionId });
116
+ const auditWarnings = await buildDocsReviewWarnings(normalized);
117
+ const metadataWarnings = collectLaneMetadataWarnings(existingLanes);
118
+ const agentFileSync = await syncAgentInstructionFilesSafely({
119
+ rootDir: normalized.rootDir,
120
+ toolingRootDir: normalized.toolingRootDir,
121
+ });
102
122
 
103
123
  return {
104
124
  rootDir: normalized.rootDir,
@@ -110,6 +130,12 @@ export async function startProjectSession(options) {
110
130
  handoffFilePath: lanePaths.handoffFilePath,
111
131
  sessionDate,
112
132
  sessionNumber: nextSessionNumber,
133
+ warnings: [
134
+ ...metadataWarnings,
135
+ ...overlapWarnings,
136
+ ...auditWarnings,
137
+ ],
138
+ agentFileSync,
113
139
  };
114
140
  }
115
141
 
@@ -185,9 +211,10 @@ export async function closeProjectSession(options) {
185
211
  );
186
212
 
187
213
  const hadHandoff = await fileExists(lanePaths.handoffFilePath);
214
+ let activeSessionIndex = null;
188
215
  if (sessionId) {
189
216
  await rm(lanePaths.laneDir, { recursive: true, force: true });
190
- await removeActiveSessionFromIndex(normalized, sessionId);
217
+ activeSessionIndex = await removeActiveSessionFromIndex(normalized, sessionId);
191
218
  } else {
192
219
  await rm(lanePaths.wipFilePath, { force: true });
193
220
  if (hadHandoff) {
@@ -195,16 +222,22 @@ export async function closeProjectSession(options) {
195
222
  }
196
223
  }
197
224
 
198
- const updatedClaude = replaceCurrentSessionBlock(claude.content, {
199
- date: `${activeSession.sessionDate} (session ${activeSession.sessionNumber})`,
200
- workingOn: 'Session closed. Ready for the next builder session.',
201
- lastThingCompleted:
202
- normalizeOptionalString(options?.lastThingCompleted) ??
203
- `Closed session ${activeSession.sessionNumber} and wrote \`${sessionRelativePath}\`.`,
204
- blockers: blockers.length > 0 ? summarizeList(blockers) : 'No blocker remains.',
205
- nextSessionShould:
206
- normalizeOptionalString(options?.nextSessionShould) ?? summarizeOrderedList(nextSteps),
207
- });
225
+ const closedSummary = `Closed session ${activeSession.sessionNumber} and wrote \`${sessionRelativePath}\`.`;
226
+ const updatedClaude = replaceCurrentSessionBlock(
227
+ claude.content,
228
+ activeSessionIndex?.current
229
+ ? buildCurrentSessionFieldsForLane(activeSessionIndex.lanes.find((lane) => lane.id === activeSessionIndex.current), {
230
+ lastThingCompleted: normalizeOptionalString(options?.lastThingCompleted) ?? closedSummary,
231
+ })
232
+ : {
233
+ date: `${activeSession.sessionDate} (session ${activeSession.sessionNumber})`,
234
+ workingOn: 'Session closed. Ready for the next builder session.',
235
+ lastThingCompleted: normalizeOptionalString(options?.lastThingCompleted) ?? closedSummary,
236
+ blockers: blockers.length > 0 ? summarizeList(blockers) : 'No blocker remains.',
237
+ nextSessionShould:
238
+ normalizeOptionalString(options?.nextSessionShould) ?? summarizeOrderedList(nextSteps),
239
+ },
240
+ );
208
241
 
209
242
  await writeFile(normalized.claudePath, updatedClaude, 'utf8');
210
243
  const agentFileSync = await syncAgentInstructionFilesSafely({
@@ -248,17 +281,35 @@ export async function switchProjectSession(options = {}) {
248
281
  const normalized = normalizeSessionPaths(options);
249
282
  const sessionId = validateLaneId(requireNonEmptyString(options?.sessionId, 'switch-session requires a session ID.'));
250
283
  const lanes = await listActiveSessionLanes(normalized);
284
+ const lane = lanes.find((item) => item.id === sessionId);
251
285
 
252
- if (!lanes.some((lane) => lane.id === sessionId)) {
286
+ if (!lane) {
253
287
  throw new Error(`Active session lane "${sessionId}" does not exist.`);
254
288
  }
255
289
 
256
290
  await upsertActiveSessionIndex(normalized, null, { current: sessionId });
291
+ const claude = await readClaudeFile(normalized.claudePath);
292
+ await writeFile(
293
+ normalized.claudePath,
294
+ replaceCurrentSessionBlock(
295
+ claude.content,
296
+ buildCurrentSessionFieldsForLane(lane, {
297
+ lastThingCompleted: `Switched current lane to ${sessionId}.`,
298
+ }),
299
+ ),
300
+ 'utf8',
301
+ );
302
+ const agentFileSync = await syncAgentInstructionFilesSafely({
303
+ rootDir: normalized.rootDir,
304
+ toolingRootDir: normalized.toolingRootDir,
305
+ });
257
306
 
258
307
  return {
259
308
  rootDir: normalized.rootDir,
309
+ claudePath: normalized.claudePath,
260
310
  current: sessionId,
261
311
  lanes,
312
+ agentFileSync,
262
313
  };
263
314
  }
264
315
 
@@ -284,6 +335,26 @@ async function syncAgentInstructionFilesSafely(options) {
284
335
  }
285
336
  }
286
337
 
338
+ async function buildDocsReviewWarnings(normalized) {
339
+ const markerPath = path.join(normalized.rootDir, 'state', 'docs-review.json');
340
+ try {
341
+ const marker = JSON.parse(await readFile(markerPath, 'utf8'));
342
+ if (marker && marker.status === 'completed') {
343
+ return [];
344
+ }
345
+ } catch (error) {
346
+ if (!(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')) {
347
+ return [
348
+ `Docs-review marker at ${markerPath} is unreadable. Starter docs are not a comprehensive architecture review; run "vibecompass docs-review --guided" before risky implementation work.`,
349
+ ];
350
+ }
351
+ }
352
+
353
+ return [
354
+ `No docs-review marker found at ${markerPath}. Starter docs are not a comprehensive architecture review; run "vibecompass docs-review --guided" before risky implementation work.`,
355
+ ];
356
+ }
357
+
287
358
  function normalizeSessionPaths(options) {
288
359
  const cwd = options?.cwd ? path.resolve(options.cwd) : process.cwd();
289
360
  const rootDir = path.resolve(cwd, options?.rootDir ?? '.compass');
@@ -397,6 +468,26 @@ function replaceCurrentSessionBlock(content, fields) {
397
468
  return `${content.slice(0, sessionFence.bodyStart)}${blockBody}${content.slice(sessionFence.bodyEnd)}`;
398
469
  }
399
470
 
471
+ function buildCurrentSessionFieldsForLane(lane, overrides = {}) {
472
+ if (!lane) {
473
+ throw new Error('Cannot update the Current session block without active lane metadata.');
474
+ }
475
+
476
+ const sessionDate = lane.sessionDate ?? normalizeSessionDate();
477
+ const sessionNumber = lane.sessionNumber || 0;
478
+ const workingOn = lane.workingOn ?? 'No summary recorded.';
479
+
480
+ return {
481
+ date: `${sessionDate} (session ${sessionNumber}, lane ${lane.id})`,
482
+ workingOn: `${workingOn} [${lane.id}]`,
483
+ lastThingCompleted:
484
+ overrides.lastThingCompleted ?? 'Current lane selected from `sessions/active/index.yaml`.',
485
+ blockers: overrides.blockers ?? 'No blocker recorded for the selected lane.',
486
+ nextSessionShould:
487
+ overrides.nextSessionShould ?? 'Continue the selected active lane from `sessions/active/index.yaml`.',
488
+ };
489
+ }
490
+
400
491
  async function getNextSessionNumber(sessionsDir, sessionDate) {
401
492
  const sessions = await listFinalizedSessions(sessionsDir);
402
493
  const activeSessions = await listActiveSessionLanes({ sessionsDir, activeSessionsDir: path.join(sessionsDir, 'active') });
@@ -414,18 +505,13 @@ async function resolveStartSessionId(normalized, options) {
414
505
  return validateLaneId(explicitId);
415
506
  }
416
507
 
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
508
  if ((await fileExists(normalized.wipFilePath)) || (await fileExists(normalized.handoffFilePath))) {
423
509
  throw new Error(
424
510
  `Legacy active session scratch files already exist under ${normalized.sessionsDir}. Close or recover that session before starting a lane.`,
425
511
  );
426
512
  }
427
513
 
428
- return 'default';
514
+ throw new Error('start-session requires --id <lane-id> so each active lane has a meaningful name.');
429
515
  }
430
516
 
431
517
  async function resolveExistingSessionId(normalized, options) {
@@ -466,6 +552,9 @@ async function listActiveSessionLanes(normalized) {
466
552
  features: metadata.features,
467
553
  repos: metadata.repos,
468
554
  claims: metadata.claims,
555
+ startedAt: metadata.startedAt,
556
+ decisionSnapshot: metadata.decisionSnapshot,
557
+ warnings: metadata.warnings,
469
558
  sessionDate: metadata.sessionDate ?? wipSession?.sessionDate ?? null,
470
559
  sessionNumber: metadata.sessionNumber ?? wipSession?.sessionNumber ?? 0,
471
560
  });
@@ -499,18 +588,30 @@ async function readLaneMetadata(sessionFilePath) {
499
588
  features: normalizeStringArray(data.feature_slugs),
500
589
  repos: normalizeStringArray(data.repos),
501
590
  claims: normalizeStringArray(data.claimed_paths),
591
+ startedAt: normalizeOptionalString(data.started_at),
592
+ decisionSnapshot: {
593
+ highestDecisionId: parseNullableNumber(data.decision_snapshot?.highest_decision_id),
594
+ },
502
595
  sessionDate: normalizeOptionalString(data.session_date),
503
596
  sessionNumber: typeof data.session_number === 'number' ? data.session_number : Number(data.session_number) || null,
597
+ warnings: [],
504
598
  };
505
- } catch {
599
+ } catch (error) {
506
600
  return {
507
601
  status: null,
508
602
  workingOn: null,
509
603
  features: [],
510
604
  repos: [],
511
605
  claims: [],
606
+ startedAt: null,
607
+ decisionSnapshot: {
608
+ highestDecisionId: null,
609
+ },
512
610
  sessionDate: null,
513
611
  sessionNumber: null,
612
+ warnings: [
613
+ `Could not parse ${sessionFilePath}: ${error instanceof Error ? error.message : String(error)}`,
614
+ ],
514
615
  };
515
616
  }
516
617
  }
@@ -547,16 +648,28 @@ async function upsertActiveSessionIndex(normalized, lane, options = {}) {
547
648
  await writeFile(normalized.activeSessionsIndexPath, renderActiveSessionIndex(current, [...laneMap.values()]), 'utf8');
548
649
  }
549
650
 
651
+ /**
652
+ * Removes a closed lane from the active index and returns the surviving lane
653
+ * state used to refresh the Current session block. `lanes` contains the full
654
+ * lane metadata read from each sibling lane's `session.yaml` / `wip.md`.
655
+ */
550
656
  async function removeActiveSessionFromIndex(normalized, sessionId) {
551
657
  const index = await readActiveSessionIndex(normalized);
552
658
  const lanes = (await listActiveSessionLanes(normalized)).filter((lane) => lane.id !== sessionId);
553
659
  const current = index.current === sessionId ? lanes[0]?.id ?? null : index.current;
554
660
  if (lanes.length === 0) {
555
661
  await rm(normalized.activeSessionsIndexPath, { force: true });
556
- return;
662
+ return {
663
+ current: null,
664
+ lanes,
665
+ };
557
666
  }
558
667
 
559
668
  await writeFile(normalized.activeSessionsIndexPath, renderActiveSessionIndex(current, lanes), 'utf8');
669
+ return {
670
+ current,
671
+ lanes,
672
+ };
560
673
  }
561
674
 
562
675
  async function listFinalizedSessions(sessionsDir) {
@@ -721,9 +834,9 @@ function renderLaneMetadata(options) {
721
834
  renderYamlArray('feature_slugs', options.features),
722
835
  renderYamlArray('repos', options.repos),
723
836
  renderYamlArray('claimed_paths', options.claims),
724
- `started_at: ${new Date().toISOString()}`,
837
+ `started_at: ${formatLocalDateTime(new Date())}`,
725
838
  'decision_snapshot:',
726
- ' highest_decision_id: null',
839
+ ` highest_decision_id: ${options.highestDecisionId ?? 'null'}`,
727
840
  '',
728
841
  ].join('\n');
729
842
  }
@@ -835,6 +948,124 @@ function normalizeSessionDate(value) {
835
948
  ].join('-');
836
949
  }
837
950
 
951
+ async function readHighestDecisionId(rootDir) {
952
+ try {
953
+ const scanResult = await scanProjectMemory(rootDir);
954
+ const decisionIds = scanResult.documents.flatMap((document) => document.extracted?.decision_ids ?? []);
955
+ return decisionIds.reduce((max, id) => Math.max(max, id), 0) || null;
956
+ } catch {
957
+ return null;
958
+ }
959
+ }
960
+
961
+ function buildOverlapWarnings(options) {
962
+ const warnings = [];
963
+ for (const lane of options.existingLanes) {
964
+ const sharedFeatures = intersect(options.features, lane.features ?? []);
965
+ if (sharedFeatures.length > 0) {
966
+ warnings.push(
967
+ `Active session lane "${options.sessionId}" overlaps "${lane.id}" on feature(s): ${sharedFeatures.join(', ')}.`,
968
+ );
969
+ }
970
+
971
+ const claimOverlaps = findClaimOverlaps(options.claims, lane.claims ?? []);
972
+ for (const overlap of claimOverlaps) {
973
+ warnings.push(
974
+ `Active session lane "${options.sessionId}" overlaps "${lane.id}" on claimed path ${overlap.repo}:${overlap.path}.`,
975
+ );
976
+ }
977
+
978
+ const sharedRepos = intersect(options.repos, lane.repos ?? []);
979
+ if (sharedRepos.length > 0 && options.claims.length === 0 && (lane.claims ?? []).length === 0) {
980
+ warnings.push(
981
+ `Active session lane "${options.sessionId}" shares repo(s) ${sharedRepos.join(', ')} with "${lane.id}" without path claims; add --claim values to clarify ownership.`,
982
+ );
983
+ }
984
+ }
985
+
986
+ return warnings;
987
+ }
988
+
989
+ function collectLaneMetadataWarnings(lanes) {
990
+ return lanes.flatMap((lane) => lane.warnings ?? []);
991
+ }
992
+
993
+ function intersect(left, right) {
994
+ const rightSet = new Set(right);
995
+ return left.filter((item) => rightSet.has(item));
996
+ }
997
+
998
+ function findClaimOverlaps(leftClaims, rightClaims) {
999
+ const overlaps = [];
1000
+ for (const left of leftClaims.map(parseClaim).filter(Boolean)) {
1001
+ for (const right of rightClaims.map(parseClaim).filter(Boolean)) {
1002
+ if (left.repo !== right.repo) {
1003
+ continue;
1004
+ }
1005
+
1006
+ if (pathPrefixesOverlap(left.path, right.path)) {
1007
+ overlaps.push({
1008
+ repo: left.repo,
1009
+ path: left.path.length <= right.path.length ? left.path : right.path,
1010
+ });
1011
+ }
1012
+ }
1013
+ }
1014
+
1015
+ return overlaps;
1016
+ }
1017
+
1018
+ function parseClaim(value) {
1019
+ const separatorIndex = value.indexOf(':');
1020
+ if (separatorIndex <= 0 || separatorIndex === value.length - 1) {
1021
+ return null;
1022
+ }
1023
+
1024
+ return {
1025
+ repo: value.slice(0, separatorIndex),
1026
+ path: normalizeClaimPath(value.slice(separatorIndex + 1)),
1027
+ };
1028
+ }
1029
+
1030
+ function normalizeClaimPath(value) {
1031
+ return value.replace(/^\/+/, '').replace(/\/+$/, '');
1032
+ }
1033
+
1034
+ function pathPrefixesOverlap(left, right) {
1035
+ return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
1036
+ }
1037
+
1038
+ function parseNullableNumber(value) {
1039
+ if (value === null || value === undefined) {
1040
+ return null;
1041
+ }
1042
+
1043
+ const number = typeof value === 'number' ? value : Number(value);
1044
+ return Number.isFinite(number) ? number : null;
1045
+ }
1046
+
1047
+ function formatLocalDateTime(date) {
1048
+ const offsetMinutes = -date.getTimezoneOffset();
1049
+ const sign = offsetMinutes >= 0 ? '+' : '-';
1050
+ const absOffset = Math.abs(offsetMinutes);
1051
+ const offset = `${sign}${String(Math.floor(absOffset / 60)).padStart(2, '0')}:${String(absOffset % 60).padStart(2, '0')}`;
1052
+
1053
+ return [
1054
+ date.getFullYear(),
1055
+ '-',
1056
+ String(date.getMonth() + 1).padStart(2, '0'),
1057
+ '-',
1058
+ String(date.getDate()).padStart(2, '0'),
1059
+ 'T',
1060
+ String(date.getHours()).padStart(2, '0'),
1061
+ ':',
1062
+ String(date.getMinutes()).padStart(2, '0'),
1063
+ ':',
1064
+ String(date.getSeconds()).padStart(2, '0'),
1065
+ offset,
1066
+ ].join('');
1067
+ }
1068
+
838
1069
  function requireNonEmptyString(value, message) {
839
1070
  const normalized = normalizeOptionalString(value);
840
1071
  if (!normalized) {
package/src/setup.js CHANGED
@@ -40,6 +40,10 @@ export async function resolveInitCliOptions(options, environment = {}) {
40
40
  throw new Error('init requires --session-working-on when --start-session is used without guided setup.');
41
41
  }
42
42
 
43
+ if (resolved.startSession && !resolved.sessionId) {
44
+ throw new Error('init requires --session-id when --start-session is used.');
45
+ }
46
+
43
47
  return {
44
48
  initOptions: {
45
49
  rootDir: resolved.rootDir,
@@ -62,6 +66,7 @@ export async function resolveInitCliOptions(options, environment = {}) {
62
66
  guidedSummary,
63
67
  sessionPlan: resolved.startSession
64
68
  ? {
69
+ sessionId: resolved.sessionId,
65
70
  workingOn: resolved.sessionWorkingOn,
66
71
  }
67
72
  : null,
@@ -278,6 +283,15 @@ async function completeGuidedInitOptions(options, environment) {
278
283
  if (!resolved.sessionWorkingOn) {
279
284
  resolved.sessionWorkingOn = await askInput(prompter, 'What are you working on?');
280
285
  }
286
+
287
+ if (!resolved.sessionId) {
288
+ resolved.sessionId = await askInput(prompter, 'Session lane id', {
289
+ defaultValue: suggestLaneId(resolved.sessionWorkingOn),
290
+ validate(value) {
291
+ return validateLaneIdForPrompt(value);
292
+ },
293
+ });
294
+ }
281
295
  }
282
296
 
283
297
  if (resolved.closeSessionGitPublish === undefined && (resolved.bootstrap.workflow || resolved.startSession)) {
@@ -669,6 +683,46 @@ function suggestRepoId(value) {
669
683
  return slug || 'repo';
670
684
  }
671
685
 
686
+ function suggestLaneId(value) {
687
+ const slug = String(value ?? '')
688
+ .toLowerCase()
689
+ .replace(/[^a-z0-9]+/g, '-')
690
+ .replace(/^-+|-+$/g, '')
691
+ .slice(0, 63)
692
+ .replace(/-+$/g, '');
693
+
694
+ return slug.length >= 3 ? slug : 'builder-session';
695
+ }
696
+
697
+ function validateLaneIdForPrompt(value) {
698
+ if (!/^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/.test(value)) {
699
+ return 'Lane id must be 3-64 lowercase letters, numbers, or hyphens, and must start and end with a letter or number.';
700
+ }
701
+
702
+ if ([
703
+ 'active',
704
+ 'current',
705
+ 'default',
706
+ 'false',
707
+ 'handoff',
708
+ 'index',
709
+ 'new',
710
+ 'no',
711
+ 'null',
712
+ 'off',
713
+ 'on',
714
+ 'sessions',
715
+ 'state',
716
+ 'true',
717
+ 'wip',
718
+ 'yes',
719
+ ].includes(value)) {
720
+ return `Lane id "${value}" is reserved.`;
721
+ }
722
+
723
+ return null;
724
+ }
725
+
672
726
  function getRepoDirectoryHints(repo) {
673
727
  const hints = new Set();
674
728