@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.
package/src/session.js ADDED
@@ -0,0 +1,553 @@
1
+ import { access, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { parseSimpleYaml } from './simple-yaml.js';
4
+ import { buildCloseSessionGuidance, resolveWorkflowSettings } from './workflow.js';
5
+ import { syncAgentInstructionFiles } from './generators/agent-files/index.js';
6
+
7
+ const CURRENT_SESSION_REQUIRED_FIELDS = ['Date:', 'Working on:', 'Last thing completed:', 'Blockers:', 'Next session should:'];
8
+ const WIP_HEADER_PATTERN = /^# WIP — (\d{4}-\d{2}-\d{2}) \(session (\d+)\)$/m;
9
+ const SESSION_FILENAME_PATTERN = /^(\d{4}-\d{2}-\d{2})-(\d+)-([a-z0-9-]+)\.md$/i;
10
+
11
+ export async function startProjectSession(options) {
12
+ const normalized = normalizeSessionPaths(options);
13
+ const workingOn = requireNonEmptyString(options?.workingOn, 'start-session requires a non-empty workingOn value.');
14
+ const sessionDate = normalizeSessionDate(options?.date);
15
+
16
+ await ensureInitializedProjectMemory(normalized.rootDir);
17
+ const claude = await readClaudeFile(normalized.claudePath);
18
+
19
+ if ((await fileExists(normalized.wipFilePath)) || (await fileExists(normalized.handoffFilePath))) {
20
+ throw new Error(
21
+ `An active session scratch file already exists under ${normalized.sessionsDir}. Close or recover the existing session before starting a new one.`,
22
+ );
23
+ }
24
+
25
+ const nextSessionNumber = await getNextSessionNumber(normalized.sessionsDir, sessionDate);
26
+ const currentSession = parseCurrentSessionBlock(claude.content);
27
+ const updatedClaude = replaceCurrentSessionBlock(claude.content, {
28
+ date: `${sessionDate} (session ${nextSessionNumber})`,
29
+ workingOn,
30
+ lastThingCompleted:
31
+ normalizeOptionalString(options?.lastThingCompleted) ??
32
+ currentSession.lastThingCompleted ??
33
+ 'Project memory bootstrap completed.',
34
+ blockers: normalizeOptionalString(options?.blockers) ?? 'No blocker yet.',
35
+ nextSessionShould:
36
+ normalizeOptionalString(options?.nextSessionShould) ??
37
+ 'Finish the active session and close it with a finalized session note.',
38
+ });
39
+
40
+ await mkdir(normalized.sessionsDir, { recursive: true });
41
+ await writeFile(normalized.claudePath, updatedClaude, 'utf8');
42
+ await writeFile(
43
+ normalized.wipFilePath,
44
+ renderWipTemplate({
45
+ sessionDate,
46
+ sessionNumber: nextSessionNumber,
47
+ workingOn,
48
+ }),
49
+ 'utf8',
50
+ );
51
+ await writeFile(
52
+ normalized.handoffFilePath,
53
+ renderHandoffTemplate({
54
+ sessionDate,
55
+ sessionNumber: nextSessionNumber,
56
+ workingOn,
57
+ }),
58
+ 'utf8',
59
+ );
60
+
61
+ return {
62
+ rootDir: normalized.rootDir,
63
+ toolingRootDir: normalized.toolingRootDir,
64
+ claudePath: normalized.claudePath,
65
+ wipFilePath: normalized.wipFilePath,
66
+ handoffFilePath: normalized.handoffFilePath,
67
+ sessionDate,
68
+ sessionNumber: nextSessionNumber,
69
+ };
70
+ }
71
+
72
+ export async function closeProjectSession(options) {
73
+ const normalized = normalizeSessionPaths(options);
74
+ const title = requireNonEmptyString(options?.title, 'close-session requires a non-empty title.');
75
+ const completed = normalizeStringArray(options?.completed);
76
+ const models = normalizeStringArray(options?.models);
77
+ const nextSteps = normalizeStringArray(options?.nextSteps);
78
+ const decisions = normalizeStringArray(options?.decisions);
79
+ const blockers = normalizeStringArray(options?.blockers);
80
+
81
+ if (completed.length === 0) {
82
+ throw new Error('close-session requires at least one completed item.');
83
+ }
84
+
85
+ if (nextSteps.length === 0) {
86
+ throw new Error('close-session requires at least one next-step entry.');
87
+ }
88
+
89
+ await ensureInitializedProjectMemory(normalized.rootDir, { allowMissingProjectFile: true });
90
+ const claude = await readClaudeFile(normalized.claudePath);
91
+ const workflowSettings = await readWorkflowSettings(normalized.projectFilePath);
92
+
93
+ if (!(await fileExists(normalized.wipFilePath))) {
94
+ throw new Error(
95
+ `No active session scratchpad exists at ${normalized.wipFilePath}. Start a session before trying to close it.`,
96
+ );
97
+ }
98
+
99
+ const wipContent = await readFile(normalized.wipFilePath, 'utf8');
100
+ const activeSession = parseActiveSession(wipContent);
101
+ const currentSession = parseCurrentSessionBlock(claude.content);
102
+ const workedOn =
103
+ normalizeOptionalString(options?.workedOn) ??
104
+ extractSectionBody(wipContent, 'Working on') ??
105
+ currentSession.workingOn;
106
+
107
+ if (!workedOn) {
108
+ throw new Error('close-session could not determine what the session worked on. Pass workedOn explicitly.');
109
+ }
110
+
111
+ const sessionSlug = slugifyTitle(title);
112
+ if (!sessionSlug) {
113
+ throw new Error('close-session title must contain at least one letter or number.');
114
+ }
115
+
116
+ const sessionRelativePath = toPosix(
117
+ path.join('sessions', `${activeSession.sessionDate}-${activeSession.sessionNumber}-${sessionSlug}.md`),
118
+ );
119
+ const sessionFilePath = path.join(normalized.rootDir, sessionRelativePath);
120
+
121
+ if (await fileExists(sessionFilePath)) {
122
+ throw new Error(`Session note already exists at ${sessionFilePath}.`);
123
+ }
124
+
125
+ await writeFile(
126
+ sessionFilePath,
127
+ renderSessionNote({
128
+ sessionDate: activeSession.sessionDate,
129
+ sessionNumber: activeSession.sessionNumber,
130
+ title,
131
+ workedOn,
132
+ completed,
133
+ decisions,
134
+ models: models.length > 0 ? models : ['Not recorded.'],
135
+ blockers,
136
+ nextSteps,
137
+ }),
138
+ 'utf8',
139
+ );
140
+
141
+ const hadHandoff = await fileExists(normalized.handoffFilePath);
142
+ await rm(normalized.wipFilePath, { force: true });
143
+ if (hadHandoff) {
144
+ await rm(normalized.handoffFilePath, { force: true });
145
+ }
146
+
147
+ const updatedClaude = replaceCurrentSessionBlock(claude.content, {
148
+ date: `${activeSession.sessionDate} (session ${activeSession.sessionNumber})`,
149
+ workingOn: 'Session closed. Ready for the next builder session.',
150
+ lastThingCompleted:
151
+ normalizeOptionalString(options?.lastThingCompleted) ??
152
+ `Closed session ${activeSession.sessionNumber} and wrote \`${sessionRelativePath}\`.`,
153
+ blockers: blockers.length > 0 ? summarizeList(blockers) : 'No blocker remains.',
154
+ nextSessionShould:
155
+ normalizeOptionalString(options?.nextSessionShould) ?? summarizeOrderedList(nextSteps),
156
+ });
157
+
158
+ await writeFile(normalized.claudePath, updatedClaude, 'utf8');
159
+ const agentFileSync = await syncAgentInstructionFilesSafely({
160
+ rootDir: normalized.rootDir,
161
+ toolingRootDir: normalized.toolingRootDir,
162
+ });
163
+
164
+ return {
165
+ rootDir: normalized.rootDir,
166
+ toolingRootDir: normalized.toolingRootDir,
167
+ claudePath: normalized.claudePath,
168
+ projectFilePath: normalized.projectFilePath,
169
+ sessionFilePath,
170
+ sessionRelativePath,
171
+ sessionDate: activeSession.sessionDate,
172
+ sessionNumber: activeSession.sessionNumber,
173
+ workflowGuidance: buildCloseSessionGuidance(workflowSettings),
174
+ agentFileSync,
175
+ removedScratchFiles: [
176
+ normalized.wipFilePath,
177
+ ...(hadHandoff ? [normalized.handoffFilePath] : []),
178
+ ],
179
+ };
180
+ }
181
+
182
+ async function syncAgentInstructionFilesSafely(options) {
183
+ try {
184
+ return await syncAgentInstructionFiles(options);
185
+ } catch (error) {
186
+ return {
187
+ rootDir: options.rootDir,
188
+ toolingRootDir: options.toolingRootDir,
189
+ dryRun: false,
190
+ results: [
191
+ {
192
+ format: 'all',
193
+ path: options.toolingRootDir,
194
+ relativePath: '.',
195
+ status: 'warning',
196
+ warning: `Agent instruction file sync skipped: ${error instanceof Error ? error.message : String(error)}`,
197
+ changed: false,
198
+ },
199
+ ],
200
+ };
201
+ }
202
+ }
203
+
204
+ function normalizeSessionPaths(options) {
205
+ const cwd = options?.cwd ? path.resolve(options.cwd) : process.cwd();
206
+
207
+ return {
208
+ rootDir: path.resolve(cwd, options?.rootDir ?? '.compass'),
209
+ toolingRootDir: options?.toolingRootDir
210
+ ? path.resolve(cwd, options.toolingRootDir)
211
+ : cwd,
212
+ projectFilePath: path.resolve(path.resolve(cwd, options?.rootDir ?? '.compass'), 'project.yaml'),
213
+ claudePath: path.resolve(
214
+ options?.toolingRootDir ? path.resolve(cwd, options.toolingRootDir) : cwd,
215
+ 'CLAUDE.md',
216
+ ),
217
+ sessionsDir: path.resolve(path.resolve(cwd, options?.rootDir ?? '.compass'), 'sessions'),
218
+ wipFilePath: path.resolve(path.resolve(cwd, options?.rootDir ?? '.compass'), 'sessions/wip.md'),
219
+ handoffFilePath: path.resolve(path.resolve(cwd, options?.rootDir ?? '.compass'), 'sessions/handoff.md'),
220
+ };
221
+ }
222
+
223
+ async function ensureInitializedProjectMemory(rootDir, options = {}) {
224
+ if (!(await fileExists(rootDir))) {
225
+ throw new Error(
226
+ `No project memory root was found at ${rootDir}. Run "vibecompass init" before using session commands.`,
227
+ );
228
+ }
229
+
230
+ const projectFilePath = path.join(rootDir, 'project.yaml');
231
+ if (!options.allowMissingProjectFile && !(await fileExists(projectFilePath))) {
232
+ throw new Error(
233
+ `No project.yaml was found at ${projectFilePath}. Run "vibecompass init" before using session commands.`,
234
+ );
235
+ }
236
+ }
237
+
238
+ async function readWorkflowSettings(projectFilePath) {
239
+ try {
240
+ const source = await readFile(projectFilePath, 'utf8');
241
+ const data = parseSimpleYaml(source, { sourceName: projectFilePath });
242
+ return resolveWorkflowSettings(data);
243
+ } catch {
244
+ return resolveWorkflowSettings(null);
245
+ }
246
+ }
247
+
248
+ async function readClaudeFile(claudePath) {
249
+ if (!(await fileExists(claudePath))) {
250
+ throw new Error(
251
+ `No CLAUDE.md was found at ${claudePath}. Re-run "vibecompass init --with-claude" or create a compatible Current session block first.`,
252
+ );
253
+ }
254
+
255
+ return {
256
+ path: claudePath,
257
+ content: await readFile(claudePath, 'utf8'),
258
+ };
259
+ }
260
+
261
+ function parseCurrentSessionBlock(content) {
262
+ const sessionFence = findCurrentSessionFence(content);
263
+ if (!sessionFence) {
264
+ throw new Error('CLAUDE.md is missing a recognizable "## Current session" fenced block.');
265
+ }
266
+
267
+ const fields = {};
268
+ for (const rawLine of sessionFence.body.trim().split('\n')) {
269
+ const separatorIndex = rawLine.indexOf(':');
270
+ if (separatorIndex <= 0) {
271
+ continue;
272
+ }
273
+
274
+ const label = rawLine.slice(0, separatorIndex).trim().toLowerCase();
275
+ const value = rawLine.slice(separatorIndex + 1).trim();
276
+ fields[label] = value;
277
+ }
278
+
279
+ return {
280
+ date: fields.date ?? null,
281
+ workingOn: fields['working on'] ?? null,
282
+ lastThingCompleted: fields['last thing completed'] ?? null,
283
+ blockers: fields.blockers ?? null,
284
+ nextSessionShould: fields['next session should'] ?? null,
285
+ };
286
+ }
287
+
288
+ function replaceCurrentSessionBlock(content, fields) {
289
+ const sessionFence = findCurrentSessionFence(content);
290
+ if (!sessionFence) {
291
+ throw new Error('CLAUDE.md is missing a recognizable "## Current session" fenced block.');
292
+ }
293
+
294
+ const blockBody = [
295
+ `Date: ${fields.date}`,
296
+ `Working on: ${fields.workingOn}`,
297
+ `Last thing completed: ${fields.lastThingCompleted}`,
298
+ `Blockers: ${fields.blockers}`,
299
+ `Next session should: ${fields.nextSessionShould}`,
300
+ ].join('\n');
301
+
302
+ return `${content.slice(0, sessionFence.bodyStart)}${blockBody}${content.slice(sessionFence.bodyEnd)}`;
303
+ }
304
+
305
+ async function getNextSessionNumber(sessionsDir, sessionDate) {
306
+ const sessions = await listFinalizedSessions(sessionsDir);
307
+ const todaysSessions = sessions.filter((session) => session.sessionDate === sessionDate);
308
+ const highestNumber = todaysSessions.reduce((max, session) => Math.max(max, session.sessionNumber), 0);
309
+ return highestNumber + 1;
310
+ }
311
+
312
+ async function listFinalizedSessions(sessionsDir) {
313
+ try {
314
+ const entries = await readdir(sessionsDir, { withFileTypes: true });
315
+ return entries
316
+ .filter((entry) => entry.isFile())
317
+ .map((entry) => entry.name.match(SESSION_FILENAME_PATTERN))
318
+ .filter(Boolean)
319
+ .map((match) => ({
320
+ sessionDate: match[1],
321
+ sessionNumber: Number(match[2]),
322
+ slug: match[3],
323
+ }))
324
+ .sort((left, right) => {
325
+ if (left.sessionDate !== right.sessionDate) {
326
+ return left.sessionDate.localeCompare(right.sessionDate);
327
+ }
328
+
329
+ return left.sessionNumber - right.sessionNumber;
330
+ });
331
+ } catch (error) {
332
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
333
+ return [];
334
+ }
335
+
336
+ throw error;
337
+ }
338
+ }
339
+
340
+ function parseActiveSession(wipContent) {
341
+ const match = wipContent.match(WIP_HEADER_PATTERN);
342
+ if (!match) {
343
+ throw new Error('sessions/wip.md is missing the expected "# WIP — YYYY-MM-DD (session N)" header.');
344
+ }
345
+
346
+ return {
347
+ sessionDate: match[1],
348
+ sessionNumber: Number(match[2]),
349
+ };
350
+ }
351
+
352
+ function extractSectionBody(content, sectionTitle) {
353
+ const heading = `## ${sectionTitle}`.toLowerCase();
354
+ const lines = content.split('\n');
355
+ const collected = [];
356
+ let inSection = false;
357
+
358
+ for (const line of lines) {
359
+ if (/^##\s+/.test(line)) {
360
+ if (inSection) {
361
+ break;
362
+ }
363
+
364
+ inSection = line.trim().toLowerCase() === heading;
365
+ continue;
366
+ }
367
+
368
+ if (inSection) {
369
+ collected.push(line);
370
+ }
371
+ }
372
+
373
+ const body = collected.join('\n').trim();
374
+ return body.length > 0 ? body : null;
375
+ }
376
+
377
+ function findCurrentSessionFence(content) {
378
+ const headingIndex = content.indexOf('## Current session');
379
+ if (headingIndex < 0) {
380
+ return null;
381
+ }
382
+
383
+ const afterHeading = content.slice(headingIndex);
384
+ const fencePattern = /```[^\n]*\n([\s\S]*?)\n```/g;
385
+
386
+ for (const match of afterHeading.matchAll(fencePattern)) {
387
+ const body = match[1];
388
+ if (!CURRENT_SESSION_REQUIRED_FIELDS.every((field) => body.includes(field))) {
389
+ continue;
390
+ }
391
+
392
+ const fullMatch = match[0];
393
+ const fullMatchIndex = match.index ?? 0;
394
+ const bodyOffset = fullMatch.indexOf(body);
395
+ const bodyStart = headingIndex + fullMatchIndex + bodyOffset;
396
+
397
+ return {
398
+ body,
399
+ bodyStart,
400
+ bodyEnd: bodyStart + body.length,
401
+ };
402
+ }
403
+
404
+ return null;
405
+ }
406
+
407
+ function renderWipTemplate(options) {
408
+ return `# WIP — ${options.sessionDate} (session ${options.sessionNumber})
409
+
410
+ ## Working on
411
+ ${options.workingOn}
412
+
413
+ ## Log
414
+ - Opened session ${options.sessionNumber}. Working on: ${options.workingOn}
415
+
416
+ ## Reviewer input needed
417
+ - None yet.
418
+
419
+ ## Review log
420
+ `;
421
+ }
422
+
423
+ function renderHandoffTemplate(options) {
424
+ return `# Handoff — ${options.sessionDate} (session ${options.sessionNumber})
425
+
426
+ ## Builder → Reviewer
427
+
428
+ ### What changed
429
+ - Opened session ${options.sessionNumber}. No implementation work has been recorded yet.
430
+
431
+ ### What needs review
432
+ - None yet.
433
+
434
+ ### What's next
435
+ - ${options.workingOn}
436
+
437
+ ## Reviewer → Builder
438
+
439
+ ### Findings summary
440
+ - Review not requested yet.
441
+
442
+ ### Recommended next step
443
+ - Continue the builder work and request review after the first substantive change block.
444
+ `;
445
+ }
446
+
447
+ function renderSessionNote(options) {
448
+ return `# Session — ${options.sessionDate}-${options.sessionNumber} — ${options.title}
449
+
450
+ ## What we worked on
451
+ ${options.workedOn}
452
+
453
+ ## Completed
454
+ ${renderBulletList(options.completed)}
455
+
456
+ ## Decisions made
457
+ ${renderBulletList(options.decisions, 'No new decisions were logged in this session.')}
458
+
459
+ ## Models used
460
+ ${renderBulletList(options.models)}
461
+
462
+ ## Blockers / open questions
463
+ ${renderBulletList(options.blockers, 'No blocker remains.')}
464
+
465
+ ## Next session should start with
466
+ ${renderOrderedList(options.nextSteps)}
467
+ `;
468
+ }
469
+
470
+ function renderBulletList(items, fallback) {
471
+ if (items.length === 0) {
472
+ return `- ${fallback}`;
473
+ }
474
+
475
+ return items.map((item) => `- ${item}`).join('\n');
476
+ }
477
+
478
+ function renderOrderedList(items) {
479
+ return items.map((item, index) => `${index + 1}. ${item}`).join('\n');
480
+ }
481
+
482
+ function summarizeList(items) {
483
+ return items.join('; ');
484
+ }
485
+
486
+ function summarizeOrderedList(items) {
487
+ return items.map((item, index) => `${index + 1}. ${item}`).join(' ');
488
+ }
489
+
490
+ function slugifyTitle(value) {
491
+ return value
492
+ .toLowerCase()
493
+ .replace(/[^a-z0-9]+/g, '-')
494
+ .replace(/^-+|-+$/g, '');
495
+ }
496
+
497
+ function normalizeStringArray(value) {
498
+ if (!Array.isArray(value)) {
499
+ return [];
500
+ }
501
+
502
+ return value
503
+ .map((item) => normalizeOptionalString(item))
504
+ .filter(Boolean);
505
+ }
506
+
507
+ function normalizeSessionDate(value) {
508
+ if (typeof value === 'string') {
509
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
510
+ throw new Error('Session date must use YYYY-MM-DD.');
511
+ }
512
+
513
+ return value;
514
+ }
515
+
516
+ const date = value instanceof Date ? value : new Date();
517
+ return [
518
+ date.getFullYear(),
519
+ String(date.getMonth() + 1).padStart(2, '0'),
520
+ String(date.getDate()).padStart(2, '0'),
521
+ ].join('-');
522
+ }
523
+
524
+ function requireNonEmptyString(value, message) {
525
+ const normalized = normalizeOptionalString(value);
526
+ if (!normalized) {
527
+ throw new Error(message);
528
+ }
529
+
530
+ return normalized;
531
+ }
532
+
533
+ function normalizeOptionalString(value) {
534
+ if (typeof value !== 'string') {
535
+ return null;
536
+ }
537
+
538
+ const trimmed = value.trim();
539
+ return trimmed.length > 0 ? trimmed : null;
540
+ }
541
+
542
+ async function fileExists(filePath) {
543
+ try {
544
+ await access(filePath);
545
+ return true;
546
+ } catch {
547
+ return false;
548
+ }
549
+ }
550
+
551
+ function toPosix(value) {
552
+ return value.split(path.sep).join('/');
553
+ }