@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/LICENSE +21 -0
- package/README.md +329 -0
- package/package.json +42 -0
- package/src/cli.js +572 -0
- package/src/frontmatter.js +28 -0
- package/src/generators/agent-files/agents-md.js +13 -0
- package/src/generators/agent-files/claude-md.js +13 -0
- package/src/generators/agent-files/copilot-instructions.js +13 -0
- package/src/generators/agent-files/cursor-rules.js +13 -0
- package/src/generators/agent-files/index.js +147 -0
- package/src/generators/agent-files/markers.js +49 -0
- package/src/generators/agent-files/template.js +75 -0
- package/src/hash.js +29 -0
- package/src/index.js +15 -0
- package/src/init.js +227 -0
- package/src/manifest.js +75 -0
- package/src/project-memory.js +678 -0
- package/src/project-yaml.js +107 -0
- package/src/read-model.js +557 -0
- package/src/scaffold.js +404 -0
- package/src/session.js +553 -0
- package/src/setup.js +719 -0
- package/src/simple-yaml.js +227 -0
- package/src/workflow.js +120 -0
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
|
+
}
|