@vibecompass/vibecompass 0.1.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.
@@ -0,0 +1,107 @@
1
+ export function serializeProjectConfig(config) {
2
+ const lines = serializeMapping(config, 0);
3
+ return `${lines.join('\n')}\n`;
4
+ }
5
+
6
+ function serializeMapping(mapping, indent) {
7
+ const lines = [];
8
+
9
+ for (const [key, value] of Object.entries(mapping)) {
10
+ lines.push(...serializeEntry(key, value, indent));
11
+ }
12
+
13
+ return lines;
14
+ }
15
+
16
+ function serializeEntry(key, value, indent) {
17
+ const prefix = `${' '.repeat(indent)}${key}:`;
18
+
19
+ if (isScalar(value)) {
20
+ return [`${prefix} ${formatScalar(value)}`];
21
+ }
22
+
23
+ if (Array.isArray(value)) {
24
+ if (value.length === 0) {
25
+ return [`${prefix} []`];
26
+ }
27
+
28
+ return [
29
+ prefix,
30
+ ...value.flatMap((item) => serializeArrayItem(item, indent + 2)),
31
+ ];
32
+ }
33
+
34
+ const objectEntries = Object.entries(value);
35
+ if (objectEntries.length === 0) {
36
+ return [`${prefix} {}`];
37
+ }
38
+
39
+ return [
40
+ prefix,
41
+ ...serializeMapping(value, indent + 2),
42
+ ];
43
+ }
44
+
45
+ function serializeArrayItem(item, indent) {
46
+ const prefix = `${' '.repeat(indent)}-`;
47
+
48
+ if (isScalar(item)) {
49
+ return [`${prefix} ${formatScalar(item)}`];
50
+ }
51
+
52
+ if (Array.isArray(item)) {
53
+ throw new Error('Nested arrays are not supported when serializing project.yaml.');
54
+ }
55
+
56
+ const entries = Object.entries(item);
57
+ if (entries.length === 0) {
58
+ return [`${prefix} {}`];
59
+ }
60
+
61
+ const [firstKey, firstValue] = entries[0];
62
+ const firstEntryLines = serializeEntry(firstKey, firstValue, indent + 2);
63
+ const firstEntryFirstLine = firstEntryLines[0].slice(indent + 2);
64
+ const lines = [`${prefix} ${firstEntryFirstLine}`];
65
+
66
+ lines.push(...firstEntryLines.slice(1));
67
+
68
+ for (const [key, value] of entries.slice(1)) {
69
+ lines.push(...serializeEntry(key, value, indent + 2));
70
+ }
71
+
72
+ return lines;
73
+ }
74
+
75
+ function isScalar(value) {
76
+ return value === null || ['string', 'number', 'boolean'].includes(typeof value);
77
+ }
78
+
79
+ function formatScalar(value) {
80
+ if (value === null) {
81
+ return 'null';
82
+ }
83
+
84
+ if (typeof value === 'number' || typeof value === 'boolean') {
85
+ return String(value);
86
+ }
87
+
88
+ if (value === '') {
89
+ return '""';
90
+ }
91
+
92
+ if (requiresQuotes(value)) {
93
+ return JSON.stringify(value);
94
+ }
95
+
96
+ return value;
97
+ }
98
+
99
+ function requiresQuotes(value) {
100
+ return (
101
+ /^\s|\s$/.test(value) ||
102
+ /^[-?:,[\]{}#&*!|>'"%@`]/.test(value) ||
103
+ value.includes(': ') ||
104
+ value.includes(' #') ||
105
+ value.includes('\n')
106
+ );
107
+ }
@@ -0,0 +1,557 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { parseFrontmatter } from './frontmatter.js';
4
+ import { generateStateManifest } from './manifest.js';
5
+ import { scanProjectMemory } from './project-memory.js';
6
+
7
+ const SECTION_PATTERN = /^##\s+(.+)$/gm;
8
+ const DECISION_HEADING_PATTERN = /^###\s+D-(\d+)\s+—\s+(.+)$/gm;
9
+
10
+ export async function loadProjectReadModel(rootDir) {
11
+ const scanResult = await scanProjectMemory(rootDir);
12
+ if (scanResult.errors.length > 0) {
13
+ const details = scanResult.errors.map((error) => `${error.path}: ${error.message}`).join('\n');
14
+ throw new Error(`Cannot build read model with canonical parse errors.\n${details}`);
15
+ }
16
+
17
+ const existingManifest = await readManifestIfPresent(rootDir);
18
+ const currentManifest = generateStateManifest(scanResult, {
19
+ generatedAt: existingManifest?.generated_at ?? new Date(),
20
+ sync: existingManifest?.sync,
21
+ });
22
+
23
+ const freshness = existingManifest?.canonical?.manifest_hash === currentManifest.canonical.manifest_hash
24
+ ? existingManifest.generated_at
25
+ : new Date().toISOString();
26
+
27
+ const warningSummary = summarizeWarnings(scanResult.warnings);
28
+ const project = buildProjectSummary(scanResult, currentManifest, freshness, existingManifest);
29
+ const repoAliases = buildRepoAliasMap(project.repos);
30
+ const featureMap = buildFeatureMap(scanResult, repoAliases, project.repos);
31
+ const features = [...featureMap.values()].sort((left, right) =>
32
+ left.feature_key.localeCompare(right.feature_key),
33
+ );
34
+ const domains = buildDomainSummaries(features);
35
+ const decisions = buildDecisionList(scanResult);
36
+ const sessions = buildSessionList(scanResult);
37
+ const fileOwners = buildFileOwnershipIndex(features);
38
+
39
+ return {
40
+ freshness,
41
+ repo_aliases: repoAliases,
42
+ manifest_state: {
43
+ exists: Boolean(existingManifest),
44
+ current: Boolean(existingManifest) && existingManifest.canonical.manifest_hash === currentManifest.canonical.manifest_hash,
45
+ manifest_hash: currentManifest.canonical.manifest_hash,
46
+ },
47
+ project,
48
+ warning_summary: warningSummary,
49
+ domains,
50
+ features,
51
+ decisions,
52
+ sessions,
53
+ file_owners: fileOwners,
54
+ };
55
+ }
56
+
57
+ export function getProjectContext(readModel, options = {}) {
58
+ const decisionLimit = options.decisionLimit ?? 10;
59
+ const sessionLimit = options.sessionLimit ?? 5;
60
+
61
+ return {
62
+ freshness: readModel.freshness,
63
+ manifest_state: readModel.manifest_state,
64
+ project: readModel.project,
65
+ warning_summary: readModel.warning_summary,
66
+ domains: readModel.domains,
67
+ recent_decisions: readModel.decisions.slice(0, decisionLimit),
68
+ recent_sessions: readModel.sessions.slice(0, sessionLimit),
69
+ };
70
+ }
71
+
72
+ export function getFeatureContext(readModel, lookup) {
73
+ const feature = findFeature(readModel.features, lookup);
74
+ if (!feature) {
75
+ return null;
76
+ }
77
+
78
+ return {
79
+ freshness: readModel.freshness,
80
+ feature,
81
+ };
82
+ }
83
+
84
+ export function getDecisionLog(readModel, options = {}) {
85
+ const limit = Math.max(1, Math.min(options.limit ?? 20, 100));
86
+
87
+ return {
88
+ freshness: readModel.freshness,
89
+ decisions: readModel.decisions.slice(0, limit),
90
+ };
91
+ }
92
+
93
+ export function getFileContext(readModel, filepath) {
94
+ const normalizedPath = normalizeLookupPath(filepath, readModel.repo_aliases, readModel.project.repos);
95
+ const owners = readModel.file_owners.get(normalizedPath) ?? [];
96
+
97
+ return {
98
+ freshness: readModel.freshness,
99
+ path: normalizedPath,
100
+ owners,
101
+ };
102
+ }
103
+
104
+ function buildProjectSummary(scanResult, currentManifest, freshness, existingManifest) {
105
+ const extracted = scanResult.project.extracted ?? {};
106
+ const repos = (extracted.repos ?? []).map((repo) => ({
107
+ id: repo.id,
108
+ remote: repo.remote,
109
+ default_branch: repo.default_branch,
110
+ }));
111
+
112
+ return {
113
+ name: extracted.name,
114
+ slug: extracted.slug,
115
+ description: extracted.description,
116
+ mode: extracted.mode,
117
+ format_version: extracted.format_version,
118
+ root_dir: scanResult.rootDir,
119
+ repos,
120
+ document_count: currentManifest.canonical.document_count,
121
+ warning_count: currentManifest.canonical.warning_count,
122
+ local_root_revision: currentManifest.canonical.local_root_revision,
123
+ manifest_hash: currentManifest.canonical.manifest_hash,
124
+ last_manifest_generated_at: existingManifest?.generated_at ?? null,
125
+ freshness,
126
+ };
127
+ }
128
+
129
+ function buildFeatureMap(scanResult, repoAliases, projectRepos) {
130
+ const featureMap = new Map();
131
+
132
+ for (const document of scanResult.documents) {
133
+ if (document.kind !== 'architecture' || !document.extracted) {
134
+ continue;
135
+ }
136
+
137
+ const featureKey = createFeatureKey(document.extracted.domain, document.extracted.feature);
138
+ const component = buildComponentRecord(document, repoAliases, projectRepos);
139
+ const existing = featureMap.get(featureKey);
140
+
141
+ if (existing) {
142
+ existing.components.push(component);
143
+ existing.repo_ids = uniqueSorted([...existing.repo_ids, ...component.repo_ids]);
144
+ existing.involved_files = uniqueSorted([...existing.involved_files, ...component.involved_files]);
145
+ existing.component_count = existing.components.length;
146
+ existing.overall_status = summarizeStatuses(existing.components.map((entry) => entry.status));
147
+ existing.component_status_counts = summarizeStatusCounts(existing.components.map((entry) => entry.status));
148
+ continue;
149
+ }
150
+
151
+ featureMap.set(featureKey, {
152
+ feature_key: featureKey,
153
+ domain: document.extracted.domain,
154
+ domain_key: slugify(document.extracted.domain),
155
+ feature: document.extracted.feature,
156
+ feature_slug: slugify(document.extracted.feature),
157
+ overall_status: summarizeStatuses([component.status]),
158
+ component_count: 1,
159
+ component_status_counts: summarizeStatusCounts([component.status]),
160
+ repo_ids: component.repo_ids,
161
+ involved_files: component.involved_files,
162
+ components: [component],
163
+ warnings: document.warnings,
164
+ warning_count: document.warnings.length,
165
+ });
166
+ }
167
+
168
+ for (const feature of featureMap.values()) {
169
+ feature.components.sort((left, right) => left.component.localeCompare(right.component));
170
+ }
171
+
172
+ return featureMap;
173
+ }
174
+
175
+ function buildComponentRecord(document, repoAliases, projectRepos) {
176
+ const frontmatter = parseFrontmatter(document.content, { sourceName: document.path });
177
+ const sections = splitMarkdownSections(frontmatter.body);
178
+ const repoIds = uniqueSorted(document.extracted.repo_ids ?? []);
179
+ const involvedFiles = parseInvolvedFiles({
180
+ rawSection: sections.get('Involved files') ?? '',
181
+ defaultRepoIds: repoIds,
182
+ repoAliases,
183
+ projectRepos,
184
+ });
185
+
186
+ return {
187
+ component_key: slugify(document.extracted.component),
188
+ component: document.extracted.component,
189
+ status: document.extracted.status,
190
+ path: document.path,
191
+ repo_ids: repoIds,
192
+ description: normalizeSectionText(sections.get('Description')),
193
+ details: normalizeSectionText(sections.get('Details')),
194
+ next_steps: normalizeSectionText(sections.get('Next steps')),
195
+ involved_files: involvedFiles,
196
+ warnings: document.warnings,
197
+ warning_count: document.warnings.length,
198
+ };
199
+ }
200
+
201
+ function buildDomainSummaries(features) {
202
+ const map = new Map();
203
+
204
+ for (const feature of features) {
205
+ const key = feature.domain_key;
206
+ const existing = map.get(key);
207
+ const featureSummary = {
208
+ feature_key: feature.feature_key,
209
+ feature: feature.feature,
210
+ feature_slug: feature.feature_slug,
211
+ overall_status: feature.overall_status,
212
+ component_count: feature.component_count,
213
+ repo_ids: feature.repo_ids,
214
+ };
215
+
216
+ if (existing) {
217
+ existing.features.push(featureSummary);
218
+ existing.feature_count += 1;
219
+ continue;
220
+ }
221
+
222
+ map.set(key, {
223
+ domain: feature.domain,
224
+ domain_key: key,
225
+ feature_count: 1,
226
+ features: [featureSummary],
227
+ });
228
+ }
229
+
230
+ return [...map.values()]
231
+ .sort((left, right) => left.domain.localeCompare(right.domain))
232
+ .map((domain) => ({
233
+ ...domain,
234
+ features: domain.features.sort((left, right) => left.feature.localeCompare(right.feature)),
235
+ }));
236
+ }
237
+
238
+ function buildDecisionList(scanResult) {
239
+ const decisions = [];
240
+
241
+ for (const document of scanResult.documents) {
242
+ if (document.kind !== 'decision') {
243
+ continue;
244
+ }
245
+
246
+ const fileLabel = path.basename(document.path, '.md');
247
+ for (const entry of extractDecisionEntries(document.content, document.path)) {
248
+ decisions.push({
249
+ decision_id: entry.decision_id,
250
+ title: entry.title,
251
+ timestamp: entry.timestamp,
252
+ decision: entry.decision,
253
+ rationale: entry.rationale,
254
+ path: document.path,
255
+ domain_file: fileLabel,
256
+ });
257
+ }
258
+ }
259
+
260
+ return decisions.sort((left, right) => right.decision_id - left.decision_id);
261
+ }
262
+
263
+ function buildSessionList(scanResult) {
264
+ return scanResult.documents
265
+ .filter((document) => document.kind === 'session' && document.extracted)
266
+ .map((document) => ({
267
+ title: document.extracted.title,
268
+ session_date: document.extracted.session_date,
269
+ session_number: document.extracted.session_number,
270
+ path: document.path,
271
+ }))
272
+ .sort(compareSessionsDescending);
273
+ }
274
+
275
+ function buildFileOwnershipIndex(features) {
276
+ const index = new Map();
277
+
278
+ for (const feature of features) {
279
+ for (const component of feature.components) {
280
+ for (const involvedFile of component.involved_files) {
281
+ if (!index.has(involvedFile)) {
282
+ index.set(involvedFile, []);
283
+ }
284
+
285
+ index.get(involvedFile).push({
286
+ feature_key: feature.feature_key,
287
+ domain: feature.domain,
288
+ feature: feature.feature,
289
+ component: component.component,
290
+ component_key: component.component_key,
291
+ component_path: component.path,
292
+ repo_ids: component.repo_ids,
293
+ matched_file: involvedFile,
294
+ });
295
+ }
296
+ }
297
+ }
298
+
299
+ for (const owners of index.values()) {
300
+ owners.sort((left, right) => {
301
+ const featureComparison = left.feature_key.localeCompare(right.feature_key);
302
+ if (featureComparison !== 0) {
303
+ return featureComparison;
304
+ }
305
+ return left.component.localeCompare(right.component);
306
+ });
307
+ }
308
+
309
+ return index;
310
+ }
311
+
312
+ function findFeature(features, lookup) {
313
+ if (!lookup) {
314
+ return null;
315
+ }
316
+
317
+ if (typeof lookup === 'string') {
318
+ return features.find((feature) => feature.feature_key === lookup) ?? null;
319
+ }
320
+
321
+ if (lookup.featureKey) {
322
+ return features.find((feature) => feature.feature_key === lookup.featureKey) ?? null;
323
+ }
324
+
325
+ if (lookup.domain && lookup.feature) {
326
+ return (
327
+ features.find(
328
+ (feature) => feature.domain === lookup.domain && feature.feature === lookup.feature,
329
+ ) ?? null
330
+ );
331
+ }
332
+
333
+ return null;
334
+ }
335
+
336
+ function extractDecisionEntries(content, sourcePath) {
337
+ const matches = [...content.matchAll(DECISION_HEADING_PATTERN)];
338
+
339
+ return matches.map((match, index) => {
340
+ const bodyStart = (match.index ?? 0) + match[0].length;
341
+ const nextMatch = matches[index + 1];
342
+ const bodyEnd = nextMatch?.index ?? content.length;
343
+ const body = content.slice(bodyStart, bodyEnd);
344
+
345
+ return {
346
+ decision_id: Number(match[1]),
347
+ title: match[2].trim(),
348
+ timestamp: extractLabeledValue(body, 'Timestamp'),
349
+ decision: extractLabeledValue(body, 'Decision'),
350
+ rationale: extractLabeledValue(body, 'Rationale'),
351
+ path: sourcePath,
352
+ };
353
+ });
354
+ }
355
+
356
+ function splitMarkdownSections(body) {
357
+ const sections = new Map();
358
+ const matches = [...body.matchAll(SECTION_PATTERN)];
359
+
360
+ for (const [index, match] of matches.entries()) {
361
+ const start = (match.index ?? 0) + match[0].length;
362
+ const end = matches[index + 1]?.index ?? body.length;
363
+ sections.set(match[1].trim(), body.slice(start, end).trim());
364
+ }
365
+
366
+ return sections;
367
+ }
368
+
369
+ function parseInvolvedFiles({ rawSection, defaultRepoIds, repoAliases, projectRepos }) {
370
+ if (!rawSection) {
371
+ return [];
372
+ }
373
+
374
+ const lines = rawSection.split(/\r?\n/);
375
+ const fallbackRepoId = defaultRepoIds.length === 1 ? defaultRepoIds[0] : null;
376
+ let currentRepoId = fallbackRepoId;
377
+ const normalized = [];
378
+
379
+ for (const line of lines) {
380
+ const trimmed = line.trim();
381
+ if (!trimmed) {
382
+ continue;
383
+ }
384
+
385
+ const repoHeading = trimmed.match(/^\*\*([^*]+):\*\*$/);
386
+ if (repoHeading) {
387
+ currentRepoId = normalizeRepoLabel(repoHeading[1], repoAliases) ?? currentRepoId;
388
+ continue;
389
+ }
390
+
391
+ const bullet = trimmed.match(/^[-*]\s+(.+)$/);
392
+ if (!bullet) {
393
+ continue;
394
+ }
395
+
396
+ const candidate = extractPathCandidate(bullet[1]);
397
+ if (!candidate) {
398
+ continue;
399
+ }
400
+
401
+ const value = normalizeLookupPath(candidate, repoAliases, projectRepos, currentRepoId ?? fallbackRepoId);
402
+ normalized.push(value);
403
+ }
404
+
405
+ return uniqueSorted(normalized);
406
+ }
407
+
408
+ function extractPathCandidate(text) {
409
+ const codeSpan = text.match(/`([^`]+)`/);
410
+ if (codeSpan) {
411
+ return codeSpan[1].trim();
412
+ }
413
+
414
+ const cleaned = text
415
+ .replace(/\s+—.*$/, '')
416
+ .replace(/\s+\(.*$/, '')
417
+ .trim();
418
+
419
+ return cleaned || null;
420
+ }
421
+
422
+ function normalizeLookupPath(filepath, repoAliases, projectRepos, fallbackRepoId = null) {
423
+ const trimmed = filepath.trim();
424
+ const repoCount = projectRepos.length;
425
+ const repoPathMatch = trimmed.match(/^([^:]+):(.+)$/);
426
+
427
+ if (repoPathMatch) {
428
+ const canonicalRepoId = normalizeRepoLabel(repoPathMatch[1], repoAliases) ?? repoPathMatch[1];
429
+ return `${canonicalRepoId}:${repoPathMatch[2]}`;
430
+ }
431
+
432
+ if (fallbackRepoId) {
433
+ return `${fallbackRepoId}:${trimmed}`;
434
+ }
435
+
436
+ if (repoCount === 1) {
437
+ return `${projectRepos[0].id}:${trimmed}`;
438
+ }
439
+
440
+ return trimmed;
441
+ }
442
+
443
+ function normalizeRepoLabel(label, repoAliases) {
444
+ return repoAliases.get(label.trim()) ?? repoAliases.get(label.trim().toLowerCase()) ?? null;
445
+ }
446
+
447
+ function buildRepoAliasMap(repos) {
448
+ const aliases = new Map();
449
+
450
+ for (const repo of repos) {
451
+ aliases.set(repo.id, repo.id);
452
+ aliases.set(repo.id.toLowerCase(), repo.id);
453
+
454
+ const remoteName = repo.remote
455
+ .replace(/\.git$/, '')
456
+ .split('/')
457
+ .pop();
458
+
459
+ if (remoteName) {
460
+ aliases.set(remoteName, repo.id);
461
+ aliases.set(remoteName.toLowerCase(), repo.id);
462
+ }
463
+ }
464
+
465
+ return aliases;
466
+ }
467
+
468
+ function summarizeWarnings(warnings) {
469
+ const counts = new Map();
470
+
471
+ for (const warning of warnings) {
472
+ counts.set(warning.code, (counts.get(warning.code) ?? 0) + 1);
473
+ }
474
+
475
+ return {
476
+ total: warnings.length,
477
+ by_code: [...counts.entries()]
478
+ .sort(([left], [right]) => left.localeCompare(right))
479
+ .map(([code, count]) => ({ code, count })),
480
+ };
481
+ }
482
+
483
+ function summarizeStatuses(statuses) {
484
+ if (statuses.includes('Blocked')) {
485
+ return 'Blocked';
486
+ }
487
+
488
+ if (statuses.includes('In progress')) {
489
+ return 'In progress';
490
+ }
491
+
492
+ if (statuses.length > 0 && statuses.every((status) => status === 'Complete')) {
493
+ return 'Complete';
494
+ }
495
+
496
+ if (statuses.length > 0 && statuses.every((status) => status === 'Not started')) {
497
+ return 'Not started';
498
+ }
499
+
500
+ return statuses[0] ?? null;
501
+ }
502
+
503
+ function summarizeStatusCounts(statuses) {
504
+ const counts = {};
505
+
506
+ for (const status of statuses) {
507
+ counts[status] = (counts[status] ?? 0) + 1;
508
+ }
509
+
510
+ return counts;
511
+ }
512
+
513
+ function createFeatureKey(domain, feature) {
514
+ return `${slugify(domain)}--${slugify(feature)}`;
515
+ }
516
+
517
+ function slugify(value) {
518
+ return value
519
+ .toLowerCase()
520
+ .replace(/[^a-z0-9]+/g, '-')
521
+ .replace(/^-+|-+$/g, '');
522
+ }
523
+
524
+ function normalizeSectionText(value) {
525
+ return value ? value.trim() : null;
526
+ }
527
+
528
+ function extractLabeledValue(body, label) {
529
+ const pattern = new RegExp(`\\*\\*${label}:\\*\\*\\s+(.+)`);
530
+ return body.match(pattern)?.[1]?.trim() ?? null;
531
+ }
532
+
533
+ function compareSessionsDescending(left, right) {
534
+ const leftDate = left.session_date ?? '';
535
+ const rightDate = right.session_date ?? '';
536
+
537
+ if (leftDate !== rightDate) {
538
+ return rightDate.localeCompare(leftDate);
539
+ }
540
+
541
+ return (right.session_number ?? 0) - (left.session_number ?? 0);
542
+ }
543
+
544
+ function uniqueSorted(values) {
545
+ return [...new Set(values)].sort((left, right) => left.localeCompare(right));
546
+ }
547
+
548
+ async function readManifestIfPresent(rootDir) {
549
+ const manifestPath = path.join(rootDir, 'state/manifest.json');
550
+
551
+ try {
552
+ const content = await readFile(manifestPath, 'utf8');
553
+ return JSON.parse(content);
554
+ } catch {
555
+ return null;
556
+ }
557
+ }