@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.
- package/README.md +64 -22
- package/package.json +1 -1
- package/src/cli.js +399 -3
- package/src/docs-review.js +408 -1
- package/src/index.js +6 -0
- package/src/init.js +64 -24
- package/src/manifest.js +18 -1
- package/src/project-memory.js +21 -2
- package/src/read-model.js +1 -1
- package/src/setup.js +282 -34
- package/src/sync.js +395 -0
package/src/project-memory.js
CHANGED
|
@@ -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-<
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
204
|
+
const setupGoal = await askChoice(prompter, 'What should VibeCompass set up?', [
|
|
166
205
|
{
|
|
167
|
-
value: '
|
|
168
|
-
description: 'Local
|
|
206
|
+
value: 'share-later',
|
|
207
|
+
description: 'Recommended. Local notes, with the option to share or collaborate later.',
|
|
169
208
|
},
|
|
170
209
|
{
|
|
171
|
-
value: 'local-
|
|
172
|
-
description: 'Local files
|
|
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: '
|
|
215
|
+
description: 'Hosted VibeCompass is already the source of truth for this project.',
|
|
177
216
|
},
|
|
178
217
|
], {
|
|
179
|
-
defaultValue: '
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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 === '
|
|
289
|
+
if (!resolved.sync && resolved.mode === 'hosted-only') {
|
|
218
290
|
const configureSync = await askConfirm(
|
|
219
291
|
prompter,
|
|
220
|
-
'
|
|
221
|
-
|
|
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);
|