@vibecompass/vibecompass 0.3.0 → 0.3.3

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.
@@ -468,8 +468,23 @@ function parseDecisionDocument(options) {
468
468
  const warnings = [];
469
469
  const errors = [];
470
470
  const entries = extractDecisionEntries(options.content);
471
+ const malformedHeadings = extractMalformedDecisionHeadings(options.content);
472
+
473
+ for (const heading of malformedHeadings) {
474
+ errors.push(
475
+ createError(
476
+ options.relativePath,
477
+ 'decision-invalid-id',
478
+ `Decision heading "${heading}" must use zero-padded D-NNN or higher.`,
479
+ ),
480
+ );
481
+ }
471
482
 
472
483
  if (entries.length === 0) {
484
+ if (malformedHeadings.length > 0) {
485
+ return { kind: 'decision', extracted: null, warnings, errors };
486
+ }
487
+
473
488
  errors.push(
474
489
  createError(options.relativePath, 'decision-missing-entries', 'Decision files must contain at least one resolvable decision entry.'),
475
490
  );
@@ -479,7 +494,7 @@ function parseDecisionDocument(options) {
479
494
  const decisionIds = [];
480
495
  for (const entry of entries) {
481
496
  if (!entry.decisionId || entry.decisionId <= 0) {
482
- errors.push(createError(options.relativePath, 'decision-invalid-id', 'Decision heading must include D-<positive integer>.'));
497
+ errors.push(createError(options.relativePath, 'decision-invalid-id', 'Decision heading must include zero-padded D-<NNN>.'));
483
498
  continue;
484
499
  }
485
500
 
@@ -576,7 +591,7 @@ function parseSessionDocument(options) {
576
591
  }
577
592
 
578
593
  function extractDecisionEntries(content) {
579
- const matches = [...content.matchAll(/^###\s+D-(\d+)\b.*$/gm)];
594
+ const matches = [...content.matchAll(/^###\s+D-(\d{3,})\b.*$/gm)];
580
595
 
581
596
  return matches.map((match, index) => {
582
597
  const bodyStart = match.index ?? 0;
@@ -590,6 +605,10 @@ function extractDecisionEntries(content) {
590
605
  });
591
606
  }
592
607
 
608
+ function extractMalformedDecisionHeadings(content) {
609
+ return [...content.matchAll(/^###\s+(D-\d{1,2})\b.*$/gm)].map((match) => match[1]);
610
+ }
611
+
593
612
  function findDuplicateDecisionErrors(documents) {
594
613
  const seen = new Map();
595
614
  const errors = [];
package/src/read-model.js CHANGED
@@ -5,7 +5,7 @@ import { generateStateManifest } from './manifest.js';
5
5
  import { scanProjectMemory } from './project-memory.js';
6
6
 
7
7
  const SECTION_PATTERN = /^##\s+(.+)$/gm;
8
- const DECISION_HEADING_PATTERN = /^###\s+D-(\d+)\s+—\s+(.+)$/gm;
8
+ const DECISION_HEADING_PATTERN = /^###\s+D-(\d{3,})\s+—\s+(.+)$/gm;
9
9
 
10
10
  export async function loadProjectReadModel(rootDir) {
11
11
  const scanResult = await scanProjectMemory(rootDir);
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, readdir, realpath } 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,96 @@ 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
 
223
+ const detectedRepos = inferredProject.repos ?? [];
224
+
183
225
  if (!Array.isArray(resolved.repos) || resolved.repos.length === 0) {
184
- resolved.repos = await promptForRepos(prompter, cwd);
226
+ if (detectedRepos.length > 0) {
227
+ const useDetectedRepos = await askConfirm(
228
+ prompter,
229
+ formatDetectedReposPrompt(detectedRepos),
230
+ true,
231
+ );
232
+ resolved.repos = useDetectedRepos
233
+ ? detectedRepos.map((repo) => toProjectRepo(repo))
234
+ : await promptForRepos(prompter, cwd);
235
+ } else {
236
+ resolved.repos = await promptForRepos(prompter, cwd);
237
+ }
185
238
  }
186
239
 
187
240
  let recommendation = null;
188
241
  if (!resolved.placementPattern) {
189
- recommendation = await promptForPlacement(prompter, {
190
- cwd,
191
- repoCount: resolved.repos.length,
192
- });
193
- resolved.placementPattern = recommendation.pattern;
242
+ if (inferredProject.source === 'child-repos' && detectedRepos.length > 1 && reposMatchDetected(resolved.repos, detectedRepos)) {
243
+ recommendation = recommendPlacementPattern({
244
+ repoCount: detectedRepos.length,
245
+ usesSharedWorkspace: true,
246
+ });
247
+ resolved.placementPattern = recommendation.pattern;
248
+ prompter.write(`Recommended placement: ${recommendation.pattern}\n`);
249
+ prompter.write(`${recommendation.reason}\n`);
250
+ } else if (detectedRepos.length === 1 && reposMatchDetected(resolved.repos, detectedRepos)) {
251
+ recommendation = recommendPlacementPattern({ repoCount: 1 });
252
+ resolved.placementPattern = recommendation.pattern;
253
+ } else {
254
+ recommendation = await promptForPlacement(prompter, {
255
+ cwd,
256
+ repoCount: resolved.repos.length,
257
+ });
258
+ resolved.placementPattern = recommendation.pattern;
259
+ }
194
260
  }
195
261
 
196
262
  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
- });
263
+ if (resolved.placementPattern === PLACEMENT_PATTERNS.PRIMARY_REPO && detectedRepos.length === 1 && reposMatchDetected(resolved.repos, detectedRepos)) {
264
+ resolved.toolingRootDir = detectedRepos[0].directory;
265
+ } else if (resolved.placementPattern === PLACEMENT_PATTERNS.WORKSPACE_ROOT && inferredProject.source === 'child-repos' && detectedRepos.length > 1 && reposMatchDetected(resolved.repos, detectedRepos)) {
266
+ resolved.toolingRootDir = '.';
267
+ } else {
268
+ const ownerLabel = describePlacementOwner(resolved.placementPattern);
269
+ const defaultOwnerDir = await suggestOwnerDirectory({
270
+ cwd,
271
+ placementPattern: resolved.placementPattern,
272
+ repos: resolved.repos,
273
+ });
274
+ resolved.toolingRootDir = await askInput(prompter, ownerLabel, {
275
+ defaultValue: defaultOwnerDir,
276
+ validate: async (value) => {
277
+ if (!(await directoryExists(path.resolve(cwd, value)))) {
278
+ return `${ownerLabel} must already exist relative to the current working directory.`;
279
+ }
280
+
281
+ return null;
282
+ },
283
+ });
284
+ }
213
285
  }
214
286
 
215
287
  resolved = applyPlacementDefaults(resolved, { cwd });
216
288
 
217
- if (!resolved.sync && resolved.mode === 'local-primary') {
289
+ if (!resolved.sync && resolved.mode === 'hosted-only') {
218
290
  const configureSync = await askConfirm(
219
291
  prompter,
220
- 'Configure hosted sync binding now?',
221
- false,
292
+ 'Connect hosted VibeCompass now?',
293
+ resolved.mode === 'hosted-only',
222
294
  );
223
295
 
224
296
  if (configureSync) {
@@ -322,6 +394,170 @@ async function completeGuidedInitOptions(options, environment) {
322
394
  };
323
395
  }
324
396
 
397
+ async function inferProjectFromCwd(cwd) {
398
+ const [packageName, gitInfo, childRepos] = await Promise.all([
399
+ readPackageName(cwd),
400
+ readGitInfo(cwd),
401
+ discoverChildGitRepos(cwd),
402
+ ]);
403
+ const projectName = packageName ?? path.basename(cwd);
404
+
405
+ if (gitInfo?.remote) {
406
+ const remoteSlug = extractRemoteSlug(gitInfo.remote);
407
+ const repoId = suggestRepoId(remoteSlug ?? packageName ?? path.basename(gitInfo.root ?? cwd));
408
+
409
+ return {
410
+ projectName,
411
+ source: 'current-repo',
412
+ repos: [
413
+ {
414
+ id: repoId,
415
+ remote: gitInfo.remote,
416
+ directory: '.',
417
+ ...(gitInfo.branch ? { defaultBranch: gitInfo.branch } : {}),
418
+ },
419
+ ],
420
+ };
421
+ }
422
+
423
+ if (childRepos.length > 0) {
424
+ return {
425
+ projectName,
426
+ source: 'child-repos',
427
+ repos: childRepos,
428
+ };
429
+ }
430
+
431
+ return {
432
+ projectName,
433
+ source: 'none',
434
+ repos: [],
435
+ };
436
+ }
437
+
438
+ async function readPackageName(cwd) {
439
+ try {
440
+ const packageJson = JSON.parse(await readFile(path.join(cwd, 'package.json'), 'utf8'));
441
+ return typeof packageJson.name === 'string' && packageJson.name.trim()
442
+ ? packageJson.name.trim()
443
+ : null;
444
+ } catch {
445
+ return null;
446
+ }
447
+ }
448
+
449
+ async function readGitInfo(cwd) {
450
+ try {
451
+ const [{ stdout: remote }, { stdout: root }, branchResult] = await Promise.all([
452
+ execFileAsync('git', ['remote', 'get-url', 'origin'], { cwd }),
453
+ execFileAsync('git', ['rev-parse', '--show-toplevel'], { cwd }),
454
+ execFileAsync('git', ['branch', '--show-current'], { cwd }).catch(() => ({ stdout: '' })),
455
+ ]);
456
+ const normalizedRemote = remote.trim();
457
+ if (!normalizedRemote) {
458
+ return null;
459
+ }
460
+
461
+ return {
462
+ remote: normalizedRemote,
463
+ root: root.trim() || null,
464
+ branch: branchResult.stdout.trim() || null,
465
+ };
466
+ } catch {
467
+ return null;
468
+ }
469
+ }
470
+
471
+ async function discoverChildGitRepos(cwd) {
472
+ let entries = [];
473
+ try {
474
+ entries = await readdir(cwd, { withFileTypes: true });
475
+ } catch {
476
+ return [];
477
+ }
478
+
479
+ const candidates = entries
480
+ .filter((entry) => entry.isDirectory() && isScannableWorkspaceChild(entry.name))
481
+ .sort((left, right) => left.name.localeCompare(right.name));
482
+
483
+ const discovered = await Promise.all(candidates.map(async (entry) => {
484
+ const directory = entry.name;
485
+ const absoluteDirectory = path.join(cwd, directory);
486
+ const [gitInfo, packageName] = await Promise.all([
487
+ readGitInfo(absoluteDirectory),
488
+ readPackageName(absoluteDirectory),
489
+ ]);
490
+
491
+ if (!gitInfo?.remote) {
492
+ return null;
493
+ }
494
+
495
+ if (gitInfo.root && !(await isSameDirectory(gitInfo.root, absoluteDirectory))) {
496
+ return null;
497
+ }
498
+
499
+ const remoteSlug = extractRemoteSlug(gitInfo.remote);
500
+ return {
501
+ id: suggestRepoId(remoteSlug ?? packageName ?? directory),
502
+ remote: gitInfo.remote,
503
+ directory,
504
+ ...(gitInfo.branch ? { defaultBranch: gitInfo.branch } : {}),
505
+ };
506
+ }));
507
+
508
+ const reposByDirectory = new Map();
509
+ for (const repo of discovered) {
510
+ if (repo && !reposByDirectory.has(repo.directory)) {
511
+ reposByDirectory.set(repo.directory, repo);
512
+ }
513
+ }
514
+
515
+ return [...reposByDirectory.values()].sort((left, right) => left.directory.localeCompare(right.directory));
516
+ }
517
+
518
+ function isScannableWorkspaceChild(name) {
519
+ if (!name || name.startsWith('.')) {
520
+ return false;
521
+ }
522
+
523
+ return !new Set([
524
+ 'build',
525
+ 'coverage',
526
+ 'dist',
527
+ 'node_modules',
528
+ 'out',
529
+ 'target',
530
+ 'vendor',
531
+ ]).has(name);
532
+ }
533
+
534
+ function formatDetectedReposPrompt(repos) {
535
+ if (repos.length === 1) {
536
+ const repo = repos[0];
537
+ const location = repo.directory && repo.directory !== '.' ? ` in ${repo.directory}` : '';
538
+ return `Use detected Git repo${location} (${repo.remote})?`;
539
+ }
540
+
541
+ return `Use ${repos.length} detected Git repos (${repos.map((repo) => repo.directory).join(', ')})?`;
542
+ }
543
+
544
+ function toProjectRepo(repo) {
545
+ return {
546
+ id: repo.id,
547
+ remote: repo.remote,
548
+ ...(repo.defaultBranch ? { defaultBranch: repo.defaultBranch } : {}),
549
+ };
550
+ }
551
+
552
+ function reposMatchDetected(repos, detectedRepos) {
553
+ if (!Array.isArray(repos) || repos.length !== detectedRepos.length) {
554
+ return false;
555
+ }
556
+
557
+ const detectedKeys = new Set(detectedRepos.map((repo) => `${repo.id}\n${repo.remote}`));
558
+ return repos.every((repo) => detectedKeys.has(`${repo.id}\n${repo.remote}`));
559
+ }
560
+
325
561
  async function promptForRepos(prompter, cwd) {
326
562
  const repoCount = await askInteger(prompter, 'How many repos belong to this logical project?', {
327
563
  defaultValue: 1,
@@ -757,6 +993,18 @@ async function directoryExists(directoryPath) {
757
993
  }
758
994
  }
759
995
 
996
+ async function isSameDirectory(left, right) {
997
+ try {
998
+ const [leftRealPath, rightRealPath] = await Promise.all([
999
+ realpath(left),
1000
+ realpath(right),
1001
+ ]);
1002
+ return leftRealPath === rightRealPath;
1003
+ } catch {
1004
+ return path.resolve(left) === path.resolve(right);
1005
+ }
1006
+ }
1007
+
760
1008
  function toRelativeIfInsideCwd(targetPath, cwd) {
761
1009
  const absolute = path.resolve(cwd, targetPath);
762
1010
  const relative = path.relative(cwd, absolute);