@vibecompass/vibecompass 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/session.js CHANGED
@@ -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,8 @@ 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);
102
118
  const agentFileSync = await syncAgentInstructionFilesSafely({
103
119
  rootDir: normalized.rootDir,
104
120
  toolingRootDir: normalized.toolingRootDir,
@@ -114,6 +130,11 @@ export async function startProjectSession(options) {
114
130
  handoffFilePath: lanePaths.handoffFilePath,
115
131
  sessionDate,
116
132
  sessionNumber: nextSessionNumber,
133
+ warnings: [
134
+ ...metadataWarnings,
135
+ ...overlapWarnings,
136
+ ...auditWarnings,
137
+ ],
117
138
  agentFileSync,
118
139
  };
119
140
  }
@@ -314,6 +335,26 @@ async function syncAgentInstructionFilesSafely(options) {
314
335
  }
315
336
  }
316
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
+
317
358
  function normalizeSessionPaths(options) {
318
359
  const cwd = options?.cwd ? path.resolve(options.cwd) : process.cwd();
319
360
  const rootDir = path.resolve(cwd, options?.rootDir ?? '.compass');
@@ -511,6 +552,9 @@ async function listActiveSessionLanes(normalized) {
511
552
  features: metadata.features,
512
553
  repos: metadata.repos,
513
554
  claims: metadata.claims,
555
+ startedAt: metadata.startedAt,
556
+ decisionSnapshot: metadata.decisionSnapshot,
557
+ warnings: metadata.warnings,
514
558
  sessionDate: metadata.sessionDate ?? wipSession?.sessionDate ?? null,
515
559
  sessionNumber: metadata.sessionNumber ?? wipSession?.sessionNumber ?? 0,
516
560
  });
@@ -544,18 +588,30 @@ async function readLaneMetadata(sessionFilePath) {
544
588
  features: normalizeStringArray(data.feature_slugs),
545
589
  repos: normalizeStringArray(data.repos),
546
590
  claims: normalizeStringArray(data.claimed_paths),
591
+ startedAt: normalizeOptionalString(data.started_at),
592
+ decisionSnapshot: {
593
+ highestDecisionId: parseNullableNumber(data.decision_snapshot?.highest_decision_id),
594
+ },
547
595
  sessionDate: normalizeOptionalString(data.session_date),
548
596
  sessionNumber: typeof data.session_number === 'number' ? data.session_number : Number(data.session_number) || null,
597
+ warnings: [],
549
598
  };
550
- } catch {
599
+ } catch (error) {
551
600
  return {
552
601
  status: null,
553
602
  workingOn: null,
554
603
  features: [],
555
604
  repos: [],
556
605
  claims: [],
606
+ startedAt: null,
607
+ decisionSnapshot: {
608
+ highestDecisionId: null,
609
+ },
557
610
  sessionDate: null,
558
611
  sessionNumber: null,
612
+ warnings: [
613
+ `Could not parse ${sessionFilePath}: ${error instanceof Error ? error.message : String(error)}`,
614
+ ],
559
615
  };
560
616
  }
561
617
  }
@@ -778,9 +834,9 @@ function renderLaneMetadata(options) {
778
834
  renderYamlArray('feature_slugs', options.features),
779
835
  renderYamlArray('repos', options.repos),
780
836
  renderYamlArray('claimed_paths', options.claims),
781
- `started_at: ${new Date().toISOString()}`,
837
+ `started_at: ${formatLocalDateTime(new Date())}`,
782
838
  'decision_snapshot:',
783
- ' highest_decision_id: null',
839
+ ` highest_decision_id: ${options.highestDecisionId ?? 'null'}`,
784
840
  '',
785
841
  ].join('\n');
786
842
  }
@@ -892,6 +948,124 @@ function normalizeSessionDate(value) {
892
948
  ].join('-');
893
949
  }
894
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
+
895
1069
  function requireNonEmptyString(value, message) {
896
1070
  const normalized = normalizeOptionalString(value);
897
1071
  if (!normalized) {
package/src/setup.js CHANGED
@@ -1,6 +1,10 @@
1
- import { access } from 'node:fs/promises';
1
+ import { execFile } from 'node:child_process';
2
+ import { access, readFile } from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
  import { createInterface } from 'node:readline/promises';
5
+ import { promisify } from 'node:util';
6
+
7
+ const execFileAsync = promisify(execFile);
4
8
 
5
9
  export const PLACEMENT_PATTERNS = {
6
10
  WORKSPACE_ROOT: 'workspace-root',
@@ -73,6 +77,40 @@ export async function resolveInitCliOptions(options, environment = {}) {
73
77
  };
74
78
  }
75
79
 
80
+ export async function resolveConnectHostedCliOptions(options, environment = {}) {
81
+ const cwd = environment.cwd ? path.resolve(environment.cwd) : process.cwd();
82
+ let resolved = {
83
+ ...options,
84
+ sync: isPlainObject(options?.sync) ? { ...options.sync } : {},
85
+ };
86
+
87
+ if (!resolved.sync.apiUrl || !resolved.sync.projectId || !resolved.sync.credentialEnvVar) {
88
+ const prompter = createPrompter(environment.io, environment.runtime);
89
+ try {
90
+ if (!resolved.sync.apiUrl) {
91
+ resolved.sync.apiUrl = await askInput(prompter, 'Hosted sync API URL', {
92
+ defaultValue: 'https://vibecompass.dev',
93
+ });
94
+ }
95
+ if (!resolved.sync.projectId) {
96
+ resolved.sync.projectId = await askInput(prompter, 'Hosted project id');
97
+ }
98
+ if (!resolved.sync.credentialEnvVar) {
99
+ resolved.sync.credentialEnvVar = await askInput(prompter, 'Credential env var', {
100
+ defaultValue: 'VIBECOMPASS_SYNC_TOKEN',
101
+ });
102
+ }
103
+ } finally {
104
+ await prompter.close();
105
+ }
106
+ }
107
+
108
+ return {
109
+ rootDir: resolved.rootDir ? toRelativeIfInsideCwd(resolved.rootDir, cwd) : undefined,
110
+ sync: resolved.sync,
111
+ };
112
+ }
113
+
76
114
  export function applyPlacementDefaults(options, environment = {}) {
77
115
  const cwd = environment.cwd ? path.resolve(environment.cwd) : process.cwd();
78
116
  const resolved = cloneInitOptions(options);
@@ -153,7 +191,8 @@ async function completeGuidedInitOptions(options, environment) {
153
191
  let resolved = cloneInitOptions(options);
154
192
  const cwd = environment.cwd;
155
193
  const prompter = environment.prompter;
156
- const projectNameDefault = path.basename(cwd);
194
+ const inferredProject = await inferProjectFromCwd(cwd);
195
+ const projectNameDefault = inferredProject.projectName ?? path.basename(cwd);
157
196
 
158
197
  if (!resolved.name) {
159
198
  resolved.name = await askInput(prompter, 'Project name', {
@@ -162,63 +201,82 @@ async function completeGuidedInitOptions(options, environment) {
162
201
  }
163
202
 
164
203
  if (!resolved.mode) {
165
- resolved.mode = await askChoice(prompter, 'Project mode', [
204
+ const setupGoal = await askChoice(prompter, 'What should VibeCompass set up?', [
166
205
  {
167
- value: 'local-only',
168
- description: 'Local files only. No hosted dependency.',
206
+ value: 'share-later',
207
+ description: 'Recommended. Local notes, with the option to share or collaborate later.',
169
208
  },
170
209
  {
171
- value: 'local-primary',
172
- description: 'Local files stay authoritative, with optional hosted sync/projection.',
210
+ value: 'local-only',
211
+ description: 'Local files only. No hosted sync or hosted docs-review prompts.',
173
212
  },
174
213
  {
175
214
  value: 'hosted-only',
176
- description: 'Compatibility mode when hosted state remains authoritative.',
215
+ description: 'Hosted VibeCompass is already the source of truth for this project.',
177
216
  },
178
217
  ], {
179
- defaultValue: 'local-only',
218
+ defaultValue: 'share-later',
180
219
  });
220
+ resolved.mode = setupGoal === 'share-later' ? 'local-primary' : setupGoal;
181
221
  }
182
222
 
183
223
  if (!Array.isArray(resolved.repos) || resolved.repos.length === 0) {
184
- resolved.repos = await promptForRepos(prompter, cwd);
224
+ if (inferredProject.repo) {
225
+ const useDetectedRepo = await askConfirm(
226
+ prompter,
227
+ `Use detected Git repo ${inferredProject.repo.remote}?`,
228
+ true,
229
+ );
230
+ resolved.repos = useDetectedRepo ? [inferredProject.repo] : await promptForRepos(prompter, cwd);
231
+ } else {
232
+ resolved.repos = await promptForRepos(prompter, cwd);
233
+ }
185
234
  }
186
235
 
187
236
  let recommendation = null;
188
237
  if (!resolved.placementPattern) {
189
- recommendation = await promptForPlacement(prompter, {
190
- cwd,
191
- repoCount: resolved.repos.length,
192
- });
193
- resolved.placementPattern = recommendation.pattern;
238
+ if (inferredProject.repo && resolved.repos.length === 1) {
239
+ recommendation = recommendPlacementPattern({ repoCount: 1 });
240
+ resolved.placementPattern = recommendation.pattern;
241
+ } else {
242
+ recommendation = await promptForPlacement(prompter, {
243
+ cwd,
244
+ repoCount: resolved.repos.length,
245
+ });
246
+ resolved.placementPattern = recommendation.pattern;
247
+ }
194
248
  }
195
249
 
196
250
  if (!resolved.toolingRootDir) {
197
- const ownerLabel = describePlacementOwner(resolved.placementPattern);
198
- const defaultOwnerDir = await suggestOwnerDirectory({
199
- cwd,
200
- placementPattern: resolved.placementPattern,
201
- repos: resolved.repos,
202
- });
203
- resolved.toolingRootDir = await askInput(prompter, ownerLabel, {
204
- defaultValue: defaultOwnerDir,
205
- validate: async (value) => {
206
- if (!(await directoryExists(path.resolve(cwd, value)))) {
207
- return `${ownerLabel} must already exist relative to the current working directory.`;
208
- }
209
-
210
- return null;
211
- },
212
- });
251
+ if (inferredProject.repo && resolved.repos.length === 1 && resolved.placementPattern === PLACEMENT_PATTERNS.PRIMARY_REPO) {
252
+ resolved.toolingRootDir = '.';
253
+ } else {
254
+ const ownerLabel = describePlacementOwner(resolved.placementPattern);
255
+ const defaultOwnerDir = await suggestOwnerDirectory({
256
+ cwd,
257
+ placementPattern: resolved.placementPattern,
258
+ repos: resolved.repos,
259
+ });
260
+ resolved.toolingRootDir = await askInput(prompter, ownerLabel, {
261
+ defaultValue: defaultOwnerDir,
262
+ validate: async (value) => {
263
+ if (!(await directoryExists(path.resolve(cwd, value)))) {
264
+ return `${ownerLabel} must already exist relative to the current working directory.`;
265
+ }
266
+
267
+ return null;
268
+ },
269
+ });
270
+ }
213
271
  }
214
272
 
215
273
  resolved = applyPlacementDefaults(resolved, { cwd });
216
274
 
217
- if (!resolved.sync && resolved.mode === 'local-primary') {
275
+ if (!resolved.sync && resolved.mode === 'hosted-only') {
218
276
  const configureSync = await askConfirm(
219
277
  prompter,
220
- 'Configure hosted sync binding now?',
221
- false,
278
+ 'Connect hosted VibeCompass now?',
279
+ resolved.mode === 'hosted-only',
222
280
  );
223
281
 
224
282
  if (configureSync) {
@@ -322,6 +380,63 @@ async function completeGuidedInitOptions(options, environment) {
322
380
  };
323
381
  }
324
382
 
383
+ async function inferProjectFromCwd(cwd) {
384
+ const [packageName, gitInfo] = await Promise.all([
385
+ readPackageName(cwd),
386
+ readGitInfo(cwd),
387
+ ]);
388
+ const projectName = packageName ?? path.basename(cwd);
389
+
390
+ if (!gitInfo?.remote) {
391
+ return { projectName, repo: null };
392
+ }
393
+
394
+ const remoteSlug = extractRemoteSlug(gitInfo.remote);
395
+ const repoId = suggestRepoId(remoteSlug ?? packageName ?? path.basename(gitInfo.root ?? cwd));
396
+
397
+ return {
398
+ projectName,
399
+ repo: {
400
+ id: repoId,
401
+ remote: gitInfo.remote,
402
+ ...(gitInfo.branch ? { defaultBranch: gitInfo.branch } : {}),
403
+ },
404
+ };
405
+ }
406
+
407
+ async function readPackageName(cwd) {
408
+ try {
409
+ const packageJson = JSON.parse(await readFile(path.join(cwd, 'package.json'), 'utf8'));
410
+ return typeof packageJson.name === 'string' && packageJson.name.trim()
411
+ ? packageJson.name.trim()
412
+ : null;
413
+ } catch {
414
+ return null;
415
+ }
416
+ }
417
+
418
+ async function readGitInfo(cwd) {
419
+ try {
420
+ const [{ stdout: remote }, { stdout: root }, branchResult] = await Promise.all([
421
+ execFileAsync('git', ['remote', 'get-url', 'origin'], { cwd }),
422
+ execFileAsync('git', ['rev-parse', '--show-toplevel'], { cwd }),
423
+ execFileAsync('git', ['branch', '--show-current'], { cwd }).catch(() => ({ stdout: '' })),
424
+ ]);
425
+ const normalizedRemote = remote.trim();
426
+ if (!normalizedRemote) {
427
+ return null;
428
+ }
429
+
430
+ return {
431
+ remote: normalizedRemote,
432
+ root: root.trim() || null,
433
+ branch: branchResult.stdout.trim() || null,
434
+ };
435
+ } catch {
436
+ return null;
437
+ }
438
+ }
439
+
325
440
  async function promptForRepos(prompter, cwd) {
326
441
  const repoCount = await askInteger(prompter, 'How many repos belong to this logical project?', {
327
442
  defaultValue: 1,